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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ reader, _ := archives.OpenBytes("pkg.tgz", data)

Filename mappings are used first. When a name has no supported extension,
`Open` and `OpenBytes` detect ZIP, TAR, gzip, bzip2, xz, and zstd from the content.
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
Compressed content is inspected for TAR headers; other payloads become a single
member named `content`. `Open` reads at most 512 bytes before rejecting an
unsupported stream with no recognised extension.

### Streaming browsing
Expand Down Expand Up @@ -180,6 +180,13 @@ 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.

When content detection finds gzip, bzip2, xz or zstd, the decompressed prefix
determines whether to read TAR entries or expose one member named `content`.
The single member has `Size: -1` in streams and browser listings because its size
is unknown before reading. Entry and expansion limits apply while reading or
skipping it. Buffered readers report its actual size. Explicit TAR filenames
such as `.tar.gz` still require TAR content.

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
Expand Down Expand Up @@ -289,6 +296,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`
- gzip, bzip2, xz and zstd compressed single files, detected by content
- `.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)
Expand Down
14 changes: 5 additions & 9 deletions archives.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const (
type FileInfo struct {
Path string // Full path within archive
Name string // Base name
Size int64 // Uncompressed size in bytes
Size int64 // Uncompressed bytes; -1 for an unknown streaming size
ModTime time.Time // Modification time
IsDir bool // Whether this is a directory
Mode uint32 // fs.FileMode value: permission bits plus fs.ModeType bits
Expand Down Expand Up @@ -152,6 +152,8 @@ func openRaw(format string, raw []byte) (Reader, error) {
return openGem(raw)
case formatConda:
return openConda(raw)
case compressionGzip, compressionBzip2, compressionXZ, compressionZstd:
return openCompressed(raw)
default:
return nil, fmt.Errorf("unsupported format: %s", format)
}
Expand All @@ -171,14 +173,8 @@ func archiveFormat(detected string) string {
return formatZIP
case "tar":
return formatTAR
case compressionGzip:
return formatTarGzip
case compressionBzip2:
return formatTarBzip2
case compressionXZ:
return formatTarXZ
case compressionZstd:
return formatTarZstd
case compressionGzip, compressionBzip2, compressionXZ, compressionZstd:
return detected
default:
return ""
}
Expand Down
8 changes: 4 additions & 4 deletions archives_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ func TestIsInDir(t *testing.T) {
{"dir/subdir/file.txt", "dir/", false},
{"dir/subdir/file.txt", "dir/subdir/", true},
{"other/file.txt", "dir/", false},
{"dir/", "", true}, // dir entry is in root
{"dir/", "dir/", true}, // explicit entry for the directory itself
{"dir/", "", true}, // dir entry is in root
{"dir/", "dir/", true}, // explicit entry for the directory itself
{"dirX/file", "dir/", false},
}

Expand Down Expand Up @@ -460,13 +460,13 @@ func TestOpenDoesNotInferGem(t *testing.T) {
}
}

func TestOpenCompressedNonTar(t *testing.T) {
func TestExplicitCompressedTarRejectsNonTar(t *testing.T) {
buf := new(bytes.Buffer)
w := gzip.NewWriter(buf)
_, _ = w.Write([]byte("not a tar archive"))
_ = w.Close()

_, err := OpenBytes("artifact", buf.Bytes())
_, err := OpenBytes("artifact.tar.gz", buf.Bytes())
if err == nil {
t.Fatal("compressed non-TAR content was accepted")
}
Expand Down
93 changes: 93 additions & 0 deletions compressed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package archives

import (
"archive/tar"
"bufio"
"bytes"
"context"
"io"

"github.com/git-pkgs/magic"
)

const compressedMemberName = "content"

type compressedStream struct {
reader io.Reader
closer io.Closer
started bool
}

//nolint:ireturn // decompressed content may contain TAR entries or one file
func newCompressedStream(ctx context.Context, content io.Reader, compression string) (streamSource, error) {
reader, closer, err := tarContentReader(contextReader{ctx: ctx, reader: content}, compression)
if err != nil {
return nil, err
}
buffered := bufio.NewReaderSize(contextReader{ctx: ctx, reader: reader}, contentSniffSize)
prefix, err := buffered.Peek(contentSniffSize)
if err != nil && err != io.EOF {
if closer != nil {
_ = closer.Close()
}
return nil, err
}
// The TAR parser also accepts v7 headers without a ustar signature.
_, headerErr := tar.NewReader(bytes.NewReader(prefix)).Next()
if magic.DetectPrefix(prefix).Format == magic.FormatTAR || headerErr == nil {
return &tarStream{reader: tar.NewReader(buffered), closer: closer}, nil
}
return &compressedStream{reader: buffered, closer: closer}, nil
}

func (s *compressedStream) Next() (*StreamEntry, error) {
if s.started {
return nil, io.EOF
}
s.started = true
return &StreamEntry{FileInfo: FileInfo{Path: compressedMemberName, Name: compressedMemberName, Size: -1}}, nil
}

func (s *compressedStream) Read(p []byte) (int, error) {
if !s.started {
return 0, io.EOF
}
return s.reader.Read(p)
}

func (s *compressedStream) Close() error {
if s.closer != nil {
return s.closer.Close()
}
return nil
}

func openCompressed(raw []byte) (*tarReader, error) {
stream, err := OpenStreamBytes("", raw, StreamOptions{})
if err != nil {
return nil, err
}
defer func() { _ = stream.Close() }()
result := &tarReader{raw: raw, index: make(map[string]int)}
for {
entry, err := stream.Next()
if err == io.EOF {
return result, nil
}
if err != nil {
return nil, err
}
data, err := io.ReadAll(stream)
if err != nil {
return nil, err
}
info := entry.FileInfo
if info.Size < 0 {
info.Size = int64(len(data))
}
if _, exists := result.index[info.Path]; !exists {
result.index[info.Path] = len(result.files)
}
result.files = append(result.files, tarFileEntry{info: info, data: data})
}
}
Loading
Loading