From 2b2caf5848c7d9b9996e68559c7ba2ea3f3ac226 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 26 Sep 2026 09:17:13 +0100 Subject: [PATCH 1/2] Support TinyGo archive readers --- .github/workflows/ci.yml | 9 ++++++ README.md | 2 ++ extract.go | 30 ++----------------- extract_options.go | 31 +++++++++++++++++++ extract_test.go | 28 ++--------------- extract_tinygo.go | 14 +++++++++ extract_tinygo_test.go | 65 ++++++++++++++++++++++++++++++++++++++++ tar_read_test.go | 8 +++-- test_helpers_test.go | 33 ++++++++++++++++++++ 9 files changed, 164 insertions(+), 56 deletions(-) create mode 100644 extract_options.go create mode 100644 extract_tinygo.go create mode 100644 extract_tinygo_test.go create mode 100644 test_helpers_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 818cdf5..50f91d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,15 @@ jobs: - name: Test run: go test -v -race ./... + - name: Test TinyGo extraction stub + run: go test -tags=tinygo ./... + + - name: Build WebAssembly + run: GOOS=js GOARCH=wasm go build ./... + + - name: Build WASI + run: GOOS=wasip1 GOARCH=wasm go build ./... + lint: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index f3aa0d9..3514988 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ if err := archives.ExtractAll(reader, dir); err != nil { File permissions are preserved where the archive records them. Entries that the format marks as symlinks or other non-regular types are skipped. +Under TinyGo, `ExtractAll` returns an error matching `errors.ErrUnsupported` because `os.Root` is unavailable. In-memory reading through `Reader.Extract` remains available. + ### Comparing versions The `diff` subpackage compares two archives and produces unified diffs. It classifies each file as added, deleted, modified, or binary, and includes line-level diff output for text files. diff --git a/extract.go b/extract.go index 565773c..c070c69 100644 --- a/extract.go +++ b/extract.go @@ -1,3 +1,5 @@ +//go:build !tinygo + package archives import ( @@ -12,34 +14,6 @@ import ( "strings" ) -// ErrUnsafePath is returned by ExtractAll when an archive entry name would -// resolve outside the target directory. -var ErrUnsafePath = errors.New("archive entry escapes target directory") - -// ErrExtractLimit is returned by ExtractAll when the total decompressed bytes -// written would exceed the WithMaxBytes limit. -var ErrExtractLimit = errors.New("extracted bytes exceed limit") - -const ( - extractDirPerm = 0o755 - extractFilePerm = 0o644 -) - -type extractConfig struct { - maxBytes int64 -} - -// ExtractOption configures ExtractAll. -type ExtractOption func(*extractConfig) - -// WithMaxBytes caps the total number of decompressed bytes ExtractAll will -// write. The limit is enforced against bytes actually read from each entry, -// not header-declared sizes, so an archive whose headers under-report content -// still cannot exceed it. A value of zero or less disables the limit. -func WithMaxBytes(n int64) ExtractOption { - return func(c *extractConfig) { c.maxBytes = n } -} - type deferredChmod struct { path string perm fs.FileMode diff --git a/extract_options.go b/extract_options.go new file mode 100644 index 0000000..005df32 --- /dev/null +++ b/extract_options.go @@ -0,0 +1,31 @@ +package archives + +import "errors" + +const ( + extractDirPerm = 0o755 + extractFilePerm = 0o644 +) + +// ErrUnsafePath is returned by ExtractAll when an archive entry name would +// resolve outside the target directory. +var ErrUnsafePath = errors.New("archive entry escapes target directory") + +// ErrExtractLimit is returned by ExtractAll when the total decompressed bytes +// written would exceed the WithMaxBytes limit. +var ErrExtractLimit = errors.New("extracted bytes exceed limit") + +type extractConfig struct { + maxBytes int64 +} + +// ExtractOption configures ExtractAll. +type ExtractOption func(*extractConfig) + +// WithMaxBytes caps the total number of decompressed bytes ExtractAll will +// write. The limit is enforced against bytes actually read from each entry, +// not header-declared sizes, so an archive whose headers under-report content +// still cannot exceed it. A value of zero or less disables the limit. +func WithMaxBytes(n int64) ExtractOption { + return func(c *extractConfig) { c.maxBytes = n } +} diff --git a/extract_test.go b/extract_test.go index 76f1f40..931fc9a 100644 --- a/extract_test.go +++ b/extract_test.go @@ -1,3 +1,5 @@ +//go:build !tinygo + package archives import ( @@ -498,32 +500,6 @@ func createTarWithEntry(t *testing.T, name string) []byte { return buf.Bytes() } -func writeTarFile(t *testing.T, tw *tar.Writer, name, content string, mode int64) { - t.Helper() - err := tw.WriteHeader(&tar.Header{ - Name: name, - Size: int64(len(content)), - Mode: mode, - }) - if err != nil { - t.Fatal(err) - } - if _, err := tw.Write([]byte(content)); err != nil { - t.Fatal(err) - } -} - -func assertFileContent(t *testing.T, path, want string) { - t.Helper() - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading %s: %v", path, err) - } - if string(got) != want { - t.Fatalf("%s = %q, want %q", path, got, want) - } -} - func assertFileMode(t *testing.T, path string, want fs.FileMode) { t.Helper() info, err := os.Stat(path) diff --git a/extract_tinygo.go b/extract_tinygo.go new file mode 100644 index 0000000..8ab7bf4 --- /dev/null +++ b/extract_tinygo.go @@ -0,0 +1,14 @@ +//go:build tinygo + +package archives + +import ( + "errors" + "fmt" +) + +// ExtractAll returns an error wrapping errors.ErrUnsupported under TinyGo, +// which lacks os.Root. +func ExtractAll(r Reader, dir string, opts ...ExtractOption) error { + return fmt.Errorf("archives: disk extraction not available on this target: %w", errors.ErrUnsupported) +} diff --git a/extract_tinygo_test.go b/extract_tinygo_test.go new file mode 100644 index 0000000..52317eb --- /dev/null +++ b/extract_tinygo_test.go @@ -0,0 +1,65 @@ +//go:build tinygo + +package archives_test + +import ( + "archive/zip" + "bytes" + "errors" + "io" + "testing" + + "github.com/git-pkgs/archives" +) + +func TestExtractAllUnsupported(t *testing.T) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("package/README.md") + if err != nil { + t.Fatal(err) + } + const content = "# Test package\n" + if _, err := io.WriteString(w, content); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + r, err := archives.OpenBytes("package.zip", buf.Bytes()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close() }() + + for _, opts := range [][]archives.ExtractOption{nil, {archives.WithMaxBytes(1024)}} { + err := archives.ExtractAll(r, "extracted", opts...) + if !errors.Is(err, errors.ErrUnsupported) { + t.Fatalf("ExtractAll error = %v, want errors.ErrUnsupported", err) + } + if errors.Is(err, archives.ErrUnsafePath) || errors.Is(err, archives.ErrExtractLimit) { + t.Fatalf("ExtractAll returned a native extraction error: %v", err) + } + } + + entries, err := r.List() + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Path != "package/README.md" { + t.Fatalf("List = %v, want package/README.md", entries) + } + rc, err := r.Extract("package/README.md") + if err != nil { + t.Fatal(err) + } + defer func() { _ = rc.Close() }() + got, err := io.ReadAll(rc) + if err != nil { + t.Fatal(err) + } + if string(got) != content { + t.Fatalf("Extract content = %q, want %q", got, content) + } +} diff --git a/tar_read_test.go b/tar_read_test.go index ebd54ea..ca511ff 100644 --- a/tar_read_test.go +++ b/tar_read_test.go @@ -91,7 +91,9 @@ func TestTarPayloadSizes(t *testing.T) { t.Fatalf("Hash = %q, %v", gotHash, err) } dir := t.TempDir() - if err := ExtractAll(r, dir); err != nil { + if err := ExtractAll(r, dir); errors.Is(err, errors.ErrUnsupported) { + t.Skip("disk extraction is unavailable on this target") + } else if err != nil { t.Fatal(err) } for i, payload := range payloads { @@ -154,7 +156,9 @@ func TestTarDuplicatePayloads(t *testing.T) { } assertTarPayload(t, r, "index.js", []byte("first")) dir := t.TempDir() - if err := ExtractAll(r, dir); err != nil { + if err := ExtractAll(r, dir); errors.Is(err, errors.ErrUnsupported) { + t.Skip("disk extraction is unavailable on this target") + } else if err != nil { t.Fatal(err) } assertFileContent(t, filepath.Join(dir, "index.js"), "first") diff --git a/test_helpers_test.go b/test_helpers_test.go new file mode 100644 index 0000000..7267189 --- /dev/null +++ b/test_helpers_test.go @@ -0,0 +1,33 @@ +package archives + +import ( + "archive/tar" + "os" + "testing" +) + +func writeTarFile(t *testing.T, tw *tar.Writer, name, content string, mode int64) { + t.Helper() + err := tw.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Mode: mode, + }) + if err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + if string(got) != want { + t.Fatalf("%s = %q, want %q", path, got, want) + } +} From cd1cdfbb58c693c59432e2081c573687f0d21845 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 26 Sep 2026 20:34:32 +0100 Subject: [PATCH 2/2] Run archive regression tests with TinyGo in CI --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50f91d5..b95295b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,44 @@ jobs: - name: Build WASI run: GOOS=wasip1 GOARCH=wasm go build ./... + tinygo: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + - name: Install TinyGo and Wasmtime + working-directory: ${{ runner.temp }} + run: | + curl --fail --location --silent --show-error --output tinygo.tar.gz https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo0.42.0.linux-amd64.tar.gz + echo 'b87688fa2e19cee7d813cad7fd7dadb71dff3198e47125aba66ba4af5e490438 tinygo.tar.gz' | sha256sum --check + tar -xzf tinygo.tar.gz + echo "$RUNNER_TEMP/tinygo/bin" >> "$GITHUB_PATH" + curl --fail --location --silent --show-error --output wasmtime.tar.xz https://github.com/bytecodealliance/wasmtime/releases/download/v44.0.1/wasmtime-v44.0.1-x86_64-linux.tar.xz + echo 'afd58715f105e3a7f454169daed22168c5736ec5f225fb04c4ac62c54c9508a3 wasmtime.tar.xz' | sha256sum --check + tar -xJf wasmtime.tar.xz + echo "$RUNNER_TEMP/wasmtime-v44.0.1-x86_64-linux" >> "$GITHUB_PATH" + + - name: Test TinyGo WebAssembly archive reading + run: tinygo test -target=wasm -run '^TestExtractAllUnsupported$' -v . + + - name: Test TinyGo WASI archive reading + run: tinygo test -target=wasip1 -run '^TestExtractAllUnsupported$' -v . + lint: runs-on: ubuntu-latest steps: