From e96eb78b99488038dd96749ae76bbb45c3c90af0 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 18 Oct 2026 12:49:34 +0100 Subject: [PATCH 1/5] Add streaming archive browser with reusable inputs --- README.md | 48 ++++++++ browse.go | 216 +++++++++++++++++++++++++++++++++++ browse_test.go | 303 +++++++++++++++++++++++++++++++++++++++++++++++++ browse_walk.go | 71 ++++++++++++ prefix.go | 12 +- stream.go | 30 +++-- 6 files changed, 664 insertions(+), 16 deletions(-) create mode 100644 browse.go create mode 100644 browse_test.go create mode 100644 browse_walk.go diff --git a/README.md b/README.md index 6b84d51..cb1bb18 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,54 @@ Compressed content is opened as TAR and returns a parser error when it does not contain a TAR archive. `Open` reads at most 512 bytes before rejecting an unsupported stream with no recognised extension. +### Streaming browsing + +`OpenBrowser` lists directories and extracts individual files without retaining +expanded bodies. It uses the same formats and limits as `OpenStream`. + +```go +reopen := func() (io.ReadCloser, error) { return os.Open("package.tar.gz") } +content, err := reopen() +if err != nil { + return err +} +defer content.Close() +browser, err := archives.OpenBrowser("package.tar.gz", content, reopen, archives.BrowseOptions{ + Limits: archives.StreamOptions{MaxInputBytes: 64 << 20}, + DetectRoot: true, +}) +if err != nil { + return err +} +files, err := browser.ListDir("") +if err != nil { + return err +} +fmt.Println(files) +file, err := browser.Extract("README.md") +if err != nil { + return err +} +defer file.Close() +``` + +`DetectRoot` strips one shared top-level directory. Top-level files, including +an archive containing only `README.md`, remain visible. `StripPrefix` sets an +explicit prefix such as `package/` and takes precedence over root detection. +`CommonRoot(files)` exposes the same detection for metadata from a buffered reader. + +Listing retains metadata in archive order, including duplicate files. Extraction +returns the first matching entry and leaves later entries unchecked. Missing paths +wrap `fs.ErrNotExist`; directories wrap `archives.ErrIsDir`. Limits apply per pass. + +The caller owns the initial input, including when construction fails. Keep it open +until the first operation and any extracted file finish. TAR and gem use `reopen` +for later passes; the browser closes those readers. Root detection adds a pass +before extraction. A nil callback is allowed, but attempting another sequential +pass wraps `errors.ErrUnsupported`. ZIP and conda buffer their compressed input +once and reuse it across passes. Close each extracted file before another +operation; a browser is not safe for concurrent use. + ### Sequential reading `OpenStream` reads entries one at a time through `Next` and `Read`, without diff --git a/browse.go b/browse.go new file mode 100644 index 0000000..380dcdc --- /dev/null +++ b/browse.go @@ -0,0 +1,216 @@ +package archives + +import ( + "errors" + "fmt" + "io" + "io/fs" + "strings" +) + +var ErrIsDir = errors.New("path is a directory") + +var errStopBrowse = errors.New("stop browse") + +// BrowseOptions controls per-pass limits and path prefixes. +type BrowseOptions struct { + Limits StreamOptions + // StripPrefix takes precedence over DetectRoot. Include the trailing slash. + StripPrefix string + // DetectRoot strips a directory shared by every entry. + DetectRoot bool +} + +// Browser lists directories and streams individual files without retaining +// expanded bodies. Finish and close each extracted file before the next call. +// A Browser is not safe for concurrent use. +type Browser struct { + filename string + options BrowseOptions + reopen func() (io.ReadCloser, error) + first io.Reader + data []byte +} + +// OpenBrowser prepares an archive for browsing. The caller owns content and +// must keep it open until the first operation and any extracted file finish. +// Later TAR and gem passes call reopen; the browser closes those readers. +// ZIP and conda input is buffered once and reused without calling reopen. +// A nil reopen is allowed, but a second sequential pass wraps errors.ErrUnsupported. +func OpenBrowser(filename string, content io.Reader, reopen func() (io.ReadCloser, error), options BrowseOptions) (*Browser, error) { + limits, err := options.Limits.defaults() + if err != nil { + return nil, err + } + options.Limits = limits + format, content, err := streamFormat(filename, &streamInput{reader: content, remaining: limits.MaxInputBytes}) + if err != nil { + return nil, err + } + b := &Browser{filename: filename, options: options, reopen: reopen} + if format != formatZIP && format != formatConda { + b.first = content + return b, nil + } + b.data, err = io.ReadAll(content) + if err != nil { + return nil, err + } + return b, nil +} + +func (b *Browser) openStream() (*Stream, func() error, error) { + if b.data != nil { + stream, err := OpenStreamBytes(b.filename, b.data, b.options.Limits) + if err != nil { + return nil, nil, err + } + return stream, stream.Close, nil + } + if b.first != nil { + content := b.first + b.first = nil + stream, err := OpenStream(b.filename, content, b.options.Limits) + if err != nil { + return nil, nil, err + } + return stream, stream.Close, nil + } + if b.reopen == nil { + return nil, nil, fmt.Errorf("%w: reopening archive requires a callback", errors.ErrUnsupported) + } + content, err := b.reopen() + if err != nil { + return nil, nil, fmt.Errorf("reopening archive: %w", err) + } + stream, err := OpenStream(b.filename, content, b.options.Limits) + if err != nil { + _ = content.Close() + return nil, nil, err + } + return stream, func() error { return errors.Join(stream.Close(), content.Close()) }, nil +} + +func (b *Browser) prefix() (string, error) { + if b.options.StripPrefix != "" || !b.options.DetectRoot { + return b.options.StripPrefix, nil + } + var root rootDetector + err := b.walk(func(entry *StreamEntry) error { + if !root.add(entry.FileInfo) { + return errStopBrowse + } + return nil + }) + return root.prefix(), err +} + +// ListDir returns immediate children in archive order, retaining only metadata. +// Root detection and listing share one pass. Duplicate files are preserved. +func (b *Browser) ListDir(dirPath string) ([]FileInfo, error) { + var entries []FileInfo + err := b.walk(func(entry *StreamEntry) error { + entries = append(entries, entry.FileInfo) + return nil + }) + if err != nil { + return nil, err + } + prefix := b.options.StripPrefix + if prefix == "" && b.options.DetectRoot { + prefix = CommonRoot(entries) + } + files := listDir(entries, prefix+dirPath) + if prefix == "" { + return files, nil + } + return stripFilePrefix(files, prefix), nil +} + +// Extract streams the first matching file. The caller must close the result. +// Root detection requires a separate pass, so sequential input needs reopen. +// Missing paths wrap fs.ErrNotExist; directories wrap ErrIsDir. Skipped entries +// count against limits. Entries after the target and trailers are not checked. +func (b *Browser) Extract(filePath string) (io.ReadCloser, error) { + prefix, err := b.prefix() + if err != nil { + return nil, err + } + stream, release, err := b.openStream() + if err != nil { + return nil, err + } + for { + entry, err := stream.Next() + if err != nil { + _ = release() + if err == io.EOF { + return nil, fmt.Errorf("%w: %s", fs.ErrNotExist, filePath) + } + return nil, err + } + if entry.Path != prefix+filePath { + continue + } + if entry.IsDir { + _ = release() + return nil, fmt.Errorf("%w: %s", ErrIsDir, filePath) + } + return &browsedFile{Reader: stream, release: release}, nil + } +} + +type browsedFile struct { + io.Reader + release func() error +} + +func (f *browsedFile) Close() error { + if f.release == nil { + return nil + } + release := f.release + f.release = nil + return release() +} + +// CommonRoot returns the shared top-level directory, including its slash. +// Empty archives and archives containing top-level files have no common root. +func CommonRoot(files []FileInfo) string { + var root rootDetector + for _, file := range files { + if !root.add(file) { + break + } + } + return root.prefix() +} + +type rootDetector struct { + root string + mixed bool +} + +func (d *rootDetector) add(file FileInfo) bool { + if d.mixed { + return false + } + dir, _, nested := strings.Cut(file.Path, "/") + if dir == "" || (!nested && !file.IsDir) { + d.mixed = true + return false + } + if d.root == "" { + d.root = dir + } else if dir != d.root { + d.mixed = true + } + return !d.mixed +} + +func (d *rootDetector) prefix() string { + if d.mixed || d.root == "" { + return "" + } + return d.root + "/" +} diff --git a/browse_test.go b/browse_test.go new file mode 100644 index 0000000..8f75bc6 --- /dev/null +++ b/browse_test.go @@ -0,0 +1,303 @@ +package archives + +import ( + "archive/tar" + "bytes" + "errors" + "io" + "io/fs" + "reflect" + "strings" + "testing" +) + +func browseTar(t *testing.T, names ...string) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, name := range names { + if strings.HasSuffix(name, "/") { + if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeDir}); err != nil { + t.Fatal(err) + } + } else { + writeTarFile(t, tw, name, "body: "+name, 0o644) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestBrowserFormats(t *testing.T) { + raw := browseTar(t, "root/", "root/README.md", "root/src/main.go", "root/src/util.go") + for _, name := range []string{"test.tar", "test.tar.gz", "test.tar.xz", "test.tar.zst", "test.zip", "test.gem", "test.conda"} { + t.Run(name, func(t *testing.T) { + data := streamTestArchive(t, name, raw) + first := &countStreamInput{Reader: bytes.NewReader(data)} + var reopened []*countStreamInput + reopen := func() (io.ReadCloser, error) { + r := &countStreamInput{Reader: bytes.NewReader(data)} + reopened = append(reopened, r) + return r, nil + } + b, err := OpenBrowser(name, first, reopen, BrowseOptions{DetectRoot: true}) + if err != nil { + t.Fatal(err) + } + buffered, err := OpenBytesWithPrefix(name, data, "root/") + if err != nil { + t.Fatal(err) + } + defer func() { _ = buffered.Close() }() + assertBrowserListings(t, b, buffered) + assertBrowsedBody(t, b, "README.md", "body: root/README.md") + if first.closed { + t.Error("browser closed caller's input") + } + if name == "test.zip" || name == "test.conda" { + if len(reopened) != 0 { + t.Error("random-access input was reopened") + } + } else if len(reopened) != 4 { + t.Errorf("reopened %d times, want 4", len(reopened)) + } + for _, r := range reopened { + if !r.closed { + t.Error("reopened input left open") + } + } + }) + } +} + +func assertBrowserListings(t *testing.T, b *Browser, buffered Reader) { + t.Helper() + for _, dir := range []string{"", "src", "missing"} { + got, err := b.ListDir(dir) + if err != nil { + t.Fatal(err) + } + want, err := buffered.ListDir(dir) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("ListDir(%q) = %+v, want %+v; %v", dir, got, want, err) + } + } +} + +func assertBrowsedBody(t *testing.T, b *Browser, name, want string) { + t.Helper() + r, err := b.Extract(name) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(r) + if closeErr := r.Close(); closeErr != nil { + t.Fatal(closeErr) + } + if err != nil || string(got) != want { + t.Fatalf("Extract(%q) = %q, %v; want %q", name, got, err, want) + } +} + +func TestBrowserRootDetection(t *testing.T) { + for _, tc := range []struct { + names, paths []string + prefix string + detect bool + }{ + {[]string{"README.md"}, []string{"README.md"}, "", true}, + {[]string{"root/README.md"}, []string{"README.md"}, "root/", true}, + {[]string{"root/", "root/README.md"}, []string{"README.md"}, "root/", true}, + {[]string{"README.md", "src/main.go"}, []string{"README.md", "src/"}, "", true}, + {[]string{"root/README.md", "other/main.go"}, []string{"root/", "other/"}, "", true}, + {[]string{"root/README.md"}, []string{"root/"}, "", false}, + {nil, nil, "", true}, + } { + t.Run(strings.Join(tc.names, ","), func(t *testing.T) { + data := browseTar(t, tc.names...) + reopen := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil } + b, err := OpenBrowser("test.tar", bytes.NewReader(data), reopen, BrowseOptions{DetectRoot: tc.detect}) + if err != nil { + t.Fatal(err) + } + files, err := b.ListDir("") + if err != nil { + t.Fatal(err) + } + var paths []string + for _, file := range files { + paths = append(paths, file.Path) + } + if !reflect.DeepEqual(paths, tc.paths) { + t.Fatalf("paths = %v, want %v", paths, tc.paths) + } + for _, name := range tc.names { + if !strings.HasSuffix(name, "/") { + assertBrowsedBody(t, b, strings.TrimPrefix(name, tc.prefix), "body: "+name) + } + } + }) + } +} + +func TestBrowserBzip2(t *testing.T) { + data := createTestTarBz2(t) + reopen := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil } + b, err := OpenBrowser("test.tar.bz2", bytes.NewReader(data), reopen, BrowseOptions{}) + if err != nil { + t.Fatal(err) + } + r, err := OpenBytes("test.tar.bz2", data) + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close() }() + assertBrowserListings(t, b, r) + files, err := r.List() + if err != nil { + t.Fatal(err) + } + for _, f := range files { + if f.IsDir { + continue + } + file, err := r.Extract(f.Path) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(file) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + assertBrowsedBody(t, b, f.Path, string(body)) + } +} + +func TestBrowserPrefixAndDuplicates(t *testing.T) { + data := browseTar(t, "package/a", "package/a", "other/b") + b, err := OpenBrowser("test.tar", bytes.NewReader(data), func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + }, BrowseOptions{StripPrefix: "package/", DetectRoot: true}) + if err != nil { + t.Fatal(err) + } + files, err := b.ListDir("") + if err != nil || len(files) != 2 || files[0].Path != "a" || files[1].Path != "a" { + t.Fatalf("listing = %+v, %v", files, err) + } + assertBrowsedBody(t, b, "a", "body: package/a") + if _, err := b.Extract("other/b"); !errors.Is(err, fs.ErrNotExist) { + t.Fatal(err) + } +} + +func TestBrowserContentDetection(t *testing.T) { + for _, format := range []string{"test.zip", "test.tar.gz"} { + data := streamTestArchive(t, format, browseTar(t, "README.md")) + for _, name := range []string{"artifact", "package.apk"} { + b, err := OpenBrowser(name, bytes.NewReader(data), nil, BrowseOptions{}) + if err != nil { + t.Fatal(err) + } + assertBrowsedBody(t, b, "README.md", "body: README.md") + } + } +} + +func TestBrowserLimits(t *testing.T) { + for _, name := range []string{"test.tar", "test.zip", "test.conda"} { + data := streamTestArchive(t, name, browseTar(t, "a", "b")) + for _, tc := range []struct { + limits StreamOptions + want error + }{ + {StreamOptions{MaxInputBytes: 1}, ErrInputLimit}, + {StreamOptions{MaxEntryBytes: 1}, ErrEntrySizeLimit}, + {StreamOptions{MaxExpandedBytes: 1}, ErrDecompressLimit}, + {StreamOptions{MaxEntries: 1}, ErrEntryLimit}, + } { + b, err := OpenBrowser(name, bytes.NewReader(data), nil, BrowseOptions{Limits: tc.limits}) + if err == nil { + _, err = b.ListDir("") + } + if !errors.Is(err, tc.want) { + t.Errorf("%s: error = %v, want %v", name, err, tc.want) + } + } + } + if _, err := OpenBrowser("test.tar", bytes.NewReader(nil), nil, BrowseOptions{Limits: StreamOptions{MaxEntries: -1}}); err == nil { + t.Fatal("negative limit accepted") + } +} + +func TestBrowserReopenErrors(t *testing.T) { + data := browseTar(t, "dir/", "dir/a") + wantErr := errors.New("storage unavailable") + for _, tc := range []struct { + reopen func() (io.ReadCloser, error) + want error + }{ + {nil, errors.ErrUnsupported}, + {func() (io.ReadCloser, error) { return nil, wantErr }, wantErr}, + } { + b, err := OpenBrowser("test.tar", bytes.NewReader(data), tc.reopen, BrowseOptions{DetectRoot: true}) + if err != nil { + t.Fatal(err) + } + if _, err := b.Extract("a"); !errors.Is(err, tc.want) { + t.Fatalf("Extract error = %v, want %v", err, tc.want) + } + } + closed := &countStreamInput{Reader: strings.NewReader("not gzip")} + b, err := OpenBrowser("test.tar.gz", bytes.NewReader(gzipTar(t, data)), func() (io.ReadCloser, error) { return closed, nil }, BrowseOptions{DetectRoot: true}) + if err != nil { + t.Fatal(err) + } + if _, err := b.Extract("a"); err == nil || !closed.closed { + t.Fatalf("error=%v, closed=%t", err, closed.closed) + } +} + +func TestBrowserExtractClosesReopenedInput(t *testing.T) { + data := browseTar(t, "dir/", "dir/a") + for _, target := range []string{"dir/a", "dir/", "missing"} { + input := &countStreamInput{Reader: bytes.NewReader(data)} + b, err := OpenBrowser("test.tar", bytes.NewReader(data), func() (io.ReadCloser, error) { return input, nil }, BrowseOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := b.ListDir(""); err != nil { + t.Fatal(err) + } + file, err := b.Extract(target) + switch target { + case "dir/": + if !errors.Is(err, ErrIsDir) { + t.Fatal(err) + } + case "missing": + if !errors.Is(err, fs.ErrNotExist) { + t.Fatal(err) + } + default: + if err != nil || input.closed { + t.Fatalf("error=%v closed=%t", err, input.closed) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if _, err := file.Read(make([]byte, 1)); !errors.Is(err, fs.ErrClosed) { + t.Fatal(err) + } + } + if !input.closed { + t.Error("reopened input left open") + } + } +} diff --git a/browse_walk.go b/browse_walk.go new file mode 100644 index 0000000..ccb4e7a --- /dev/null +++ b/browse_walk.go @@ -0,0 +1,71 @@ +package archives + +import ( + "errors" + "io" + "strings" +) + +// walk calls fn for each entry in archive order until fn returns an error or +// the archive ends. fn may read the entry body from the stream. +func (a *Browser) walk(fn func(entry *StreamEntry) error) error { + stream, release, err := a.openStream() + if err != nil { + return err + } + defer func() { _ = release() }() + + for { + entry, err := stream.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if err := fn(entry); err != nil { + if errors.Is(err, errStopBrowse) { + return nil + } + return err + } + } +} + +func listDir(entries []FileInfo, dirPath string) []FileInfo { + dirPath = normalizeDir(dirPath) + var files []FileInfo + seenDirs := make(map[string]bool) + + for _, f := range entries { + p := f.Path + + if isInDir(p, dirPath) { + if f.IsDir { + name := strings.TrimSuffix(strings.TrimPrefix(p, dirPath), "/") + if seenDirs[name] { + continue + } + seenDirs[name] = true + } + files = append(files, f) + continue + } + + if dirPath != "" && !strings.HasPrefix(p, dirPath) { + continue + } + rel := strings.TrimSuffix(strings.TrimPrefix(p, dirPath), "/") + name, _, nested := strings.Cut(rel, "/") + if nested && !seenDirs[name] { + seenDirs[name] = true + files = append(files, FileInfo{ + Path: dirPath + name + "/", + Name: name, + IsDir: true, + }) + } + } + + return files +} diff --git a/prefix.go b/prefix.go index 486678f..a83b137 100644 --- a/prefix.go +++ b/prefix.go @@ -17,7 +17,7 @@ func (p *prefixStripper) List() ([]FileInfo, error) { return nil, err } - return p.stripPrefix(files), nil + return stripFilePrefix(files, p.prefix), nil } func (p *prefixStripper) ListDir(dirPath string) ([]FileInfo, error) { @@ -28,7 +28,7 @@ func (p *prefixStripper) ListDir(dirPath string) ([]FileInfo, error) { return nil, err } - return p.stripPrefix(files), nil + return stripFilePrefix(files, p.prefix), nil } func (p *prefixStripper) Extract(filePath string) (io.ReadCloser, error) { @@ -45,19 +45,19 @@ func (p *prefixStripper) Close() error { return p.reader.Close() } -// stripPrefix removes the prefix from all file paths. -func (p *prefixStripper) stripPrefix(files []FileInfo) []FileInfo { +// stripFilePrefix removes the prefix from all file paths. +func stripFilePrefix(files []FileInfo, prefix string) []FileInfo { result := make([]FileInfo, 0, len(files)) for _, f := range files { // Skip files that don't have the prefix - if !strings.HasPrefix(f.Path, p.prefix) { + if !strings.HasPrefix(f.Path, prefix) { continue } // Strip the prefix stripped := f - stripped.Path = strings.TrimPrefix(f.Path, p.prefix) + stripped.Path = strings.TrimPrefix(f.Path, prefix) stripped.Name = extractName(stripped.Path) // Skip if path is now empty (was the prefix directory itself) diff --git a/stream.go b/stream.go index 0afee00..75ae129 100644 --- a/stream.go +++ b/stream.go @@ -72,18 +72,11 @@ func OpenStreamBytes(filename string, content []byte, options StreamOptions) (*S } func openStream(filename string, content io.Reader, raw []byte, options StreamOptions) (*Stream, error) { - format := detectFormat(filename) - if format == "" { - buffered := bufio.NewReaderSize(content, contentSniffSize) - prefix, err := buffered.Peek(contentSniffSize) - if err != nil && err != io.EOF { - return nil, fmt.Errorf("reading archive content: %w", err) - } - format = detectContentPrefixFormat(prefix) - content = buffered + format, content, err := streamFormat(filename, content) + if err != nil { + return nil, err } var source streamSource - var err error switch format { case formatZIP, formatConda: if raw == nil { @@ -108,6 +101,23 @@ func openStream(filename string, content io.Reader, raw []byte, options StreamOp return &Stream{source: source, options: options}, nil } +func streamFormat(filename string, content io.Reader) (string, io.Reader, error) { + format := detectFormat(filename) + if format == "" { + buffered := bufio.NewReaderSize(content, contentSniffSize) + prefix, err := buffered.Peek(contentSniffSize) + if err != nil && err != io.EOF { + return "", nil, fmt.Errorf("reading archive content: %w", err) + } + format = detectContentPrefixFormat(prefix) + content = buffered + } + if format == "" { + return "", nil, fmt.Errorf("%w: archive format: %s", errors.ErrUnsupported, filename) + } + return format, content, nil +} + func (o StreamOptions) defaults() (StreamOptions, error) { if o.MaxInputBytes < 0 || o.MaxEntryBytes < 0 || o.MaxExpandedBytes < 0 || o.MaxEntries < 0 { return o, errors.New("stream limits must not be negative") From 8dcd88e765d3714791e6d24199a7220981040e01 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 18 Oct 2026 12:59:44 +0100 Subject: [PATCH 2/5] Add full metadata listings to the streaming browser --- README.md | 6 ++-- browse.go | 29 ++++++++++++++--- browse_list_test.go | 79 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 browse_list_test.go diff --git a/README.md b/README.md index cb1bb18..c0a1c08 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ unsupported stream with no recognised extension. ### Streaming browsing -`OpenBrowser` lists directories and extracts individual files without retaining +`OpenBrowser` lists entries and directories and extracts files without retaining expanded bodies. It uses the same formats and limits as `OpenStream`. ```go @@ -108,7 +108,9 @@ an archive containing only `README.md`, remain visible. `StripPrefix` sets an explicit prefix such as `package/` and takes precedence over root detection. `CommonRoot(files)` exposes the same detection for metadata from a buffered reader. -Listing retains metadata in archive order, including duplicate files. Extraction +`List()` returns all entries, including nested paths, in one pass. `ListDir(path)` +returns immediate children. Both retain metadata in archive order, including +duplicate files, and apply the configured prefix stripping. Extraction returns the first matching entry and leaves later entries unchecked. Missing paths wrap `fs.ErrNotExist`; directories wrap `archives.ErrIsDir`. Limits apply per pass. diff --git a/browse.go b/browse.go index 380dcdc..c992a1d 100644 --- a/browse.go +++ b/browse.go @@ -105,21 +105,42 @@ func (b *Browser) prefix() (string, error) { return root.prefix(), err } -// ListDir returns immediate children in archive order, retaining only metadata. -// Root detection and listing share one pass. Duplicate files are preserved. -func (b *Browser) ListDir(dirPath string) ([]FileInfo, error) { +func (b *Browser) entries() ([]FileInfo, string, error) { var entries []FileInfo err := b.walk(func(entry *StreamEntry) error { entries = append(entries, entry.FileInfo) return nil }) if err != nil { - return nil, err + return nil, "", err } prefix := b.options.StripPrefix if prefix == "" && b.options.DetectRoot { prefix = CommonRoot(entries) } + return entries, prefix, nil +} + +// List returns all entries in archive order, retaining only metadata. +// Root detection and listing share one pass. Duplicate entries are preserved. +func (b *Browser) List() ([]FileInfo, error) { + files, prefix, err := b.entries() + if err != nil { + return nil, err + } + if prefix == "" { + return files, nil + } + return stripFilePrefix(files, prefix), nil +} + +// ListDir returns immediate children in archive order, retaining only metadata. +// Root detection and listing share one pass. Duplicate files are preserved. +func (b *Browser) ListDir(dirPath string) ([]FileInfo, error) { + entries, prefix, err := b.entries() + if err != nil { + return nil, err + } files := listDir(entries, prefix+dirPath) if prefix == "" { return files, nil diff --git a/browse_list_test.go b/browse_list_test.go new file mode 100644 index 0000000..7166544 --- /dev/null +++ b/browse_list_test.go @@ -0,0 +1,79 @@ +package archives + +import ( + "bytes" + "errors" + "io" + "reflect" + "testing" +) + +func TestBrowserList(t *testing.T) { + for _, name := range []string{"test.tar", "test.tar.gz", "test.zip", "test.gem", "test.conda"} { + for _, tc := range []struct { + label string + names []string + options BrowseOptions + prefix string + }{ + {"all entries", []string{"root/", "root/a", "root/src/b", "root/a"}, BrowseOptions{}, ""}, + {"common root", []string{"root/", "root/a", "root/src/b", "root/a"}, BrowseOptions{DetectRoot: true}, "root/"}, + {"explicit prefix", []string{"other/x", "package/a", "package/src/b"}, BrowseOptions{StripPrefix: "package/", DetectRoot: true}, "package/"}, + {"root file", []string{"README.md"}, BrowseOptions{DetectRoot: true}, ""}, + {"empty", nil, BrowseOptions{DetectRoot: true}, ""}, + } { + t.Run(name+"/"+tc.label, func(t *testing.T) { + data := streamTestArchive(t, name, browseTar(t, tc.names...)) + b, err := OpenBrowser(name, bytes.NewReader(data), func() (io.ReadCloser, error) { + t.Error("List reopened its input") + return nil, errors.New("unexpected reopen") + }, tc.options) + if err != nil { + t.Fatal(err) + } + got, err := b.List() + if err != nil { + t.Fatal(err) + } + if len(tc.names) == 0 { + if len(got) != 0 { + t.Fatalf("empty archive listing = %+v", got) + } + return + } + assertFullListing(t, name, data, tc.prefix, got) + }) + } + } +} + +func assertFullListing(t *testing.T, name string, data []byte, prefix string, got []FileInfo) { + t.Helper() + r, err := OpenBytesWithPrefix(name, data, prefix) + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close() }() + want, err := r.List() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("List = %+v, want %+v", got, want) + } +} + +func TestBrowserListLimits(t *testing.T) { + data := browseTar(t, "root/a", "root/b") + for _, limit := range []StreamOptions{ + {MaxEntries: 1}, {MaxInputBytes: 512}, {MaxEntryBytes: 1}, {MaxExpandedBytes: 1}, + } { + b, err := OpenBrowser("test.tar", bytes.NewReader(data), nil, BrowseOptions{Limits: limit, DetectRoot: true}) + if err != nil { + t.Fatal(err) + } + if files, err := b.List(); err == nil || files != nil { + t.Fatalf("List returned partial result: %+v, %v", files, err) + } + } +} From dfa18fa400ca21d8bf64d88d06dc6b8b67bba561 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 18 Oct 2026 13:04:07 +0100 Subject: [PATCH 3/5] Add cancellation to archive streams and browsers --- README.md | 14 ++++ browse.go | 25 +++++-- context.go | 38 ++++++++++ context_test.go | 189 ++++++++++++++++++++++++++++++++++++++++++++++++ stream.go | 43 +++++++++-- stream_tar.go | 14 ++-- stream_zip.go | 17 +++-- zip.go | 8 ++ 8 files changed, 324 insertions(+), 24 deletions(-) create mode 100644 context.go create mode 100644 context_test.go diff --git a/README.md b/README.md index c0a1c08..4bbb07e 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,12 @@ pass wraps `errors.ErrUnsupported`. ZIP and conda buffer their compressed input once and reuse it across passes. Close each extracted file before another operation; a browser is not safe for concurrent use. +Use `OpenBrowserContext(ctx, ...)` to cancel listing, prefix detection, and file +reads, including decompression of buffered ZIP and conda input. The same context +applies to every pass. Supply a request context or one with a deadline, and make +the reopen callback honor it if opening can block. Close extracted files after +cancellation too; the browser still leaves the initial input owned by the caller. + ### Sequential reading `OpenStream` reads entries one at a time through `Next` and `Read`, without @@ -167,6 +173,14 @@ buffers that input within `MaxInputBytes`; `OpenStreamBytes(name, data, options) reuses an existing byte slice without copying it. Keep that slice unchanged until `Close`. TAR and gem can consume a forward-only input directly. +`OpenStreamContext(ctx, ...)` and `OpenStreamBytesContext(ctx, ...)` add +cancellation to the same operations. Checks run between reads, while skipping +entries, and while parsing ZIP metadata. Cancellation errors match +`context.Canceled` or `context.DeadlineExceeded` and end iteration. These methods +cannot interrupt an input read already blocked inside the caller's reader; use +a context-aware input for that. The constructors without a context use +`context.Background()`. + Zero limits default to 512 MiB for each byte limit and 100,000 entries. Negative limits are rejected. Entry and expanded-byte limits use logical header sizes, including sparse files, before exposing a body. Container members diff --git a/browse.go b/browse.go index c992a1d..e7c6d8d 100644 --- a/browse.go +++ b/browse.go @@ -1,6 +1,7 @@ package archives import ( + "context" "errors" "fmt" "io" @@ -25,6 +26,7 @@ type BrowseOptions struct { // expanded bodies. Finish and close each extracted file before the next call. // A Browser is not safe for concurrent use. type Browser struct { + ctx context.Context filename string options BrowseOptions reopen func() (io.ReadCloser, error) @@ -38,16 +40,26 @@ type Browser struct { // ZIP and conda input is buffered once and reused without calling reopen. // A nil reopen is allowed, but a second sequential pass wraps errors.ErrUnsupported. func OpenBrowser(filename string, content io.Reader, reopen func() (io.ReadCloser, error), options BrowseOptions) (*Browser, error) { + return OpenBrowserContext(context.Background(), filename, content, reopen, options) +} + +// OpenBrowserContext is OpenBrowser with cancellation across every pass, +// including decompression of buffered input. The reopen callback must honor ctx +// itself if opening can block. Canceling does not close the caller's input. +func OpenBrowserContext(ctx context.Context, filename string, content io.Reader, reopen func() (io.ReadCloser, error), options BrowseOptions) (*Browser, error) { + if err := ctx.Err(); err != nil { + return nil, err + } limits, err := options.Limits.defaults() if err != nil { return nil, err } options.Limits = limits - format, content, err := streamFormat(filename, &streamInput{reader: content, remaining: limits.MaxInputBytes}) + format, content, err := streamFormat(filename, &streamInput{reader: contextReader{ctx: ctx, reader: content}, remaining: limits.MaxInputBytes}) if err != nil { return nil, err } - b := &Browser{filename: filename, options: options, reopen: reopen} + b := &Browser{ctx: ctx, filename: filename, options: options, reopen: reopen} if format != formatZIP && format != formatConda { b.first = content return b, nil @@ -60,8 +72,11 @@ func OpenBrowser(filename string, content io.Reader, reopen func() (io.ReadClose } func (b *Browser) openStream() (*Stream, func() error, error) { + if err := b.ctx.Err(); err != nil { + return nil, nil, err + } if b.data != nil { - stream, err := OpenStreamBytes(b.filename, b.data, b.options.Limits) + stream, err := OpenStreamBytesContext(b.ctx, b.filename, b.data, b.options.Limits) if err != nil { return nil, nil, err } @@ -70,7 +85,7 @@ func (b *Browser) openStream() (*Stream, func() error, error) { if b.first != nil { content := b.first b.first = nil - stream, err := OpenStream(b.filename, content, b.options.Limits) + stream, err := OpenStreamContext(b.ctx, b.filename, content, b.options.Limits) if err != nil { return nil, nil, err } @@ -83,7 +98,7 @@ func (b *Browser) openStream() (*Stream, func() error, error) { if err != nil { return nil, nil, fmt.Errorf("reopening archive: %w", err) } - stream, err := OpenStream(b.filename, content, b.options.Limits) + stream, err := OpenStreamContext(b.ctx, b.filename, content, b.options.Limits) if err != nil { _ = content.Close() return nil, nil, err diff --git a/context.go b/context.go new file mode 100644 index 0000000..7852aa0 --- /dev/null +++ b/context.go @@ -0,0 +1,38 @@ +package archives + +import ( + "context" + "io" +) + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + n, err := r.reader.Read(p) + if canceled := r.ctx.Err(); canceled != nil { + return n, canceled + } + return n, err +} + +type contextReaderAt struct { + ctx context.Context + reader io.ReaderAt +} + +func (r contextReaderAt) ReadAt(p []byte, off int64) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + n, err := r.reader.ReadAt(p, off) + if canceled := r.ctx.Err(); canceled != nil { + return n, canceled + } + return n, err +} diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..17ed784 --- /dev/null +++ b/context_test.go @@ -0,0 +1,189 @@ +package archives + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" +) + +func TestCanceledOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + deadline, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer stop() + for _, ctx := range []context.Context{ctx, deadline} { + data := browseTar(t, "README.md") + input := &countStreamInput{Reader: bytes.NewReader(data)} + _, err := OpenStreamContext(ctx, "test.tar", input, StreamOptions{}) + if !errors.Is(err, ctx.Err()) { + t.Fatalf("OpenStreamContext: %v", err) + } + _, err = OpenStreamBytesContext(ctx, "test.tar", data, StreamOptions{}) + if !errors.Is(err, ctx.Err()) { + t.Fatalf("OpenStreamBytesContext: %v", err) + } + _, err = OpenBrowserContext(ctx, "test.tar", input, nil, BrowseOptions{}) + if !errors.Is(err, ctx.Err()) { + t.Fatalf("OpenBrowserContext: %v", err) + } + if input.bytes != 0 || input.closed { + t.Fatalf("caller input touched: %+v", input) + } + } +} + +func TestStreamCancellation(t *testing.T) { + for _, name := range []string{"test.tar", "test.tar.gz", "test.tar.xz", "test.tar.zst", "test.zip", "test.gem", "test.conda"} { + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + data := streamTestArchive(t, name, browseTar(t, "README.md", "other")) + stream, err := OpenStreamBytesContext(ctx, name, data, StreamOptions{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = stream.Close() }() + if _, err := stream.Next(); err != nil { + t.Fatal(err) + } + cancel() + if _, err := io.ReadAll(stream); !errors.Is(err, context.Canceled) { + t.Fatalf("Read: %v", err) + } + if _, err := stream.Next(); !errors.Is(err, context.Canceled) { + t.Fatalf("Next: %v", err) + } + }) + } +} + +// Cancellation during a single operation must not depend on scheduler timing. +type cancelAfterChecks struct { + context.Context + cancel context.CancelFunc + remaining int +} + +func (c *cancelAfterChecks) Err() error { + if c.remaining > 0 { + c.remaining-- + if c.remaining == 0 { + c.cancel() + } + } + return c.Context.Err() +} + +func newCheckContext(t *testing.T) *cancelAfterChecks { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + return &cancelAfterChecks{Context: ctx, cancel: cancel} +} + +func cancellationTar(t *testing.T) []byte { + t.Helper() + var data bytes.Buffer + tw := tar.NewWriter(&data) + writeTarFile(t, tw, "root/padding", strings.Repeat("padding", 100_000), 0o644) + writeTarFile(t, tw, "root/README.md", "hello", 0o644) + if err := tw.Close(); err != nil { + t.Fatal(err) + } + return data.Bytes() +} + +func TestCancellationDuringSkippedEntry(t *testing.T) { + for _, name := range []string{"test.tar", "test.tar.gz", "test.zip", "test.gem", "test.conda"} { + t.Run(name, func(t *testing.T) { + ctx := newCheckContext(t) + stream, err := OpenStreamBytesContext(ctx, name, streamTestArchive(t, name, cancellationTar(t)), StreamOptions{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = stream.Close() }() + if _, err := stream.Next(); err != nil { + t.Fatal(err) + } + ctx.remaining = 20 + if _, err := stream.Next(); !errors.Is(err, context.Canceled) { + t.Fatalf("skipped body: %v", err) + } + }) + } +} + +func TestBrowserCancellation(t *testing.T) { + for _, name := range []string{"test.tar.gz", "test.zip", "test.conda"} { + for _, operation := range []string{"list", "directory", "extract"} { + t.Run(name+"/"+operation, func(t *testing.T) { + ctx := newCheckContext(t) + data := streamTestArchive(t, name, cancellationTar(t)) + b, err := OpenBrowserContext(ctx, name, bytes.NewReader(data), nil, BrowseOptions{DetectRoot: true}) + if err != nil { + t.Fatal(err) + } + ctx.remaining = 25 + switch operation { + case "list": + _, err = b.List() + case "directory": + _, err = b.ListDir("") + case "extract": + _, err = b.Extract("README.md") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("%s: %v", operation, err) + } + }) + } + } +} + +func TestBrowserCancellationClosesReopenedInput(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + data := browseTar(t, "README.md") + first := &countStreamInput{Reader: bytes.NewReader(data)} + reopened := &countStreamInput{Reader: bytes.NewReader(data)} + b, err := OpenBrowserContext(ctx, "test.tar", first, func() (io.ReadCloser, error) { + cancel() + return reopened, nil + }, BrowseOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := b.List(); err != nil { + t.Fatal(err) + } + if _, err := b.Extract("README.md"); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if first.closed || !reopened.closed { + t.Fatalf("first closed=%t, reopened closed=%t", first.closed, reopened.closed) + } +} + +func TestBrowserExtractedFileCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + data := streamTestArchive(t, "test.zip", browseTar(t, "README.md")) + b, err := OpenBrowserContext(ctx, "test.zip", bytes.NewReader(data), nil, BrowseOptions{}) + if err != nil { + t.Fatal(err) + } + file, err := b.Extract("README.md") + if err != nil { + t.Fatal(err) + } + defer func() { _ = file.Close() }() + cancel() + if _, err := io.ReadAll(file); !errors.Is(err, context.Canceled) { + t.Fatalf("Read: %v", err) + } +} diff --git a/stream.go b/stream.go index 75ae129..f434738 100644 --- a/stream.go +++ b/stream.go @@ -3,6 +3,7 @@ package archives import ( "bufio" "bytes" + "context" "errors" "fmt" "io" @@ -34,6 +35,7 @@ type StreamEntry struct { // Paths and duplicate entries are preserved. A Stream is not safe for // concurrent use. It does not implement the random-access Reader interface. type Stream struct { + ctx context.Context source streamSource options StreamOptions expanded int64 @@ -51,16 +53,28 @@ type streamSource interface { // buffered up to MaxInputBytes for random access; TAR and gem input is streamed. // The caller owns content and must close it when needed. func OpenStream(filename string, content io.Reader, options StreamOptions) (*Stream, error) { + return OpenStreamContext(context.Background(), filename, content, options) +} + +// OpenStreamContext is OpenStream with cancellation during reading and decoding. +// Cancellation is terminal. The caller owns content; canceling does not close it. +func OpenStreamContext(ctx context.Context, filename string, content io.Reader, options StreamOptions) (*Stream, error) { options, err := options.defaults() if err != nil { return nil, err } - return openStream(filename, &streamInput{reader: content, remaining: options.MaxInputBytes}, nil, options) + return openStream(ctx, filename, &streamInput{reader: content, remaining: options.MaxInputBytes}, nil, options) } // OpenStreamBytes reuses content without copying it, including ZIP and conda // input. The caller must not modify the slice until the stream is closed. func OpenStreamBytes(filename string, content []byte, options StreamOptions) (*Stream, error) { + return OpenStreamBytesContext(context.Background(), filename, content, options) +} + +// OpenStreamBytesContext is OpenStreamBytes with cancellation, including while +// decoding or skipping buffered ZIP and conda entries. +func OpenStreamBytesContext(ctx context.Context, filename string, content []byte, options StreamOptions) (*Stream, error) { options, err := options.defaults() if err != nil { return nil, err @@ -68,10 +82,14 @@ func OpenStreamBytes(filename string, content []byte, options StreamOptions) (*S if int64(len(content)) > options.MaxInputBytes { return nil, ErrInputLimit } - return openStream(filename, bytes.NewReader(content), content, options) + return openStream(ctx, filename, bytes.NewReader(content), content, options) } -func openStream(filename string, content io.Reader, raw []byte, options StreamOptions) (*Stream, error) { +func openStream(ctx context.Context, filename string, content io.Reader, raw []byte, options StreamOptions) (*Stream, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + content = contextReader{ctx: ctx, reader: content} format, content, err := streamFormat(filename, content) if err != nil { return nil, err @@ -85,20 +103,23 @@ func openStream(filename string, content io.Reader, raw []byte, options StreamOp return nil, err } } - source, err = newZipStream(raw, options, format == formatConda) + source, err = newZipStream(ctx, raw, options, format == formatConda) case formatGem: - source = &gemStream{outer: newContainerTar(content, options)} + source = &gemStream{outer: newContainerTar(ctx, content, options)} default: compression, ok := streamCompression(format) if !ok { return nil, fmt.Errorf("%w: archive format: %s", errors.ErrUnsupported, filename) } - source, err = newTarStream(content, compression) + source, err = newTarStream(ctx, content, compression) } if err != nil { + if canceled := ctx.Err(); canceled != nil { + return nil, canceled + } return nil, err } - return &Stream{source: source, options: options}, nil + return &Stream{ctx: ctx, source: source, options: options}, nil } func streamFormat(filename string, content io.Reader) (string, io.Reader, error) { @@ -166,6 +187,10 @@ func (s *Stream) Next() (*StreamEntry, error) { if s.err != nil { return nil, s.err } + if err := s.ctx.Err(); err != nil { + s.err = err + return nil, err + } entry, err := s.source.Next() if err == nil { err = s.checkEntry(entry) @@ -201,6 +226,10 @@ func (s *Stream) Read(p []byte) (int, error) { if s.err != nil { return 0, s.err } + if err := s.ctx.Err(); err != nil { + s.err = err + return 0, err + } n, err := s.source.Read(p) if err != nil && err != io.EOF { s.err = err diff --git a/stream_tar.go b/stream_tar.go index 0f91b06..5401058 100644 --- a/stream_tar.go +++ b/stream_tar.go @@ -2,6 +2,7 @@ package archives import ( "archive/tar" + "context" "fmt" "io" ) @@ -11,12 +12,12 @@ type tarStream struct { closer io.Closer } -func newTarStream(content io.Reader, compression string) (*tarStream, error) { - r, closer, err := tarContentReader(content, compression) +func newTarStream(ctx context.Context, content io.Reader, compression string) (*tarStream, error) { + r, closer, err := tarContentReader(contextReader{ctx: ctx, reader: content}, compression) if err != nil { return nil, err } - return &tarStream{reader: tar.NewReader(r), closer: closer}, nil + return &tarStream{reader: tar.NewReader(contextReader{ctx: ctx, reader: r}), closer: closer}, nil } func (t *tarStream) Next() (*StreamEntry, error) { @@ -48,9 +49,10 @@ func containerOptions(options StreamOptions) StreamOptions { return options } -func newContainerTar(content io.Reader, options StreamOptions) *Stream { +func newContainerTar(ctx context.Context, content io.Reader, options StreamOptions) *Stream { return &Stream{ - source: &tarStream{reader: tar.NewReader(content)}, + ctx: ctx, + source: &tarStream{reader: tar.NewReader(contextReader{ctx: ctx, reader: content})}, options: containerOptions(options), } } @@ -73,7 +75,7 @@ func (g *gemStream) Next() (*StreamEntry, error) { if entry.Path != "data.tar.gz" { continue } - g.inner, err = newTarStream(g.outer, compressionGzip) + g.inner, err = newTarStream(g.outer.ctx, g.outer, compressionGzip) if err != nil { return nil, fmt.Errorf("opening data.tar.gz: %w", err) } diff --git a/stream_zip.go b/stream_zip.go index 5c01504..f7d2e03 100644 --- a/stream_zip.go +++ b/stream_zip.go @@ -3,12 +3,14 @@ package archives import ( "archive/zip" "bytes" + "context" "fmt" "io" "strings" ) type zipStream struct { + ctx context.Context reader *zip.Reader index int file *zip.File @@ -16,11 +18,11 @@ type zipStream struct { } //nolint:ireturn // archive formats share a sequential source -func newZipStream(raw []byte, options StreamOptions, conda bool) (streamSource, error) { - if err := checkZipEntryCountLimit(raw, options.MaxEntries); err != nil { +func newZipStream(ctx context.Context, raw []byte, options StreamOptions, conda bool) (streamSource, error) { + if err := checkZipEntryCountLimitContext(ctx, raw, options.MaxEntries); err != nil { return nil, err } - reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + reader, err := zip.NewReader(contextReaderAt{ctx: ctx, reader: bytes.NewReader(raw)}, int64(len(raw))) if err != nil { return nil, fmt.Errorf("opening zip: %w", err) } @@ -28,9 +30,9 @@ func newZipStream(raw []byte, options StreamOptions, conda bool) (streamSource, return nil, ErrEntryLimit } reader.RegisterDecompressor(zip.Deflate, newFlateReader) - z := &zipStream{reader: reader} + z := &zipStream{ctx: ctx, reader: reader} if conda { - return &condaStream{outer: &Stream{source: z, options: containerOptions(options)}}, nil + return &condaStream{outer: &Stream{ctx: ctx, source: z, options: containerOptions(options)}}, nil } return z, nil } @@ -55,6 +57,9 @@ func (z *zipStream) Next() (*StreamEntry, error) { } func (z *zipStream) Read(p []byte) (int, error) { + if err := z.ctx.Err(); err != nil { + return 0, err + } if z.file == nil { return 0, io.EOF } @@ -107,7 +112,7 @@ func (c *condaStream) Next() (*StreamEntry, error) { continue } c.found = true - c.inner, err = newTarStream(c.outer, compressionZstd) + c.inner, err = newTarStream(c.outer.ctx, c.outer, compressionZstd) if err != nil { return nil, fmt.Errorf("opening %s: %w", entry.Path, err) } diff --git a/zip.go b/zip.go index a4bd766..ad2c387 100644 --- a/zip.go +++ b/zip.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bufio" "bytes" + "context" "encoding/binary" "fmt" "io" @@ -76,6 +77,10 @@ func checkZipEntryCount(raw []byte) error { } func checkZipEntryCountLimit(raw []byte, limit int) error { + return checkZipEntryCountLimitContext(context.Background(), raw, limit) +} + +func checkZipEntryCountLimitContext(ctx context.Context, raw []byte, limit int) error { start, end, ok := zipCentralDirectoryBounds(raw) if !ok { return nil @@ -83,6 +88,9 @@ func checkZipEntryCountLimit(raw []byte, limit int) error { count := 0 for offset := start; offset+zipDirectoryHeaderLen <= end; { + if err := ctx.Err(); err != nil { + return err + } if binary.LittleEndian.Uint32(raw[offset:]) != zipDirectoryHeaderSignature { break } From 37b9a6e2b5267502a750c763750eda1174a70963 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 18 Oct 2026 13:13:04 +0100 Subject: [PATCH 4/5] Add Hex package detection to archive readers --- README.md | 18 +++- archives.go | 9 ++ browse.go | 2 +- gem.go | 37 ++++--- hex.go | 127 +++++++++++++++++++++++ hex_test.go | 277 ++++++++++++++++++++++++++++++++++++++++++++++++++ stream.go | 7 +- stream_tar.go | 20 ++-- 8 files changed, 462 insertions(+), 35 deletions(-) create mode 100644 hex.go create mode 100644 hex_test.go diff --git a/README.md b/README.md index 4bbb07e..986e9db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # archives -A Go library for reading and browsing archive files in memory. Supports ZIP, TAR (with gzip, bzip2, xz, zstd compression), Ruby gem, and conda formats. +A Go library for reading and browsing archive files in memory. Supports ZIP, TAR (with gzip, bzip2, xz, zstd compression), Ruby gem, Hex, and conda formats. ## Installation @@ -115,7 +115,7 @@ returns the first matching entry and leaves later entries unchecked. Missing pat wrap `fs.ErrNotExist`; directories wrap `archives.ErrIsDir`. Limits apply per pass. The caller owns the initial input, including when construction fails. Keep it open -until the first operation and any extracted file finish. TAR and gem use `reopen` +until the first operation and any extracted file finish. TAR, gem, and Hex use `reopen` for later passes; the browser closes those readers. Root detection adds a pass before extraction. A nil callback is allowed, but attempting another sequential pass wraps `errors.ErrUnsupported`. ZIP and conda buffer their compressed input @@ -132,7 +132,7 @@ cancellation too; the browser still leaves the initial input owned by the caller `OpenStream` reads entries one at a time through `Next` and `Read`, without retaining expanded file bodies. It supports every format listed below, -including the inner files of gem and conda packages. +including the inner files of gem, Hex, and conda packages. ```go stream, err := archives.OpenStream("package.tgz", f, archives.StreamOptions{ @@ -173,6 +173,15 @@ buffers that input within `MaxInputBytes`; `OpenStreamBytes(name, data, options) reuses an existing byte slice without copying it. Keep that slice unchanged until `Close`. TAR and gem can consume a forward-only input directly. +Hex packages use `.tar` filenames. Buffered readers, streams, and browsers detect +the four regular outer files `VERSION`, `CHECKSUM`, `metadata.config`, and +`contents.tar.gz` in any order, with `VERSION` equal to `3` and a 64-character +uppercase hexadecimal checksum. They expose the files inside `contents.tar.gz`. +A TAR without this signature keeps its outer entries. Detection buffers the outer +prefix within `MaxInputBytes`; if the contents member precedes other markers, +that prefix includes its compressed body. Expanded file bodies remain streamed. +`Hash` covers the original outer TAR. Detection does not verify the package checksum. + `OpenStreamContext(ctx, ...)` and `OpenStreamBytesContext(ctx, ...)` add cancellation to the same operations. Checks run between reads, while skipping entries, and while parsing ZIP metadata. Cancellation errors match @@ -184,7 +193,7 @@ a context-aware input for that. The constructors without a context use Zero limits default to 512 MiB for each byte limit and 100,000 entries. Negative limits are rejected. Entry and expanded-byte limits use logical header sizes, including sparse files, before exposing a body. Container members -in gem and conda have a separate entry-count budget, with their combined bodies +in gem, Hex, and conda have a separate entry-count budget, with their combined bodies bounded by `MaxInputBytes`. Decoder workspace and metadata are additional memory; these limits do not cap total heap usage. For forward-only input, the input limit covers bytes consumed, with at most one extra byte read to detect overflow. @@ -274,6 +283,7 @@ for _, f := range result.Files { - `.zip`, `.jar`, `.whl`, `.nupkg`, `.egg`, `.vsix` (ZIP-based) - `.tar`, `.tar.gz`, `.tgz`, `.crate`, `.tar.bz2`, `.tar.xz`, `.tar.zst` - `.gem` (Ruby gems with nested data.tar.gz) +- `.tar` Hex packages (detected by outer markers, with nested contents.tar.gz) - `.conda` (v2 conda packages: zip of `pkg-*.tar.zst` and `info-*.tar.zst`, presented as one merged tar) - `.apk` (routed by content: Android packages open as ZIP, Alpine packages open as gzipped tar) diff --git a/archives.go b/archives.go index 610e753..cf1feaf 100644 --- a/archives.go +++ b/archives.go @@ -4,6 +4,7 @@ // - ZIP (.zip, .jar, .whl, .nupkg, .egg, .vsix) // - TAR (.tar, .tar.gz, .tgz, .crate, .tar.bz2, .tar.xz, .tar.zst) // - GEM (.gem - Ruby gems with nested tar structure) +// - Hex (.tar - Hex packages with nested contents.tar.gz) // - CONDA (.conda - v2 conda packages, zip of zstd tarballs) // // The .apk extension is routed by content since Android packages are ZIP @@ -16,6 +17,7 @@ package archives import ( "bufio" + "bytes" "fmt" "io" "path" @@ -130,6 +132,13 @@ func openRaw(format string, raw []byte) (Reader, error) { case formatZIP: return openZip(raw) case formatTAR: + hexPackage, err := isHex(bytes.NewReader(raw), StreamOptions{MaxInputBytes: maxDecompressedSize, MaxEntries: maxArchiveEntries}) + if err != nil { + return nil, err + } + if hexPackage { + return openNestedTar(raw, hexContents, "Hex") + } return openTar(raw, "") case formatTarGzip, formatTGZ: return openTar(raw, compressionGzip) diff --git a/browse.go b/browse.go index e7c6d8d..79f2a46 100644 --- a/browse.go +++ b/browse.go @@ -36,7 +36,7 @@ type Browser struct { // OpenBrowser prepares an archive for browsing. The caller owns content and // must keep it open until the first operation and any extracted file finish. -// Later TAR and gem passes call reopen; the browser closes those readers. +// Later TAR, gem, and Hex passes call reopen; the browser closes those readers. // ZIP and conda input is buffered once and reused without calling reopen. // A nil reopen is allowed, but a second sequential pass wraps errors.ErrUnsupported. func OpenBrowser(filename string, content io.Reader, reopen func() (io.ReadCloser, error), options BrowseOptions) (*Browser, error) { diff --git a/gem.go b/gem.go index f57614d..cd37503 100644 --- a/gem.go +++ b/gem.go @@ -7,69 +7,68 @@ import ( "io" ) -// gemReader handles Ruby .gem files which have a nested structure: -// - gem file is a tar archive containing metadata.gz and data.tar.gz -// - data.tar.gz contains the actual source code -type gemReader struct { +type nestedTarReader struct { raw []byte dataReader Reader } -func openGem(raw []byte) (*gemReader, error) { +func openGem(raw []byte) (*nestedTarReader, error) { + return openNestedTar(raw, "data.tar.gz", "gem") +} + +func openNestedTar(raw []byte, member, kind string) (*nestedTarReader, error) { tr := tar.NewReader(bytes.NewReader(raw)) entryCount := 0 - // Find data.tar.gz in the gem for { header, err := tr.Next() if err == io.EOF { - return nil, fmt.Errorf("data.tar.gz not found in gem") + return nil, fmt.Errorf("%s not found in %s", member, kind) } if err != nil { - return nil, fmt.Errorf("reading gem tar: %w", err) + return nil, fmt.Errorf("reading %s tar: %w", kind, err) } entryCount++ if err := checkArchiveEntryCount(entryCount); err != nil { return nil, err } - // Look for data.tar.gz - if header.Name == "data.tar.gz" { + if header.Name == member { dataContent, err := io.ReadAll(io.LimitReader(tr, maxDecompressedSize+1)) if err != nil { - return nil, fmt.Errorf("reading data.tar.gz: %w", err) + return nil, fmt.Errorf("reading %s: %w", member, err) } if int64(len(dataContent)) > maxDecompressedSize { - return nil, fmt.Errorf("%w: data.tar.gz exceeds %d bytes", ErrDecompressLimit, maxDecompressedSize) + return nil, fmt.Errorf("%w: %s exceeds %d bytes", ErrDecompressLimit, member, maxDecompressedSize) } dataReader, err := openTar(dataContent, compressionGzip) if err != nil { - return nil, fmt.Errorf("opening data.tar.gz: %w", err) + return nil, fmt.Errorf("opening %s: %w", member, err) } - return &gemReader{raw: raw, dataReader: dataReader}, nil + return &nestedTarReader{raw: raw, dataReader: dataReader}, nil } } } -func (g *gemReader) List() ([]FileInfo, error) { +func (g *nestedTarReader) List() ([]FileInfo, error) { return g.dataReader.List() } -func (g *gemReader) ListDir(dirPath string) ([]FileInfo, error) { +func (g *nestedTarReader) ListDir(dirPath string) ([]FileInfo, error) { return g.dataReader.ListDir(dirPath) } -func (g *gemReader) Extract(filePath string) (io.ReadCloser, error) { +func (g *nestedTarReader) Extract(filePath string) (io.ReadCloser, error) { return g.dataReader.Extract(filePath) } -func (g *gemReader) Hash(algo string) (string, error) { +func (g *nestedTarReader) Hash(algo string) (string, error) { return hashRaw(g.raw, algo) } -func (g *gemReader) Close() error { +func (g *nestedTarReader) Close() error { g.raw = nil if g.dataReader != nil { return g.dataReader.Close() diff --git a/hex.go b/hex.go new file mode 100644 index 0000000..8b10e4b --- /dev/null +++ b/hex.go @@ -0,0 +1,127 @@ +package archives + +import ( + "archive/tar" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "strings" +) + +const hexContents = "contents.tar.gz" + +// Hex packages have four regular outer entries, in any order. +func isHex(content io.Reader, options StreamOptions) (bool, error) { + tr := tar.NewReader(content) + budget := Stream{options: containerOptions(options)} + var limitErr error + remaining := map[string]bool{"VERSION": true, "CHECKSUM": true, "metadata.config": true, hexContents: true} + for len(remaining) != 0 { + h, err := tr.Next() + if err != nil || !remaining[h.Name] || h.Typeflag != tar.TypeReg { + return false, nil + } + if limitErr == nil { + limitErr = budget.checkEntry(&StreamEntry{FileInfo: fileInfoFromTar(h)}) + if limitErr == nil { + budget.entries++ + budget.expanded += h.Size + } + } + switch h.Name { + case "VERSION": + if h.Size != 1 { + return false, nil + } + var version [1]byte + if _, err := io.ReadFull(tr, version[:]); err != nil || version[0] != '3' { + return false, nil + } + case "CHECKSUM": + if h.Size != int64(hex.EncodedLen(sha256.Size)) { + return false, nil + } + checksum, err := io.ReadAll(tr) + if err != nil || string(checksum) != strings.ToUpper(string(checksum)) { + return false, nil + } + if _, err := hex.DecodeString(string(checksum)); err != nil { + return false, nil + } + } + delete(remaining, h.Name) + } + return true, limitErr +} + +type plainTarStream struct { + ctx context.Context + content io.Reader + options StreamOptions + source streamSource +} + +func (s *plainTarStream) Next() (*StreamEntry, error) { + if s.source == nil { + probe := &hexProbe{reader: s.content} + hexPackage, err := isHex(probe, s.options) + if err != nil { + return nil, err + } + input := io.MultiReader(bytes.NewReader(probe.prefix.Bytes()), &probeTail{reader: s.content, err: probe.err}) + s.content = nil + if hexPackage { + s.source = &nestedTarStream{outer: newContainerTar(s.ctx, input, s.options), member: hexContents, kind: "Hex"} + } else { + var err error + s.source, err = newTarStream(s.ctx, input, "") + if err != nil { + return nil, err + } + } + } + return s.source.Next() +} + +func (s *plainTarStream) Read(p []byte) (int, error) { + if s.source == nil { + return 0, io.EOF + } + return s.source.Read(p) +} + +func (s *plainTarStream) Close() error { + s.content = nil + if s.source != nil { + return s.source.Close() + } + return nil +} + +// Preserve probe errors as well as bytes so a failed probe cannot hide input errors. +type hexProbe struct { + reader io.Reader + prefix bytes.Buffer + err error +} + +func (p *hexProbe) Read(b []byte) (int, error) { + n, err := p.reader.Read(b) + _, _ = p.prefix.Write(b[:n]) + p.err = err + return n, err +} + +type probeTail struct { + reader io.Reader + err error +} + +func (p *probeTail) Read(b []byte) (int, error) { + if p.err != nil { + return 0, p.err + } + return p.reader.Read(b) +} diff --git a/hex_test.go b/hex_test.go new file mode 100644 index 0000000..88090c0 --- /dev/null +++ b/hex_test.go @@ -0,0 +1,277 @@ +package archives + +import ( + "archive/tar" + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" + "testing/iotest" +) + +var hexOrder = []string{"VERSION", "CHECKSUM", "metadata.config", hexContents} + +func hexMembers(t *testing.T, raw []byte) map[string]string { + t.Helper() + contents := string(gzipTar(t, raw)) + metadata := "{<<\"name\">>,<<\"example\">>}.\n" + checksum := sha256.Sum256([]byte("3" + metadata + contents)) + return map[string]string{"VERSION": "3", "CHECKSUM": fmt.Sprintf("%X", checksum), "metadata.config": metadata, hexContents: contents} +} + +func hexTar(t *testing.T, order []string, members map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, name := range order { + writeTarFile(t, tw, name, members[name], 0o644) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func hexOrders(names []string) [][]string { + if len(names) == 0 { + return [][]string{nil} + } + var orders [][]string + for i, name := range names { + rest := append(append([]string{}, names[:i]...), names[i+1:]...) + for _, tail := range hexOrders(rest) { + orders = append(orders, append([]string{name}, tail...)) + } + } + return orders +} + +func TestHexPublicReaders(t *testing.T) { + raw := browseTar(t, "root/", "root/README.md", "root/src/main.go", "root/README.md") + want, err := OpenBytes("inner.tar", raw) + if err != nil { + t.Fatal(err) + } + defer func() { _ = want.Close() }() + files, err := want.List() + if err != nil { + t.Fatal(err) + } + for _, order := range hexOrders(hexOrder) { + t.Run(strings.Join(order, ","), func(t *testing.T) { + data := hexTar(t, order, hexMembers(t, raw)) + for _, name := range []string{"example-1.0.0.tar", "download"} { + for _, open := range []func() (Reader, error){ + func() (Reader, error) { return Open(name, bytes.NewReader(data)) }, + func() (Reader, error) { return OpenBytes(name, data) }, + } { + r, err := open() + if err != nil { + t.Fatal(err) + } + assertHexReader(t, r, files, data) + _ = r.Close() + } + for _, open := range []func() (*Stream, error){ + func() (*Stream, error) { return OpenStream(name, bytes.NewReader(data), StreamOptions{}) }, + func() (*Stream, error) { return OpenStreamBytes(name, data, StreamOptions{}) }, + } { + s, err := open() + if err != nil { + t.Fatal(err) + } + assertStreamMatches(t, s, want, files) + _ = s.Close() + } + assertHexBrowser(t, name, data, files) + } + }) + } +} + +func assertHexReader(t *testing.T, r Reader, files []FileInfo, data []byte) { + t.Helper() + got, err := r.List() + if err != nil || !reflect.DeepEqual(got, files) { + t.Fatalf("List: %+v, %v", got, err) + } + assertTarPayload(t, r, "root/README.md", []byte("body: root/README.md")) + hash, err := r.Hash("sha256") + if err != nil || hash != fmt.Sprintf("%x", sha256.Sum256(data)) { + t.Fatalf("outer Hash: %s, %v", hash, err) + } +} + +func assertHexBrowser(t *testing.T, name string, data []byte, files []FileInfo) { + t.Helper() + b, err := OpenBrowser(name, bytes.NewReader(data), func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + }, BrowseOptions{DetectRoot: true}) + if err != nil { + t.Fatal(err) + } + got, err := b.List() + if err != nil || !reflect.DeepEqual(got, stripFilePrefix(files, "root/")) { + t.Fatalf("Browser.List: %+v, %v", got, err) + } + assertBrowsedBody(t, b, "README.md", "body: root/README.md") +} + +func TestHexDetectionLeavesOrdinaryTar(t *testing.T) { + for _, tc := range []struct { + name string + order []string + change string + value string + }{ + {"only-contents", []string{hexContents}, "", ""}, + {"missing-checksum", []string{"VERSION", "metadata.config", hexContents}, "", ""}, + {"duplicate", []string{"VERSION", "VERSION", "CHECKSUM", "metadata.config", hexContents}, "", ""}, + {"version", hexOrder, "VERSION", "2"}, + {"checksum", hexOrder, "CHECKSUM", strings.Repeat("Z", 64)}, + {"short-checksum", hexOrder, "CHECKSUM", "1234"}, + {"unrelated", []string{"README.md", hexContents}, "README.md", "hello"}, + } { + t.Run(tc.name, func(t *testing.T) { + members := hexMembers(t, browseTar(t, "inner")) + members[tc.change] = tc.value + data := hexTar(t, tc.order, members) + r, err := OpenBytes("example.tar", data) + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close() }() + files, err := r.List() + if err != nil || len(files) != len(tc.order) { + t.Fatalf("List: %+v, %v", files, err) + } + for i, f := range files { + if f.Path != tc.order[i] { + t.Fatalf("path = %s, want %s", f.Path, tc.order[i]) + } + } + s, err := OpenStream("example.tar", bytes.NewReader(data), StreamOptions{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + assertStreamMatches(t, s, r, files) + }) + } +} + +func TestHexLimitsAndInvalidPayload(t *testing.T) { + members := hexMembers(t, browseTar(t, "README.md", "other")) + for _, order := range [][]string{hexOrder, {hexContents, "VERSION", "CHECKSUM", "metadata.config"}} { + data := hexTar(t, order, members) + for _, tc := range []struct { + options StreamOptions + want error + }{ + {StreamOptions{MaxEntryBytes: 2}, ErrEntrySizeLimit}, + {StreamOptions{MaxExpandedBytes: 20}, ErrDecompressLimit}, + {StreamOptions{MaxEntries: 3}, ErrEntryLimit}, + {StreamOptions{MaxInputBytes: 600}, ErrInputLimit}, + } { + b, err := OpenBrowser("example.tar", bytes.NewReader(data), nil, BrowseOptions{Limits: tc.options}) + if err != nil { + t.Fatal(err) + } + files, err := b.List() + if !errors.Is(err, tc.want) || files != nil { + t.Fatalf("List = %+v, %v, want %v", files, err, tc.want) + } + } + } + members[hexContents] = "invalid gzip" + data := hexTar(t, hexOrder, members) + if r, err := OpenBytes("example.tar", data); err == nil { + _ = r.Close() + t.Fatal("OpenBytes accepted invalid payload") + } + s, err := OpenStreamBytes("example.tar", data, StreamOptions{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + if _, err := s.Next(); err == nil { + t.Fatal("Next accepted invalid payload") + } +} + +func TestHexCancellation(t *testing.T) { + for _, order := range [][]string{hexOrder, {hexContents, "VERSION", "CHECKSUM", "metadata.config"}} { + ctx := newCheckContext(t) + data := hexTar(t, order, hexMembers(t, cancellationTar(t))) + b, err := OpenBrowserContext(ctx, "example.tar", bytes.NewReader(data), nil, BrowseOptions{}) + if err != nil { + t.Fatal(err) + } + ctx.remaining = 10 + if _, err := b.List(); !errors.Is(err, context.Canceled) { + t.Fatalf("List during detection: %v", err) + } + ctx = newCheckContext(t) + b, err = OpenBrowserContext(ctx, "example.tar", bytes.NewReader(data), nil, BrowseOptions{}) + if err != nil { + t.Fatal(err) + } + file, err := b.Extract("root/padding") + if err != nil { + t.Fatal(err) + } + ctx.remaining = 10 + _, err = io.Copy(io.Discard, file) + _ = file.Close() + if !errors.Is(err, context.Canceled) { + t.Fatalf("Read during decoding: %v", err) + } + } +} + +func TestHexStreamsContentsLast(t *testing.T) { + payload := make([]byte, 1<<20) + if _, err := rand.Read(payload); err != nil { + t.Fatal(err) + } + data := hexTar(t, hexOrder, hexMembers(t, tarWithPayloads(t, payload))) + input := &countStreamInput{Reader: bytes.NewReader(data)} + s, err := OpenStream("example.tar", input, StreamOptions{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + if _, err := s.Next(); err != nil { + t.Fatal(err) + } + if input.bytes >= len(data)/2 { + t.Fatalf("read %d of %d bytes before returning first inner header", input.bytes, len(data)) + } + got, err := io.ReadAll(s) + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("inner payload mismatch: %v", err) + } + if input.closed { + t.Fatal("closed caller input") + } +} + +func TestTarProbePreservesInputError(t *testing.T) { + data := hexTar(t, hexOrder, hexMembers(t, browseTar(t, "README.md"))) + want := errors.New("input failed") + input := io.MultiReader(bytes.NewReader(data[:600]), iotest.ErrReader(want)) + s, err := OpenStream("example.tar", input, StreamOptions{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + if err := consumeStream(s, false); !errors.Is(err, want) { + t.Fatalf("lost input error: %v", err) + } +} diff --git a/stream.go b/stream.go index f434738..63f8baf 100644 --- a/stream.go +++ b/stream.go @@ -50,7 +50,8 @@ type streamSource interface { } // OpenStream reads supported archives sequentially. ZIP and conda input is -// buffered up to MaxInputBytes for random access; TAR and gem input is streamed. +// buffered up to MaxInputBytes for random access. Hex detection buffers an outer +// TAR prefix; TAR, gem, and Hex entry bodies are streamed. // The caller owns content and must close it when needed. func OpenStream(filename string, content io.Reader, options StreamOptions) (*Stream, error) { return OpenStreamContext(context.Background(), filename, content, options) @@ -105,7 +106,9 @@ func openStream(ctx context.Context, filename string, content io.Reader, raw []b } source, err = newZipStream(ctx, raw, options, format == formatConda) case formatGem: - source = &gemStream{outer: newContainerTar(ctx, content, options)} + source = &nestedTarStream{outer: newContainerTar(ctx, content, options), member: "data.tar.gz", kind: "gem"} + case formatTAR: + source = &plainTarStream{ctx: ctx, content: content, options: options} default: compression, ok := streamCompression(format) if !ok { diff --git a/stream_tar.go b/stream_tar.go index 5401058..a4a2ee3 100644 --- a/stream_tar.go +++ b/stream_tar.go @@ -57,27 +57,29 @@ func newContainerTar(ctx context.Context, content io.Reader, options StreamOptio } } -type gemStream struct { - outer *Stream - inner *tarStream +type nestedTarStream struct { + outer *Stream + inner *tarStream + member string + kind string } -func (g *gemStream) Next() (*StreamEntry, error) { +func (g *nestedTarStream) Next() (*StreamEntry, error) { if g.inner == nil { for { entry, err := g.outer.Next() if err == io.EOF { - return nil, fmt.Errorf("data.tar.gz not found in gem") + return nil, fmt.Errorf("%s not found in %s", g.member, g.kind) } if err != nil { return nil, err } - if entry.Path != "data.tar.gz" { + if entry.Path != g.member { continue } g.inner, err = newTarStream(g.outer.ctx, g.outer, compressionGzip) if err != nil { - return nil, fmt.Errorf("opening data.tar.gz: %w", err) + return nil, fmt.Errorf("opening %s: %w", g.member, err) } break } @@ -85,14 +87,14 @@ func (g *gemStream) Next() (*StreamEntry, error) { return g.inner.Next() } -func (g *gemStream) Read(p []byte) (int, error) { +func (g *nestedTarStream) Read(p []byte) (int, error) { if g.inner == nil { return 0, io.EOF } return g.inner.Read(p) } -func (g *gemStream) Close() error { +func (g *nestedTarStream) Close() error { var err error if g.inner != nil { err = g.inner.Close() From 76db8443c96613abf9815029857c8cadfd21d5cf Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 18 Oct 2026 17:18:08 +0100 Subject: [PATCH 5/5] Report ErrIsDir for directory paths in Browser.Extract --- browse.go | 22 ++++++++++++++-------- browse_test.go | 38 ++++++++++++++++++++++++++++++++++++++ browse_walk.go | 4 ++-- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/browse.go b/browse.go index 79f2a46..a58dcd8 100644 --- a/browse.go +++ b/browse.go @@ -176,21 +176,27 @@ func (b *Browser) Extract(filePath string) (io.ReadCloser, error) { if err != nil { return nil, err } + target := prefix + filePath + dir := prefix + strings.TrimSuffix(filePath, "/") + "/" + isDir := false for { entry, err := stream.Next() if err != nil { _ = release() - if err == io.EOF { - return nil, fmt.Errorf("%w: %s", fs.ErrNotExist, filePath) + if err != io.EOF { + return nil, err } - return nil, err + if isDir { + return nil, fmt.Errorf("%w: %s", ErrIsDir, filePath) + } + return nil, fmt.Errorf("%w: %s", fs.ErrNotExist, filePath) } - if entry.Path != prefix+filePath { - continue + // Implicit directories only show up as prefixes of later entries. + if strings.HasPrefix(entry.Path, dir) || (entry.IsDir && entry.Path+"/" == dir) { + isDir = true } - if entry.IsDir { - _ = release() - return nil, fmt.Errorf("%w: %s", ErrIsDir, filePath) + if entry.Path != target || entry.IsDir { + continue } return &browsedFile{Reader: stream, release: release}, nil } diff --git a/browse_test.go b/browse_test.go index 8f75bc6..e65ab86 100644 --- a/browse_test.go +++ b/browse_test.go @@ -301,3 +301,41 @@ func TestBrowserExtractClosesReopenedInput(t *testing.T) { } } } + +func TestBrowserExtractDirectories(t *testing.T) { + for _, tc := range []struct { + name string + entries []string + options BrowseOptions + target string + }{ + {"explicit without slash", []string{"dir/", "dir/a"}, BrowseOptions{}, "dir"}, + {"explicit with slash", []string{"dir/", "dir/a"}, BrowseOptions{}, "dir/"}, + {"implicit without slash", []string{"dir/a"}, BrowseOptions{}, "dir"}, + {"implicit with slash", []string{"dir/a"}, BrowseOptions{}, "dir/"}, + {"nested implicit", []string{"dir/sub/a"}, BrowseOptions{}, "dir/sub"}, + {"stripped root", []string{"pkg/dir/a"}, BrowseOptions{DetectRoot: true}, "dir"}, + } { + t.Run(tc.name, func(t *testing.T) { + data := browseTar(t, tc.entries...) + b, err := OpenBrowser("test.tar", bytes.NewReader(data), func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + }, tc.options) + if err != nil { + t.Fatal(err) + } + if _, err := b.Extract(tc.target); !errors.Is(err, ErrIsDir) { + t.Fatalf("Extract(%q) error = %v, want ErrIsDir", tc.target, err) + } + }) + } + + data := browseTar(t, "dir/a", "dirx") + b, err := OpenBrowser("test.tar", bytes.NewReader(data), nil, BrowseOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := b.Extract("di"); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("Extract(di) error = %v, want fs.ErrNotExist", err) + } +} diff --git a/browse_walk.go b/browse_walk.go index ccb4e7a..bc90c5b 100644 --- a/browse_walk.go +++ b/browse_walk.go @@ -8,8 +8,8 @@ import ( // walk calls fn for each entry in archive order until fn returns an error or // the archive ends. fn may read the entry body from the stream. -func (a *Browser) walk(fn func(entry *StreamEntry) error) error { - stream, release, err := a.openStream() +func (b *Browser) walk(fn func(entry *StreamEntry) error) error { + stream, release, err := b.openStream() if err != nil { return err }