From 275c9c744309604c988ea03e5d9150deccca2692 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 3 Aug 2026 14:44:31 +0100 Subject: [PATCH] Add .crate and .vsix extension aliases Route .crate to the gzipped-tar reader and .vsix to the zip reader in detectFormat so they open without falling back to content sniffing. .apk is left to the sniff fallback since Android packages are zip and Alpine packages are gzipped tar; add tests pinning both variants and document all three in the README. Closes #21 --- README.md | 7 +++++-- archives.go | 16 +++++++++++----- archives_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) 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)