Files
goclaw/internal/skills/archive_extract_test.go
Duy /zuey/andviettranx 2cbf838158 feat(packages): GitHub Releases binary installer (#898)
* 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>
2026-04-16 15:09:48 +07:00

373 lines
8.9 KiB
Go

package skills
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// writeTarGz builds a .tar.gz file with the given entries.
func writeTarGz(t *testing.T, entries map[string]string) string {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, body := range entries {
hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
tw.Close()
gz.Close()
f, err := os.CreateTemp("", "goclaw-test-*.tar.gz")
if err != nil {
t.Fatal(err)
}
if _, err := f.Write(buf.Bytes()); err != nil {
t.Fatal(err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
return f.Name()
}
// writeZip builds a .zip with the given entries.
func writeZip(t *testing.T, entries map[string]string) string {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, body := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(body))
}
zw.Close()
f, err := os.CreateTemp("", "goclaw-test-*.zip")
if err != nil {
t.Fatal(err)
}
if _, err := f.Write(buf.Bytes()); err != nil {
t.Fatal(err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
return f.Name()
}
func TestExtractTarGz_HappyPath(t *testing.T) {
path := writeTarGz(t, map[string]string{
"lazygit": "ELF\x7fhello",
"LICENSE": "MIT",
"README.md": "readme",
})
files, err := ExtractArchive(path, 10*1024*1024)
if err != nil {
t.Fatal(err)
}
if len(files) != 3 {
t.Fatalf("want 3 files, got %d", len(files))
}
var gotNames []string
for _, f := range files {
gotNames = append(gotNames, f.Name)
}
for _, want := range []string{"lazygit", "LICENSE", "README.md"} {
found := false
for _, g := range gotNames {
if g == want {
found = true
break
}
}
if !found {
t.Errorf("missing %s in %v", want, gotNames)
}
}
}
func TestExtractZip_HappyPath(t *testing.T) {
path := writeZip(t, map[string]string{
"rg": "binary-content",
"doc.md": "doc",
})
files, err := ExtractArchive(path, 1024*1024)
if err != nil {
t.Fatal(err)
}
if len(files) != 2 {
t.Fatalf("want 2, got %d", len(files))
}
}
// -------- Security: path traversal --------
func TestSanitizePath_Malicious(t *testing.T) {
bad := []string{
"../../../etc/passwd",
"/etc/passwd",
"..",
"../outside",
"safe/../../etc/shadow",
"C:\\Windows\\cmd.exe",
"with\x00null",
"",
}
for _, p := range bad {
if _, err := sanitizePath(p); err == nil {
t.Errorf("sanitizePath(%q) should reject", p)
}
}
ok := []string{"lazygit", "dir/sub/file", "nested/tool.bin"}
for _, p := range ok {
if _, err := sanitizePath(p); err != nil {
t.Errorf("sanitizePath(%q) should accept: %v", p, err)
}
}
}
func TestExtractTarGz_PathTraversal(t *testing.T) {
path := writeTarGz(t, map[string]string{"../../../etc/evil": "pwn"})
_, err := ExtractArchive(path, 1024)
if !errors.Is(err, ErrUnsafePath) {
t.Errorf("want ErrUnsafePath, got %v", err)
}
}
func TestExtractZip_PathTraversal(t *testing.T) {
path := writeZip(t, map[string]string{"../escape.txt": "pwn"})
_, err := ExtractArchive(path, 1024)
if !errors.Is(err, ErrUnsafePath) {
t.Errorf("want ErrUnsafePath, got %v", err)
}
}
// -------- Security: zip bomb / size cap --------
func TestExtractTarGz_ZipBomb_ByCumulativeSize(t *testing.T) {
entries := map[string]string{}
body := strings.Repeat("A", 1024)
for i := 0; i < 100; i++ {
entries[fileNameN(i)] = body // 100 KB total
}
path := writeTarGz(t, entries)
_, err := ExtractArchive(path, 10*1024) // 10 KB cap
if !errors.Is(err, ErrZipBomb) {
t.Errorf("want ErrZipBomb, got %v", err)
}
}
func TestExtractZip_ZipBomb(t *testing.T) {
entries := map[string]string{}
body := strings.Repeat("X", 1024)
for i := 0; i < 100; i++ {
entries[fileNameN(i)] = body
}
path := writeZip(t, entries)
_, err := ExtractArchive(path, 10*1024)
if !errors.Is(err, ErrZipBomb) {
t.Errorf("want ErrZipBomb, got %v", err)
}
}
// -------- Security: ELF validation --------
func TestValidateELF_NonELFRejected(t *testing.T) {
vectors := map[string][]byte{
"PDF": []byte("%PDF-1.4\n"),
"shell": []byte("#!/bin/bash\necho hi\n"),
"PE": []byte("MZ\x90\x00"),
"machO": {0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01},
"truncated": {0x7f, 0x45, 0x4c},
"empty": {},
}
for name, v := range vectors {
if err := validateELF(v); err == nil {
t.Errorf("vector %s should be rejected as non-ELF", name)
}
}
}
// -------- Security: extractRaw for unknown bytes --------
func TestExtractRaw_NonArchive(t *testing.T) {
tmp, err := os.CreateTemp("", "raw-*.bin")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmp.Name())
tmp.Write([]byte("plain bytes"))
tmp.Close()
files, err := ExtractArchive(tmp.Name(), 1024)
if err != nil {
t.Fatal(err)
}
if len(files) != 1 || string(files[0].Content) != "plain bytes" {
t.Errorf("unexpected files: %+v", files)
}
}
// -------- Helpers --------
func fileNameN(i int) string {
// Avoid importing "strconv" for tiny helper.
return filepath.Join("d", []string{"a", "b", "c"}[i%3], "f", rune2s(i))
}
func rune2s(i int) string {
var buf [6]byte
n := 0
if i == 0 {
return "0"
}
for i > 0 {
buf[n] = byte('0' + i%10)
n++
i /= 10
}
// reverse
var out []byte
for k := n - 1; k >= 0; k-- {
out = append(out, buf[k])
}
return string(out)
}
// Ensure the compress/bytes/io imports are kept — used only in helpers above.
var _ = io.EOF
// -------- P1.1: Raw ELF uses caller-supplied logical name --------
func TestExtractArchiveAs_RawELFUsesFallbackName(t *testing.T) {
// Write a tiny "ELF" (magic bytes only — format parsing is done by
// validateELF in callers, extractRaw just copies bytes).
tmp, err := os.CreateTemp("", "goclaw-gh-asset-*.bin")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmp.Name())
tmp.Write([]byte{0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00}) // \x7fELF header stub
tmp.Close()
// Without fallback → takes temp basename (the bug we're fixing).
files, err := ExtractArchive(tmp.Name(), 1024)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(filepath.Base(files[0].Name), "goclaw-gh-asset-") {
t.Errorf("expected temp basename leakage when no fallback, got %q", files[0].Name)
}
// With fallback → records logical name.
files, err = ExtractArchiveAs(tmp.Name(), "lazygit", 1024)
if err != nil {
t.Fatal(err)
}
if files[0].Name != "lazygit" {
t.Errorf("ExtractArchiveAs with fallbackName=lazygit recorded %q, want %q",
files[0].Name, "lazygit")
}
}
// -------- P1.3: Archive entry count cap (DoS protection) --------
func TestExtractTarGz_EntryCountCap(t *testing.T) {
// Build a tar archive with more than maxArchiveEntries tiny entries.
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for i := 0; i < maxArchiveEntries+5; i++ {
name := "f" + rune2s(i)
hdr := &tar.Header{Name: name, Mode: 0o644, Size: 1, Typeflag: tar.TypeReg}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
tw.Write([]byte{'x'})
}
tw.Close()
gz.Close()
f, err := os.CreateTemp("", "many-*.tar.gz")
if err != nil {
t.Fatal(err)
}
defer os.Remove(f.Name())
f.Write(buf.Bytes())
f.Close()
_, err = ExtractArchive(f.Name(), 100*1024*1024) // Large size cap — only entry cap should trip.
if !errors.Is(err, ErrTooManyEntries) {
t.Errorf("want ErrTooManyEntries, got %v", err)
}
}
func TestExtractZip_EntryCountCap(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for i := 0; i < maxArchiveEntries+5; i++ {
w, err := zw.Create("f" + rune2s(i))
if err != nil {
t.Fatal(err)
}
w.Write([]byte{'x'})
}
zw.Close()
f, err := os.CreateTemp("", "many-*.zip")
if err != nil {
t.Fatal(err)
}
defer os.Remove(f.Name())
f.Write(buf.Bytes())
f.Close()
_, err = ExtractArchive(f.Name(), 100*1024*1024)
if !errors.Is(err, ErrTooManyEntries) {
t.Errorf("want ErrTooManyEntries, got %v", err)
}
}
// TestPeekZipEntryCount covers the DoS-preemption path: the EOCD-level check
// must reject oversized archives BEFORE zip.OpenReader allocates
// []*zip.File of declared capacity.
func TestPeekZipEntryCount(t *testing.T) {
// Small zip: peek returns exact count.
small := writeZip(t, map[string]string{"a": "1", "b": "2", "c": "3"})
n, err := peekZipEntryCount(small)
if err != nil {
t.Fatalf("peek small: %v", err)
}
if n != 3 {
t.Errorf("peek small count = %d, want 3", n)
}
// Non-zip file: peek returns -1 gracefully (no false positive).
raw, err := os.CreateTemp("", "raw-*.bin")
if err != nil {
t.Fatal(err)
}
defer os.Remove(raw.Name())
raw.Write([]byte("not a zip file, just random bytes"))
raw.Close()
n, err = peekZipEntryCount(raw.Name())
if err != nil {
t.Fatalf("peek raw: %v", err)
}
if n != -1 {
t.Errorf("peek raw should return -1 (EOCD not found), got %d", n)
}
}