mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
* feat(packages): add GitHub Releases binary installer New runtime source `github:owner/repo[@tag]` for installing Linux CLI binaries from GitHub Releases. Admin-only, SHA256-verified, ELF-validated. Backend: - GitHub API client with 10-min cache + rate-limit mapping - SSRF-guarded streaming downloader (HTTPS + host allowlist, re-validated on every redirect hop, literal-IP rejection) - Checksums.txt / SHA256SUMS lookup with constant-time verify - Archive extract (tar.gz / zip / raw) with path-traversal + zip-bomb guards, symlink skip - ELF magic + 64-bit class + runtime-arch validation - Atomic manifest persistence (temp + rename) HTTP: - POST /v1/packages/install accepts github: spec - GET /v1/packages/github-releases for picker UI (viewer+, arch-filtered) - Extended InstalledPackages response with github field - github-bin runtime probe Infra: - Dockerfile creates /app/data/.runtime/bin (goclaw:goclaw 0755) - docker-entrypoint.sh prepends bin dir to PATH - Env-only config (never config.json): token, max size, org allowlist, bin dir, manifest path UI: - GitHub Binaries section + release picker modal - Dismissable musl/glibc compatibility warning (localStorage) - i18n keys across en/vi/zh Docs: docs/packages-github.md user guide + 14-skills-runtime.md cross-ref. Closes #741 * refactor(packages): revert validPkgName broadening + drop unused sentinel Code review cleanup: - validPkgName regex had `:` added defensively, but github: specs are validated separately via skills.ParseGitHubSpec before reaching this check — the broadening was dead attack surface. - Drop unused ErrUnknownArchive sentinel + the `_ = ErrUnknownArchive` stub in extractRaw. * feat(packages): per-user rate limit on /v1/packages/github-releases Cap picker endpoint at 30 req/min/user (burst 10) to protect the shared GitHub API quota. Key is userID (header X-GoClaw-User-Id) or remote IP for anonymous callers. Returns 429 + Retry-After: 60 when tripped. Standalone token-bucket limiter (stale-entry cleanup every 5 min) lives in internal/http rather than importing internal/gateway, which would create a package cycle. * fix(ui): guard split()[0] for noUncheckedIndexedAccess strict TS CI pnpm build failed on TS2345: `.split('@')[0]` returns `string | undefined` under strict index access. Default to empty string to satisfy the type checker; runtime behaviour unchanged because the downstream regex rejects empty strings. * fix(packages): address Claude review — medium + low findings Medium - rate limiter: atomic.Int64 lastSeen + amortized sweep replaces goroutine-based cleanup → fixes data race on lastSeen and the goroutine leak when tests swap the package-level limiter. - checksum pipeline: slog.Warn on ReadFile and ParseChecksums failures (previously silent). "asset not listed" stays warn+proceed but is now documented as the publisher's choice — ELF validation remains the final gate. - downloader: drop http.Client.Timeout (30s capped the whole request including body read, aborting large downloads on slow links). Context deadline from install timeout (5 min) is the correct bound. Low / style / UI - extractRaw honors maxUncompressed (ErrFileTooLarge on overflow) so the helper is safe outside the hot path. - cmd/gateway_github_installer.go: drop the explicit cfg.Defaults() call — NewGitHubInstaller already invokes it. - GitHubPackageEntry: remove unpopulated InstalledBy field + document why. - owner regex tightened to 39-char GitHub limit (was 40). - mu lock comment corrected: serializes only the disk-write phase. - UI: shared stripPrefixAndTag helper + owner regex mirrors the backend 39-char cap; destructure-with-default kills the split()[0] ?? "" awkwardness while still satisfying noUncheckedIndexedAccess. Verified: go build (pg + sqliteonly) · go vet · go test -race ./internal/skills ./internal/http · pnpm build. * fix(packages): address Claude review round 2 Medium - validRepoPath now rejects trailing hyphens in the owner segment and caps at 39 chars, matching gitHubSpecRE exactly. Previously a subtle drift between the two validators could let `foo-/repo` slip to the GitHub API and surface as a 502 instead of a clean 400. - handleGitHubReleases no longer forwards raw err.Error() from the upstream call. Maps sentinel errors: ErrGitHubRateLimited → 429 + Retry-After ErrGitHubNotFound → 404 ErrGitHubUnauthorized → 502 "github authentication failed" default → 502 "failed to fetch releases" Avoids leaking rate-limit reset timestamps / server internals to viewer-tier callers. Low / UX - Install response now returns the manifest entry for github: specs (new lookupGitHubEntry helper; nil-safe fallback to {ok:true}). Lets the UI display "installed: lazygit v0.42.0" without a list refresh. - gitHubSpecRE tag segment capped at 1..255 chars (git ref-name bound). UI isValidFullSpec mirrors the same cap. * fix(packages): address Claude review round 3 Medium - github_api: URL-encode owner, repo, and tag via url.PathEscape when building API paths. Previously a tag containing '#' would be stripped as a URL fragment and '?' would inject a query parameter, silently hitting the wrong release. Low / polish - Uninstall via full "github:owner/repo[@tag]" spec now falls back to manifest lookup by owner/repo, handling packages whose binary name differs from the repo name (cli/cli → gh). - GitHubClient.cache sweeps expired entries opportunistically when the map grows past 256 entries (prevents theoretical unbounded growth over long uptime). - handleInstall for github: specs now calls GitHubInstaller.Install directly and returns the freshly-created manifest entry, eliminating the double manifest read via List() from the lookupGitHubEntry helper. - pickBinaries comment corrected — actual behavior excludes paths matched by nonBinaryPathRE rather than enforcing a single-depth limit. * fix(packages): address Claude review round 4 (style + ordering) All 4 findings are Low severity: - github_api.go: replace interface{} with any across the cache type, cacheGet return, cacheSet param, and doJSON out param. - doJSON: rename local `url` to `apiURL` to avoid shadowing the "net/url" package import used by GetRelease/ListReleases. - Uninstall: save the updated manifest BEFORE removing binaries on disk. If saveManifest fails we now bail out without leaving a manifest entry that still claims binaries which have been deleted (a retried Uninstall would otherwise hit ErrPackageNotInstalled after the first attempt wiped the files). Disk removal stays best-effort and warn-on-error, which matches the idempotent intent. - pickBinaries: inline comment corrected to reflect actual behavior — depth is not enforced; nonBinaryPathRE filter + downstream ELF validation are the real gates. * fix(packages): address Claude review round 5 All 3 findings are Low severity: - handleInstall github fast-path now wraps the context with skills.InstallTimeout (5 min) before calling gh.Install and emits the same "skills: installing dep" / "dep installed" / "github install failed" log lines as the generic InstallSingleDep path, so operator-observability is identical between github: and pip:/npm: install flows. - installTimeout promoted to exported InstallTimeout so the http layer shares the single source of truth rather than duplicating the 5-minute constant. - cacheMaxEntries comment clarifies it is a soft sweep trigger, not a hard cap — when every entry is still within TTL the map can briefly exceed the threshold by one insert. * fix(packages): address Claude review round 6 (final Lows) Both findings are Low severity (reviewer marked the PR "ready to merge" already): - github_installer: "no checksum asset available" downgraded from slog.Warn to slog.Info. Many popular upstream releases (jq, fzf, older ripgrep, etc.) ship no checksum file at all — that is publisher policy, not a problem with the install. The suspicious cases (checksum file unreadable, unparseable, or missing this asset) stay at Warn so they stand out. - handleGitHubReleases response now uses a narrow assetPreview DTO (name + size_bytes) instead of embedding the full GitHubAsset type which also carried browser_download_url. The picker UI never rendered the URL; trimming the response keeps the viewer-tier surface minimal. UI AssetPreview interface realigned to match. * fix(packages): address Claude review round 7 Narrow the GET /v1/packages GitHub entry to a viewer-safe projection (repo/tag/binaries/name/installed_at), mirroring the assetPreview fix from round 6. Strips asset_url, sha256, and asset_name from the list response — viewer-level callers no longer see CDN download URLs or checksum metadata for installed packages. UI types realigned; the removed fields were never rendered. Finding #2 (install writes binary before manifest save) left as noted — reviewer confirmed informational only, self-heals on retry, no security impact since binaries pass ELF validation before being written. * fix(packages): address Claude review round 8 Map HTTP 429 (GitHub secondary rate limits — abuse detection, unauthenticated bursts, search) to ErrGitHubRateLimited in the API client so the picker endpoint renders 429 "rate limit reached" with Retry-After: 60 instead of falling through to 502 "failed to fetch releases". Primary rate limits (403 + X-RateLimit-Remaining: 0) were already handled; this covers the secondary class documented at https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#about-secondary-rate-limits * fix(packages): address Claude review round 9 Two defensive hardenings flagged as Very Low: - ParseChecksums: strip leading `./` from checksum filenames. `sha256sum ./file` emits `./file` in the name column; the caller looks up by bare asset basename so `./`-prefixed entries would silently miss. Real release checksums almost never use this form, but the guard is essentially free. - doJSON: cap response body at 8 MiB via io.LimitReader before JSON decode. Current GitHub list/release payloads are well under this (~1 MiB at per_page=100). Guards against future call sites or a misbehaving upstream returning an oversized document. * fix(cron): eliminate cross-test race on runLoopTickInterval `Service.Stop()` closes stopChan but does not wait for the runLoop goroutine to exit. In the test suite, test A's `defer cs.Stop()` can return before the spawned runLoop has reached `ticker := time.NewTicker(runLoopTickInterval)`. If test B then calls `setFastTick()` to mutate the package-level var, the race detector correctly flags it: Read at runLoopTickInterval by goroutine A (runLoop ticker init) Previous write by goroutine B (setFastTick in test B) Fix: snapshot `runLoopTickInterval` inside `Start()` under the mutex before spawning the goroutine, and pass the value as a parameter to `runLoop`. The spawned goroutine no longer reads the package-level var, so the cross-test window is closed. Production behavior unchanged. Verified: `go test -race -count=3 ./internal/cron/...` passes three times in a row; the CI failure on PR #898 reproduced before the fix and is gone after. * fix(packages): address review P0/P1/P2 + new DoS vector P0.1 — UI uninstall 400: parseAndValidatePackage now accepts github:<bare-name> (manifest Name form, no owner/repo) in addition to the full spec. UI sends github:${pkg.name} from the manifest; dispatcher already tolerated bare names — the HTTP validator was the only gate rejecting them. Install path re-validates strictly via ParseGitHubSpec and bare-name install returns 400 now (was 500). P1.1 — ExtractArchive raw-ELF fallback name: add ExtractArchiveAs( path, fallbackName, max). Installer passes parsed.Repo so raw (non-archive) ELF assets no longer end up recorded as /tmp/goclaw-gh-asset-XXXX.bin — that basename would leak into the manifest Binaries entry and break PATH lookup. P1.3 — Archive entry count cap: maxArchiveEntries = 10_000 + ErrTooManyEntries sentinel. Tar: count ALL headers seen (incl. symlinks/dirs we skip) to block the gzip-bomb-of-headers DoS — header bytes don't count against maxUncompressed for zero-size entries. Zip: pre-check via peekZipEntryCount reads the EOCD record manually and rejects oversized archives BEFORE zip.OpenReader allocates []*zip.File of declared capacity (this was a fresh red-team finding; stdlib would otherwise alloc ~1GB for a crafted 200MB zip claiming 4M entries). P1.4 — Rate-limit install/uninstall: packagesWriteLimiter (10/min/user, burst 3). Admin-only mitigates but a compromised token could otherwise flood upstream (GitHub/pip/npm) or spam manifest mutations. P1.6 — Non-Linux early reject: ErrUnsupportedOS guard at the top of Install(). Windows/macOS hosts no longer waste bandwidth fetching a Linux asset just to fail at the ELF machine check. P1.7 — Manifest fsync: OpenFile → Write → Sync → Close → Rename → dir Sync, with tmp cleanup on every error path. POSIX doesn't guarantee durability via rename alone; XFS / ext4 with async journal can reorder. P2.1 — Belt-and-suspenders zip runtime break when cumulative bytes reach the cap (pre-declared check already covers it but the streaming loop now bails immediately). P2.6 — Binary-name collision warn: slog.Warn when a different repo already owns the basename we're about to overwrite. Last-writer- wins unchanged; operator now gets a signal instead of silence. Hardening — rate-limit key: rateLimitKeyFromRequest prefers store.UserIDFromContext over the raw X-GoClaw-User-Id header so an admin can't rotate the header mid-session to dodge the bucket. Header/IP fallback retained for pre-auth / test callers. Tests: 9 new cases on parseAndValidatePackage (github full/bare/ empty/traversal/injection/space/leading-hyphen); TestExtractArchiveAs_RawELFUsesFallbackName; TestExtractTarGz_EntryCountCap + TestExtractZip_EntryCountCap; TestPeekZipEntryCount (DoS pre-check path). Verified: go build ./... && go build -tags sqliteonly ./... && go vet ./... && go test -race ./internal/skills/... ./internal/http/... --------- Co-authored-by: viettranx <viettranx@gmail.com>
363 lines
11 KiB
Go
363 lines
11 KiB
Go
package skills
|
||
|
||
import (
|
||
"archive/tar"
|
||
"archive/zip"
|
||
"bytes"
|
||
"compress/gzip"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"log/slog"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
// Sentinel errors for archive extraction.
|
||
var (
|
||
ErrUnsafePath = errors.New("archive: unsafe path rejected")
|
||
ErrZipBomb = errors.New("archive: uncompressed size exceeds limit (zip bomb protection)")
|
||
ErrFileTooLarge = errors.New("archive: single file exceeds limit")
|
||
ErrTooManyEntries = errors.New("archive: entry count exceeds limit (DoS protection)")
|
||
)
|
||
|
||
// maxArchiveEntries caps regular file entries per archive. Chosen to cover
|
||
// real-world tooling (Go toolchain has ~4k, Python wheel ~6k) while
|
||
// preventing a 400M×1-byte file entry DoS where `append` growth + per-entry
|
||
// struct alloc (~64B) would OOM the server.
|
||
const maxArchiveEntries = 10_000
|
||
|
||
// ArchiveFile is a single extracted entry held in memory.
|
||
type ArchiveFile struct {
|
||
Name string
|
||
Mode fs.FileMode
|
||
Size int64
|
||
Content []byte
|
||
}
|
||
|
||
// Magic byte sequences for format detection.
|
||
var (
|
||
magicGzip = []byte{0x1f, 0x8b}
|
||
magicZip = []byte{0x50, 0x4b, 0x03, 0x04}
|
||
magicELF = []byte{0x7f, 0x45, 0x4c, 0x46}
|
||
)
|
||
|
||
// ExtractArchive detects the format by magic bytes + extension fallback and extracts.
|
||
// maxUncompressed caps total uncompressed bytes (zip-bomb protection).
|
||
// Raw (non-archive) inputs are named after filepath.Base(path). Use
|
||
// ExtractArchiveAs when the path is a temp file and a logical name should be
|
||
// recorded instead.
|
||
func ExtractArchive(path string, maxUncompressed int64) ([]ArchiveFile, error) {
|
||
return ExtractArchiveAs(path, "", maxUncompressed)
|
||
}
|
||
|
||
// ExtractArchiveAs is ExtractArchive with a caller-supplied fallbackName for
|
||
// raw (non-archive) binary inputs. This matters when `path` is a temp file
|
||
// like `/tmp/goclaw-gh-asset-XXXX.bin` — without a logical name the resulting
|
||
// ArchiveFile.Name leaks the temp filename into downstream install logic.
|
||
// Empty fallbackName falls back to filepath.Base(path).
|
||
func ExtractArchiveAs(path, fallbackName string, maxUncompressed int64) ([]ArchiveFile, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer f.Close()
|
||
|
||
var head [4]byte
|
||
n, _ := io.ReadFull(f, head[:])
|
||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||
return nil, err
|
||
}
|
||
prefix := head[:n]
|
||
|
||
rawName := fallbackName
|
||
if rawName == "" {
|
||
rawName = filepath.Base(path)
|
||
}
|
||
|
||
switch {
|
||
case bytes.HasPrefix(prefix, magicGzip):
|
||
return extractTarGz(f, maxUncompressed)
|
||
case bytes.HasPrefix(prefix, magicZip):
|
||
return extractZip(path, maxUncompressed)
|
||
case bytes.HasPrefix(prefix, magicELF):
|
||
return extractRaw(f, rawName, maxUncompressed)
|
||
}
|
||
// Fallback on extension.
|
||
lower := strings.ToLower(path)
|
||
switch {
|
||
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
|
||
return extractTarGz(f, maxUncompressed)
|
||
case strings.HasSuffix(lower, ".zip"):
|
||
return extractZip(path, maxUncompressed)
|
||
}
|
||
// Last resort: treat as raw binary.
|
||
return extractRaw(f, rawName, maxUncompressed)
|
||
}
|
||
|
||
// sanitizePath rejects absolute, parent-escaping, and Windows-drive paths.
|
||
// Returns a cleaned relative path safe to join with an install dir.
|
||
func sanitizePath(name string) (string, error) {
|
||
if name == "" {
|
||
return "", fmt.Errorf("%w: empty", ErrUnsafePath)
|
||
}
|
||
// Reject null bytes explicitly.
|
||
if strings.ContainsRune(name, 0x00) {
|
||
return "", fmt.Errorf("%w: null byte", ErrUnsafePath)
|
||
}
|
||
// Reject Windows drive prefix like "C:\".
|
||
if len(name) >= 2 && name[1] == ':' {
|
||
return "", fmt.Errorf("%w: windows drive %q", ErrUnsafePath, name)
|
||
}
|
||
// Normalize both separators.
|
||
normalized := strings.ReplaceAll(name, "\\", "/")
|
||
// Reject absolute.
|
||
if strings.HasPrefix(normalized, "/") {
|
||
return "", fmt.Errorf("%w: absolute path %q", ErrUnsafePath, name)
|
||
}
|
||
// Reject any "../" component.
|
||
for _, part := range strings.Split(normalized, "/") {
|
||
if part == ".." {
|
||
return "", fmt.Errorf("%w: traversal component in %q", ErrUnsafePath, name)
|
||
}
|
||
}
|
||
cleaned := path.Clean(normalized)
|
||
// path.Clean of a non-absolute path should remain non-absolute.
|
||
if strings.HasPrefix(cleaned, "../") || cleaned == ".." || strings.HasPrefix(cleaned, "/") {
|
||
return "", fmt.Errorf("%w: escapes base after clean %q → %q", ErrUnsafePath, name, cleaned)
|
||
}
|
||
return cleaned, nil
|
||
}
|
||
|
||
// extractTarGz streams a gzip'd tar and returns all regular-file entries.
|
||
func extractTarGz(r io.Reader, maxUncompressed int64) ([]ArchiveFile, error) {
|
||
gzr, err := gzip.NewReader(r)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("gzip: %w", err)
|
||
}
|
||
defer gzr.Close()
|
||
tr := tar.NewReader(gzr)
|
||
|
||
var out []ArchiveFile
|
||
var total int64
|
||
// iterations counts ALL tar headers seen (including symlinks/dirs we
|
||
// skip). A crafted archive can inflate a tiny gzip payload into billions
|
||
// of zero-size symlink headers (header bytes count against
|
||
// maxUncompressed only for regular-file payloads); without a header cap,
|
||
// the loop itself becomes the DoS.
|
||
var iterations int
|
||
for {
|
||
hdr, err := tr.Next()
|
||
if errors.Is(err, io.EOF) {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("tar: %w", err)
|
||
}
|
||
iterations++
|
||
if iterations > maxArchiveEntries {
|
||
return nil, ErrTooManyEntries
|
||
}
|
||
|
||
switch hdr.Typeflag {
|
||
case tar.TypeReg, tar.TypeRegA:
|
||
// supported
|
||
case tar.TypeSymlink, tar.TypeLink:
|
||
slog.Warn("archive: skipping link entry", "name", hdr.Name, "type", string(hdr.Typeflag))
|
||
continue
|
||
default:
|
||
// Skip directories and other special types.
|
||
continue
|
||
}
|
||
|
||
clean, err := sanitizePath(hdr.Name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if hdr.Size < 0 {
|
||
return nil, fmt.Errorf("tar: negative size for %q", hdr.Name)
|
||
}
|
||
if total+hdr.Size > maxUncompressed {
|
||
return nil, ErrZipBomb
|
||
}
|
||
total += hdr.Size
|
||
|
||
buf := make([]byte, 0, hdr.Size)
|
||
w := bytes.NewBuffer(buf)
|
||
// Use limited reader so a truncated tar doesn't spin forever.
|
||
if _, err := io.Copy(w, io.LimitReader(tr, hdr.Size)); err != nil {
|
||
return nil, fmt.Errorf("tar read %q: %w", hdr.Name, err)
|
||
}
|
||
out = append(out, ArchiveFile{
|
||
Name: clean,
|
||
Mode: fs.FileMode(hdr.Mode) & fs.ModePerm,
|
||
Size: hdr.Size,
|
||
Content: w.Bytes(),
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// peekZipEntryCount reads only the end-of-central-directory record and
|
||
// returns the declared entry count WITHOUT allocating per-entry structs.
|
||
// Returns (-1, nil) when EOCD lookup fails gracefully — callers should fall
|
||
// back to the stdlib parser which has its own stricter format checks.
|
||
//
|
||
// This is a critical DoS guard: zip.OpenReader alloc's a []*File of declared
|
||
// capacity BEFORE our higher-level pre-check runs. A crafted zip claiming
|
||
// 4M entries in a 200MB file could otherwise pin ~1GB of heap per call.
|
||
func peekZipEntryCount(filePath string) (int, error) {
|
||
f, err := os.Open(filePath)
|
||
if err != nil {
|
||
return -1, err
|
||
}
|
||
defer f.Close()
|
||
st, err := f.Stat()
|
||
if err != nil {
|
||
return -1, err
|
||
}
|
||
size := st.Size()
|
||
if size < 22 {
|
||
return -1, nil // too small for any valid zip EOCD
|
||
}
|
||
// EOCD record is 22 bytes + variable-length comment (up to 65535 bytes).
|
||
// Scan the last ≤65557 bytes from the back for the EOCD signature.
|
||
const eocdSigMax = 65557
|
||
scanFrom := int64(0)
|
||
if size > eocdSigMax {
|
||
scanFrom = size - eocdSigMax
|
||
}
|
||
buf := make([]byte, size-scanFrom)
|
||
if _, err := f.ReadAt(buf, scanFrom); err != nil && !errors.Is(err, io.EOF) {
|
||
return -1, err
|
||
}
|
||
// EOCD magic: 0x06054b50 (little-endian on-disk: 50 4b 05 06).
|
||
sig := []byte{0x50, 0x4b, 0x05, 0x06}
|
||
for i := len(buf) - 22; i >= 0; i-- {
|
||
if buf[i] == sig[0] && bytes.Equal(buf[i:i+4], sig) {
|
||
// EOCD layout (offsets from record start):
|
||
// 10: total number of entries in central directory (u16)
|
||
// A value of 0xFFFF indicates ZIP64 — we conservatively bail
|
||
// and let the stdlib parser decide (it handles ZIP64 and has
|
||
// its own entries-vs-size sanity check).
|
||
n := binary.LittleEndian.Uint16(buf[i+10 : i+12])
|
||
if n == 0xFFFF {
|
||
return -1, nil
|
||
}
|
||
return int(n), nil
|
||
}
|
||
}
|
||
return -1, nil
|
||
}
|
||
|
||
// extractZip opens a zip file and returns all regular file entries.
|
||
func extractZip(filePath string, maxUncompressed int64) ([]ArchiveFile, error) {
|
||
// Pre-check entry count BEFORE zip.OpenReader to prevent the stdlib
|
||
// from pre-allocating [N]*zip.File for a crafted declared count.
|
||
if n, err := peekZipEntryCount(filePath); err == nil && n > maxArchiveEntries {
|
||
return nil, ErrTooManyEntries
|
||
}
|
||
|
||
zr, err := zip.OpenReader(filePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("zip open: %w", err)
|
||
}
|
||
defer zr.Close()
|
||
|
||
// Post-open recheck covers ZIP64 (where peek returns -1 and the stdlib
|
||
// parsed the true count) plus declared-vs-actual mismatches.
|
||
if len(zr.File) > maxArchiveEntries {
|
||
return nil, ErrTooManyEntries
|
||
}
|
||
var sum uint64
|
||
for _, f := range zr.File {
|
||
if f.Mode().IsDir() {
|
||
continue
|
||
}
|
||
sum += f.UncompressedSize64
|
||
if sum > uint64(maxUncompressed) {
|
||
return nil, ErrZipBomb
|
||
}
|
||
}
|
||
|
||
var out []ArchiveFile
|
||
var total int64
|
||
for _, f := range zr.File {
|
||
if f.Mode().IsDir() {
|
||
continue
|
||
}
|
||
if f.Mode()&fs.ModeSymlink != 0 {
|
||
slog.Warn("archive: skipping symlink entry", "name", f.Name)
|
||
continue
|
||
}
|
||
// Guard against exhausting the cap through streaming reads alone
|
||
// (pre-check above covers declared counts — this covers runtime).
|
||
if total >= maxUncompressed {
|
||
return nil, ErrZipBomb
|
||
}
|
||
clean, err := sanitizePath(f.Name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if f.UncompressedSize64 > uint64(maxUncompressed) {
|
||
return nil, ErrFileTooLarge
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("zip open %q: %w", f.Name, err)
|
||
}
|
||
|
||
// Also enforce streaming cap in case declared size lies.
|
||
lr := io.LimitReader(rc, maxUncompressed-total+1)
|
||
buf, err := io.ReadAll(lr)
|
||
rc.Close()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("zip read %q: %w", f.Name, err)
|
||
}
|
||
if int64(len(buf)) > maxUncompressed-total {
|
||
return nil, ErrZipBomb
|
||
}
|
||
total += int64(len(buf))
|
||
|
||
out = append(out, ArchiveFile{
|
||
Name: clean,
|
||
Mode: f.Mode().Perm(),
|
||
Size: int64(len(buf)),
|
||
Content: buf,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// extractRaw reads the entire file as a single binary entry.
|
||
// maxBytes guards against oversized inputs (belt-and-braces with the downloader
|
||
// cap) so this helper is safe to call from any context.
|
||
func extractRaw(f *os.File, name string, maxBytes int64) ([]ArchiveFile, error) {
|
||
if maxBytes <= 0 {
|
||
maxBytes = 200 * 1024 * 1024
|
||
}
|
||
b, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if int64(len(b)) > maxBytes {
|
||
return nil, ErrFileTooLarge
|
||
}
|
||
clean, err := sanitizePath(name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return []ArchiveFile{{
|
||
Name: clean,
|
||
Mode: 0o755,
|
||
Size: int64(len(b)),
|
||
Content: b,
|
||
}}, nil
|
||
}
|