Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -72,11 +72,67 @@ 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 entries and directories and extracts 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.

`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.

The caller owns the initial input, including when construction fails. Keep it open
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
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
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{
Expand Down Expand Up @@ -117,10 +173,27 @@ 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
`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
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.
Expand Down Expand Up @@ -210,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)

Expand Down
9 changes: 9 additions & 0 deletions archives.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@ package archives

import (
"bufio"
"bytes"
"fmt"
"io"
"path"
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading