diff --git a/README.md b/README.md index ecc0b1d..7a57030 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,12 @@ for _, f := range result.Files { ## Supported formats -- `.zip`, `.jar`, `.whl`, `.nupkg`, `.egg` (ZIP-based) -- `.tar`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tar.xz` +- `.zip`, `.jar`, `.whl`, `.nupkg`, `.egg`, `.vsix` (ZIP-based) +- `.tar`, `.tar.gz`, `.tgz`, `.crate`, `.tar.bz2`, `.tar.xz` - `.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. ## License diff --git a/archives.go b/archives.go index 6b10360..a2bc1a3 100644 --- a/archives.go +++ b/archives.go @@ -1,10 +1,14 @@ // Package archives provides in-memory archive reading and browsing capabilities. // // It supports multiple archive formats including: -// - ZIP (.zip, .jar, .whl, .nupkg) -// - TAR (.tar, .tar.gz, .tgz, .tar.bz2, .tar.xz) +// - ZIP (.zip, .jar, .whl, .nupkg, .egg, .vsix) +// - TAR (.tar, .tar.gz, .tgz, .crate, .tar.bz2, .tar.xz) // - GEM (.gem - Ruby gems with nested tar structure) // +// The .apk extension is routed by content since Android packages are ZIP +// and Alpine packages are gzipped tar. Filenames without a recognised +// extension are opened by inspecting the first bytes. +// // The package is designed to work entirely in memory without writing to disk, // making it suitable for browsing cached artifacts on-demand. package archives @@ -205,14 +209,16 @@ func detectFormat(filename string) string { return formatTarXZ } - // Check simple extensions + // Check simple extensions. .apk is deliberately absent: Alpine packages + // are gzipped tarballs and Android packages are zips, so it falls + // through to content sniffing. ext := path.Ext(filename) switch ext { - case ".zip", ".jar", ".whl", ".nupkg", ".egg": + case ".zip", ".jar", ".whl", ".nupkg", ".egg", ".vsix": return formatZIP case ".tar": return formatTAR - case ".tgz": + case ".tgz", ".crate": return formatTGZ case ".gem": return formatGem diff --git a/archives_test.go b/archives_test.go index c343c57..780acc6 100644 --- a/archives_test.go +++ b/archives_test.go @@ -32,6 +32,9 @@ func TestDetectFormat(t *testing.T) { {"package.tar.bz2", "tar.bz2"}, {"package.tar.xz", "tar.xz"}, {"package.gem", "gem"}, + {"package.vsix", "zip"}, + {"package.crate", "tgz"}, + {"package.apk", ""}, // Ambiguous: routed by content sniff {"unknown.txt", ""}, {"Package.ZIP", "zip"}, // Case insensitive {"package.TAR.GZ", "tar.gz"}, @@ -357,6 +360,50 @@ func TestOpenDetectsExtensionlessArchives(t *testing.T) { } } +func TestOpenRegistryArtifactExtensions(t *testing.T) { + tests := []struct { + filename string + data []byte + want string + }{ + {"serde-1.0.0.crate", createTestTarGz(), "*archives.tarReader"}, + {"extension-1.0.0.vsix", createTestZip(), "*archives.zipReader"}, + {"android.apk", createTestZip(), "*archives.zipReader"}, + {"alpine.apk", createTestTarGz(), "*archives.tarReader"}, + } + + for _, test := range tests { + t.Run(test.filename+"/Open", func(t *testing.T) { + reader, err := Open(test.filename, bytes.NewReader(test.data)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reader.Close() }() + if got := fmt.Sprintf("%T", reader); got != test.want { + t.Fatalf("reader = %s, want %s", got, test.want) + } + files, err := reader.List() + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("archive contains no files") + } + }) + + t.Run(test.filename+"/OpenBytes", func(t *testing.T) { + reader, err := OpenBytes(test.filename, test.data) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reader.Close() }() + if got := fmt.Sprintf("%T", reader); got != test.want { + t.Fatalf("reader = %s, want %s", got, test.want) + } + }) + } +} + func TestOpenLimitsUnsupportedContentRead(t *testing.T) { content := bytes.NewReader(bytes.Repeat([]byte("x"), contentSniffSize*4)) _, err := Open("artifact", content)