diff --git a/README.md b/README.md index e83a78f..42a6f5f 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 compression), and Ruby gem formats. +A Go library for reading and browsing archive files in memory. Supports ZIP, TAR (with gzip, bzip2, xz, zstd compression), and Ruby gem formats. ## Installation @@ -67,7 +67,7 @@ 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, and xz from the content. +`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 unsupported stream with no recognised extension. @@ -115,11 +115,11 @@ for _, f := range result.Files { ## Supported formats - `.zip`, `.jar`, `.whl`, `.nupkg`, `.egg`, `.vsix` (ZIP-based) -- `.tar`, `.tar.gz`, `.tgz`, `.crate`, `.tar.bz2`, `.tar.xz` +- `.tar`, `.tar.gz`, `.tgz`, `.crate`, `.tar.bz2`, `.tar.xz`, `.tar.zst` - `.gem` (Ruby gems with nested data.tar.gz) - `.apk` (routed by content: Android packages open as ZIP, Alpine packages open as gzipped tar) -Filenames without a recognised extension are opened by inspecting the first bytes for a ZIP, tar, gzip, bzip2, or xz signature. +Filenames without a recognised extension are opened by inspecting the first bytes for a ZIP, tar, gzip, bzip2, xz, or zstd signature. ## License diff --git a/archives.go b/archives.go index f1695f4..891a3e6 100644 --- a/archives.go +++ b/archives.go @@ -2,7 +2,7 @@ // // It supports multiple archive formats including: // - ZIP (.zip, .jar, .whl, .nupkg, .egg, .vsix) -// - TAR (.tar, .tar.gz, .tgz, .crate, .tar.bz2, .tar.xz) +// - TAR (.tar, .tar.gz, .tgz, .crate, .tar.bz2, .tar.xz, .tar.zst) // - GEM (.gem - Ruby gems with nested tar structure) // // The .apk extension is routed by content since Android packages are ZIP @@ -31,6 +31,7 @@ const ( formatTGZ = "tgz" formatTarBzip2 = "tar.bz2" formatTarXZ = "tar.xz" + formatTarZstd = "tar.zst" formatGem = "gem" contentSniffSize = 512 ) @@ -130,6 +131,8 @@ func openRaw(format string, raw []byte) (Reader, error) { return openTar(raw, "bzip2") case formatTarXZ: return openTar(raw, "xz") + case formatTarZstd: + return openTar(raw, "zstd") case formatGem: return openGem(raw) default: @@ -157,6 +160,8 @@ func archiveFormat(detected string) string { return formatTarBzip2 case "xz": return formatTarXZ + case "zstd": + return formatTarZstd default: return "" } @@ -209,6 +214,9 @@ func detectFormat(filename string) string { if strings.HasSuffix(filename, ".tar.xz") { return formatTarXZ } + if strings.HasSuffix(filename, ".tar.zst") { + return formatTarZstd + } // Check simple extensions. .apk is deliberately absent: Alpine packages // are gzipped tarballs and Android packages are zips, so it falls diff --git a/archives_test.go b/archives_test.go index 780acc6..e4eb4e7 100644 --- a/archives_test.go +++ b/archives_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/klauspost/compress/zstd" "github.com/ulikunitz/xz" ) @@ -31,6 +32,7 @@ func TestDetectFormat(t *testing.T) { {"package.tgz", "tgz"}, {"package.tar.bz2", "tar.bz2"}, {"package.tar.xz", "tar.xz"}, + {"package.tar.zst", "tar.zst"}, {"package.gem", "gem"}, {"package.vsix", "zip"}, {"package.crate", "tgz"}, @@ -237,6 +239,22 @@ func createTestTarBz2(t *testing.T) []byte { return data } +func createTestTarZst(t *testing.T) []byte { + t.Helper() + buf := new(bytes.Buffer) + w, err := zstd.NewWriter(buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(createTestTar()); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + func createTestTarXz(t *testing.T) []byte { t.Helper() buf := new(bytes.Buffer) @@ -326,6 +344,7 @@ func TestOpenDetectsExtensionlessArchives(t *testing.T) { {"gzip", createTestTarGz()}, {"bzip2", createTestTarBz2(t)}, {"xz", createTestTarXz(t)}, + {"zstd", createTestTarZst(t)}, } for _, test := range tests { t.Run(test.name+"/Open", func(t *testing.T) { diff --git a/go.mod b/go.mod index 772f650..22a6468 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,6 @@ go 1.25.6 require ( github.com/git-pkgs/magic v0.2.0 + github.com/klauspost/compress v1.19.2 github.com/ulikunitz/xz v0.5.16 ) diff --git a/go.sum b/go.sum index a3cf9e9..851b328 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/git-pkgs/magic v0.2.0 h1:c7HqVxnP8c88EaVMH0/KraDFVTcmiXckRiSvNZEnvMQ= github.com/git-pkgs/magic v0.2.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= diff --git a/tar.go b/tar.go index 0358f8e..e33a710 100644 --- a/tar.go +++ b/tar.go @@ -11,6 +11,7 @@ import ( "io/fs" "strings" + "github.com/klauspost/compress/zstd" "github.com/ulikunitz/xz" ) @@ -49,6 +50,13 @@ func openTar(raw []byte, compression string) (*tarReader, error) { return nil, fmt.Errorf("opening xz: %w", err) } r = xzReader + case "zstd": + dec, err := zstd.NewReader(content, zstd.WithDecoderConcurrency(1)) + if err != nil { + return nil, fmt.Errorf("opening zstd: %w", err) + } + defer dec.Close() + r = dec } tr := tar.NewReader(r)