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
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,53 @@ 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 ./...

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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 2 additions & 28 deletions extract.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build !tinygo

package archives

import (
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions extract_options.go
Original file line number Diff line number Diff line change
@@ -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 }
}
28 changes: 2 additions & 26 deletions extract_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build !tinygo

package archives

import (
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions extract_tinygo.go
Original file line number Diff line number Diff line change
@@ -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)
}
65 changes: 65 additions & 0 deletions extract_tinygo_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 6 additions & 2 deletions tar_read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
33 changes: 33 additions & 0 deletions test_helpers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading