From 5cf0553ca223781c1443e09e36a0dbb8ae25b685 Mon Sep 17 00:00:00 2001 From: Andrey Bogoyavlenskiy Date: Wed, 5 Aug 2026 23:41:01 +0100 Subject: [PATCH] feat(rt): add os/unzip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (os/unzip zip-path dest-dir) extracts a zip archive into dest-dir and returns dest-dir. The destination is created if missing; existing files are overwritten. Motivation: dependency tooling built on let-go currently shells out to `unzip` to expand jars. A native extractor removes that host-binary requirement. Extraction is confined by os.Root, which opens each path component under the destination and refuses any that escapes. That covers both the lexical case ("../evil.txt") and the symbolic one ("link/x" where dest/link already points elsewhere), and unlike a check-then-write guard it leaves no window for a process sharing the destination to swap a validated directory for a symlink after the check. Absolute entry names are refused outright rather than silently re-rooted inside dest. Safety is favoured over fidelity besides, since the archives in the motivating use case arrive over the network: symlink entries are skipped rather than recreated, as are devices, fifos and sockets. A non-regular file already sitting at a target path is removed rather than written through. Permissions follow the unzip(1) contract — as recorded in the entry, masked by the process umask. Entries written by DOS/FAT-style tools record no unix mode and archive/zip synthesizes 0666 for them, so those fall back to 0644 rather than landing world-writable. Directory entries keep their recorded mode with owner rwx forced on, so a read-only directory listed before its contents cannot make the rest of the archive unextractable. Two failure modes are handled so that a failed extraction is never worse than no extraction: a directory entry colliding with an existing regular file is an error rather than a silently swallowed EEXIST, and each entry is opened before its destination is cleared, so an unreadable entry cannot destroy the file it was meant to replace. Covered by pkg/rt/os_unzip_test.go (archives built programmatically, including the adversarial ones) plus a test/os_unzip_test.lg smoke test whose fixture is embedded as base64 to keep test/ free of binary blobs. --- pkg/rt/os.go | 186 ++++++++++++++ pkg/rt/os_unzip_test.go | 520 ++++++++++++++++++++++++++++++++++++++++ test/os_unzip_test.lg | 52 ++++ 3 files changed, 758 insertions(+) create mode 100644 pkg/rt/os_unzip_test.go create mode 100644 test/os_unzip_test.lg diff --git a/pkg/rt/os.go b/pkg/rt/os.go index 5fb4549a2..dd975d085 100644 --- a/pkg/rt/os.go +++ b/pkg/rt/os.go @@ -8,12 +8,15 @@ package rt import ( + "archive/zip" "bytes" + "errors" "fmt" "io" "net" "os" "os/exec" + "path/filepath" "runtime" "github.com/nooga/let-go/pkg/vm" @@ -262,6 +265,28 @@ func installOsNS() { return vm.Int(port), nil })) + // os/unzip — (os/unzip zip-path dest-dir) → dest-dir + // Extracts a zip archive into dest-dir, creating it if missing and + // overwriting existing files. Entries that would land outside dest-dir + // are refused (see unzipEntryTarget); symlink entries are skipped. + ns.Def("unzip", mustWrap(func(vs []vm.Value) (vm.Value, error) { + if len(vs) != 2 { + return vm.NIL, fmt.Errorf("os/unzip expects 2 args") + } + src, ok := vs[0].(vm.String) + if !ok { + return vm.NIL, fmt.Errorf("os/unzip expected String path") + } + dest, ok := vs[1].(vm.String) + if !ok { + return vm.NIL, fmt.Errorf("os/unzip expected String destination") + } + if err := unzipTo(string(src), string(dest)); err != nil { + return vm.NIL, err + } + return vs[1], nil + })) + // os/os-name — (os/os-name) → "linux", "darwin", "windows", ... ns.Def("os-name", mustWrap(func(vs []vm.Value) (vm.Value, error) { return vm.String(runtime.GOOS), nil @@ -304,6 +329,167 @@ func lineSeparator() string { return "\n" } +// unzipTo extracts src into dest. +// +// Every write goes through an os.Root confined to dest, so an entry can +// neither escape lexically ("../evil.txt") nor through a symlink that already +// exists inside dest ("link/x" where dest/link points elsewhere). os.Root +// enforces containment while it walks each path component, which a +// check-then-write guard fundamentally cannot: another process sharing dest +// could swap a validated directory for a symlink in the window between the +// check and the write. +// +// Fidelity is traded for safety besides: symlink entries and other +// non-regular entries (devices, fifos, sockets) are skipped rather than +// recreated. Permissions follow the unzip(1) contract — as recorded in the +// entry, masked by the process umask. +func unzipTo(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + root, err := os.OpenRoot(dest) + if err != nil { + return err + } + defer root.Close() + + for _, f := range r.File { + mode := f.Mode() + if mode&os.ModeSymlink != 0 { + continue + } + // Zip names are always slash-separated, whatever the host. + name := filepath.Clean(filepath.FromSlash(f.Name)) + if name == "." { + continue + } + if f.FileInfo().IsDir() { + err = unzipDir(root, name, unzipDirPerm(f)) + } else if mode.IsRegular() { + err = unzipFile(root, f, name) + } else { + continue + } + if err != nil { + return fmt.Errorf("os/unzip: %s: %w", f.Name, err) + } + } + return nil +} + +// unzipEntryPerm reads the permissions a zip entry records for itself. Zip +// keeps unix permissions in the high 16 bits of the external attributes; a +// DOS/FAT-style writer leaves them empty and archive/zip synthesizes 0666, +// which under a permissive umask would land world-writable files on disk. So +// distinguish "recorded" from "synthesized" here and let callers pick their +// own default for the latter. +func unzipEntryPerm(f *zip.File) (os.FileMode, bool) { + if unix := f.ExternalAttrs >> 16; unix != 0 { + if perm := os.FileMode(unix).Perm(); perm != 0 { + return perm, true + } + } + return 0, false +} + +// unzipFilePerm is the mode to create a file entry with — as recorded (this +// is what carries the executable bit), else 0644. +func unzipFilePerm(f *zip.File) os.FileMode { + if perm, ok := unzipEntryPerm(f); ok { + return perm + } + return 0o644 +} + +// unzipDirPerm is the mode to create a directory entry with. Owner rwx is +// forced on: a read-only directory entry listed before the files it contains +// would otherwise make the rest of the archive unextractable. +func unzipDirPerm(f *zip.File) os.FileMode { + if perm, ok := unzipEntryPerm(f); ok { + return perm | 0o700 + } + return 0o755 +} + +// unzipParents creates name's parent directories inside root. +func unzipParents(root *os.Root, name string) error { + parent := filepath.Dir(name) + if parent == "." || parent == string(filepath.Separator) { + return nil + } + return root.MkdirAll(parent, 0o755) +} + +// unzipDir materialises an explicit directory entry. A directory created +// earlier as some file's implicit parent keeps the 0755 it got then — only a +// freshly created one carries the entry's recorded mode. +func unzipDir(root *os.Root, name string, perm os.FileMode) error { + if err := unzipParents(root, name); err != nil { + return err + } + err := root.Mkdir(name, perm) + if err == nil || !errors.Is(err, os.ErrExist) { + return err + } + // "Already exists" is only benign when what exists is itself a directory. + // A plain file (or a symlink) sitting at a directory entry's path is a + // genuine conflict — swallowing it would report a successful extraction + // while leaving the entry's contents nowhere to go. + info, statErr := root.Lstat(name) + if statErr != nil { + return statErr + } + if !info.IsDir() { + return errors.New("exists and is not a directory") + } + return nil +} + +// unzipFile writes one regular entry, creating parents as needed. +func unzipFile(root *os.Root, f *zip.File, name string) error { + if err := unzipParents(root, name); err != nil { + return err + } + // Open the entry BEFORE touching the destination: f.Open fails outright + // on an unsupported compression method, and destroying a perfectly good + // existing file on the way to an error nobody can recover from is worse + // than not extracting at all. + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + // Clear whatever is already at the target. Removing rather than + // overwriting in place matters twice over: O_CREATE applies its perm + // argument only when it creates the file, so an in-place overwrite would + // silently keep the old file's mode; and a symlink sitting there would + // otherwise be followed (os.Root confines where it may point, but a link + // to another path inside dest is legal and would still be written + // through). + if info, err := root.Lstat(name); err == nil && !info.IsDir() { + if err := root.Remove(name); err != nil { + return err + } + } + + out, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, unzipFilePerm(f)) + if err != nil { + return err + } + if _, err := io.Copy(out, rc); err != nil { + out.Close() + return err + } + return out.Close() +} + func mustWrap(fn func([]vm.Value) (vm.Value, error)) vm.Value { v, err := vm.NativeFnType.Wrap(fn) if err != nil { diff --git a/pkg/rt/os_unzip_test.go b/pkg/rt/os_unzip_test.go new file mode 100644 index 000000000..e0065e610 --- /dev/null +++ b/pkg/rt/os_unzip_test.go @@ -0,0 +1,520 @@ +//go:build !tinygo + +/* + * Copyright (c) 2026 let-go contributors + * SPDX-License-Identifier: MIT + */ + +package rt + +import ( + "archive/zip" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nooga/let-go/pkg/vm" +) + +// zipEntry describes one member of a zip built by writeZip. +type zipEntry struct { + name string + body string + mode fs.FileMode // zero means "no unix mode recorded" + dir bool +} + +// writeZip builds a zip at dir/name from entries and returns its path. +func writeZip(t *testing.T, dir, name string, entries []zipEntry) string { + t.Helper() + path := filepath.Join(dir, name) + f, err := os.Create(path) + if err != nil { + t.Fatalf("create zip: %v", err) + } + defer f.Close() + + zw := zip.NewWriter(f) + for _, e := range entries { + hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate} + if e.mode != 0 { + hdr.SetMode(e.mode) + } + if e.dir && !strings.HasSuffix(hdr.Name, "/") { + hdr.Name += "/" + } + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatalf("create entry %q: %v", e.name, err) + } + if !e.dir { + if _, err := w.Write([]byte(e.body)); err != nil { + t.Fatalf("write entry %q: %v", e.name, err) + } + } + } + if err := zw.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + return path +} + +// unzipFn resolves the registered os/unzip native. +func unzipFn(t *testing.T) vm.Fn { + t.Helper() + v := NS("os").Lookup(vm.Symbol("unzip")) + if v == nil || v == vm.NIL { + t.Fatal("os/unzip not found") + } + if vr, ok := v.(*vm.Var); ok { + v = vr.Deref() + } + fn, ok := v.(vm.Fn) + if !ok { + t.Fatalf("os/unzip is not an Fn, got %T", v) + } + return fn +} + +func callUnzip(t *testing.T, src, dest string) (vm.Value, error) { + t.Helper() + return unzipFn(t).Invoke([]vm.Value{vm.String(src), vm.String(dest)}) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +func TestOsUnzipExtractsFilesAndDirs(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "a.zip", []zipEntry{ + {name: "top.txt", body: "top"}, + {name: "nested/deep/leaf.txt", body: "leaf"}, + {name: "explicit", dir: true, mode: 0o755 | fs.ModeDir}, + {name: "explicit/inside.txt", body: "inside"}, + }) + dest := filepath.Join(tmp, "out") + + got, err := callUnzip(t, src, dest) + if err != nil { + t.Fatalf("unzip: %v", err) + } + if want := vm.String(dest); got != want { + t.Errorf("return = %#v, want %#v", got, want) + } + + for path, want := range map[string]string{ + filepath.Join(dest, "top.txt"): "top", + filepath.Join(dest, "nested", "deep", "leaf.txt"): "leaf", + filepath.Join(dest, "explicit", "inside.txt"): "inside", + } { + if got := readFile(t, path); got != want { + t.Errorf("%s = %q, want %q", path, got, want) + } + } + + // Implicit parent directories are created for nested entries. + info, err := os.Stat(filepath.Join(dest, "nested", "deep")) + if err != nil { + t.Fatalf("stat nested dir: %v", err) + } + if !info.IsDir() { + t.Errorf("nested/deep is not a directory") + } +} + +func TestOsUnzipCreatesMissingDestination(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "a.zip", []zipEntry{{name: "f.txt", body: "x"}}) + dest := filepath.Join(tmp, "does", "not", "exist") + + if _, err := callUnzip(t, src, dest); err != nil { + t.Fatalf("unzip: %v", err) + } + if got := readFile(t, filepath.Join(dest, "f.txt")); got != "x" { + t.Errorf("f.txt = %q, want %q", got, "x") + } +} + +func TestOsUnzipOverwritesExistingFile(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "a.zip", []zipEntry{{name: "f.txt", body: "new"}}) + dest := filepath.Join(tmp, "out") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("mkdir dest: %v", err) + } + // Pre-existing content is longer than the replacement — a truncating + // write is required, not just an overwrite of the first bytes. + if err := os.WriteFile(filepath.Join(dest, "f.txt"), []byte("stale stale stale"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + + if _, err := callUnzip(t, src, dest); err != nil { + t.Fatalf("unzip: %v", err) + } + if got := readFile(t, filepath.Join(dest, "f.txt")); got != "new" { + t.Errorf("f.txt = %q, want %q", got, "new") + } +} + +func TestOsUnzipAppliesEntryMode(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "a.zip", []zipEntry{ + {name: "script.sh", body: "#!/bin/sh\n", mode: 0o755}, + {name: "plain.txt", body: "p"}, // no recorded mode -> default 0644 + }) + dest := filepath.Join(tmp, "out") + if _, err := callUnzip(t, src, dest); err != nil { + t.Fatalf("unzip: %v", err) + } + + // Assert the bits that matter rather than the exact mode: os.OpenFile + // applies the process umask, so an exact comparison would fail under an + // unusual one. + info, err := os.Stat(filepath.Join(dest, "script.sh")) + if err != nil { + t.Fatalf("stat script: %v", err) + } + if got := info.Mode().Perm(); got&0o100 == 0 { + t.Errorf("script.sh mode = %v, want the owner-execute bit set", got) + } + + info, err = os.Stat(filepath.Join(dest, "plain.txt")) + if err != nil { + t.Fatalf("stat plain: %v", err) + } + perm := info.Mode().Perm() + if perm&0o111 != 0 { + t.Errorf("plain.txt mode = %v, want no execute bits", perm) + } + // The FAT-style entry records no unix mode; archive/zip would synthesize + // 0666, which is world-writable when the umask is permissive. + if perm&0o022 != 0 { + t.Errorf("plain.txt mode = %v, want no group/other write bits", perm) + } +} + +func TestOsUnzipRelativeDestination(t *testing.T) { + tmp := t.TempDir() + // Two entries under one nested directory: the second is extracted after + // the directory already exists, which is where a containment check that + // compares unnormalised paths goes wrong for a relative dest. + src := writeZip(t, tmp, "a.zip", []zipEntry{ + {name: "sub/one.txt", body: "1"}, + {name: "sub/two.txt", body: "2"}, + }) + work := filepath.Join(tmp, "work") + if err := os.MkdirAll(work, 0o755); err != nil { + t.Fatalf("mkdir work: %v", err) + } + t.Chdir(work) + + if _, err := callUnzip(t, src, "."); err != nil { + t.Fatalf("unzip into .: %v", err) + } + for name, want := range map[string]string{"one.txt": "1", "two.txt": "2"} { + if got := readFile(t, filepath.Join(work, "sub", name)); got != want { + t.Errorf("sub/%s = %q, want %q", name, got, want) + } + } +} + +func TestOsUnzipOverwriteAppliesEntryMode(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "a.zip", []zipEntry{{name: "run.sh", body: "#!/bin/sh\n", mode: 0o755}}) + dest := filepath.Join(tmp, "out") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("mkdir dest: %v", err) + } + // O_CREATE applies its perm only when it creates the file, so overwriting + // in place would leave this one non-executable. + if err := os.WriteFile(filepath.Join(dest, "run.sh"), []byte("old"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + + if _, err := callUnzip(t, src, dest); err != nil { + t.Fatalf("unzip: %v", err) + } + info, err := os.Stat(filepath.Join(dest, "run.sh")) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := info.Mode().Perm(); got&0o100 == 0 { + t.Errorf("run.sh mode = %v, want the owner-execute bit set after overwrite", got) + } +} + +func TestOsUnzipAppliesDirectoryEntryMode(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "a.zip", []zipEntry{ + {name: "private", dir: true, mode: 0o700 | fs.ModeDir}, + {name: "private/secret.txt", body: "s"}, + {name: "plain", dir: true}, + }) + dest := filepath.Join(tmp, "out") + if _, err := callUnzip(t, src, dest); err != nil { + t.Fatalf("unzip: %v", err) + } + + info, err := os.Stat(filepath.Join(dest, "private")) + if err != nil { + t.Fatalf("stat private: %v", err) + } + // A 0700 entry must not widen to 0755 just because the umask is permissive. + if got := info.Mode().Perm(); got&0o077 != 0 { + t.Errorf("private mode = %v, want no group/other bits", got) + } + // The entry's own contents still extract — owner rwx is forced on. + if got := readFile(t, filepath.Join(dest, "private", "secret.txt")); got != "s" { + t.Errorf("secret.txt = %q, want %q", got, "s") + } + + info, err = os.Stat(filepath.Join(dest, "plain")) + if err != nil { + t.Fatalf("stat plain: %v", err) + } + if !info.IsDir() { + t.Errorf("plain is not a directory") + } +} + +func TestOsUnzipRejectsZipSlip(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "evil.zip", []zipEntry{ + {name: "ok.txt", body: "ok"}, + {name: "../evil.txt", body: "pwned"}, + }) + dest := filepath.Join(tmp, "out") + + if _, err := callUnzip(t, src, dest); err == nil { + t.Fatal("expected an error for a ../ entry, got nil") + } + if _, err := os.Stat(filepath.Join(tmp, "evil.txt")); !os.IsNotExist(err) { + t.Errorf("escaped file was created outside dest (stat err = %v)", err) + } +} + +func TestOsUnzipRejectsAbsoluteEntryEscape(t *testing.T) { + tmp := t.TempDir() + outside := filepath.Join(tmp, "outside.txt") + // A deep ../ chain: even after Join cleans it, the result must not escape. + src := writeZip(t, tmp, "evil.zip", []zipEntry{ + {name: "../../../../../../../../etc/passwd-lg-test", body: "pwned"}, + {name: "sub/../../outside.txt", body: "pwned"}, + }) + dest := filepath.Join(tmp, "out") + + if _, err := callUnzip(t, src, dest); err == nil { + t.Fatal("expected an error for escaping entries, got nil") + } + if _, err := os.Stat(outside); !os.IsNotExist(err) { + t.Errorf("escaped file was created outside dest (stat err = %v)", err) + } +} + +func TestOsUnzipRejectsAbsoluteEntryName(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "evil.zip", []zipEntry{{name: "/etc/passwd-lg-test", body: "pwned"}}) + dest := filepath.Join(tmp, "out") + + // An absolute name is refused outright rather than quietly re-rooted + // inside dest — the archive asked for something it cannot have. + if _, err := callUnzip(t, src, dest); err == nil { + t.Fatal("expected an error for an absolute entry name, got nil") + } + if _, err := os.Stat(filepath.Join(dest, "etc", "passwd-lg-test")); !os.IsNotExist(err) { + t.Errorf("absolute entry was re-rooted into dest (stat err = %v)", err) + } +} + +func TestOsUnzipRejectsEscapeThroughPreexistingSymlink(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + victim := filepath.Join(target, "victim.txt") + if err := os.WriteFile(victim, []byte("untouched"), 0o644); err != nil { + t.Fatalf("seed victim: %v", err) + } + + dest := filepath.Join(tmp, "out") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("mkdir dest: %v", err) + } + // A symlink that already lives inside dest and points outside it. The + // lexical guard alone cannot see this — dest/link/victim.txt is + // lexically contained. + if err := os.Symlink(target, filepath.Join(dest, "link")); err != nil { + t.Skipf("symlinks unsupported here: %v", err) + } + + src := writeZip(t, tmp, "evil.zip", []zipEntry{ + {name: "link/victim.txt", body: "pwned"}, + {name: "link/deep/other.txt", body: "pwned"}, + }) + + if _, err := callUnzip(t, src, dest); err == nil { + t.Fatal("expected an error writing through a symlinked dir, got nil") + } + if got := readFile(t, victim); got != "untouched" { + t.Errorf("victim.txt = %q, want %q", got, "untouched") + } + if _, err := os.Stat(filepath.Join(target, "deep")); !os.IsNotExist(err) { + t.Errorf("directory created outside dest through symlink (stat err = %v)", err) + } +} + +func TestOsUnzipReplacesPreexistingSymlinkAtTarget(t *testing.T) { + tmp := t.TempDir() + victim := filepath.Join(tmp, "victim.txt") + if err := os.WriteFile(victim, []byte("untouched"), 0o644); err != nil { + t.Fatalf("seed victim: %v", err) + } + dest := filepath.Join(tmp, "out") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("mkdir dest: %v", err) + } + // The escape the parent-directory guard cannot see: the symlink is the + // target itself, so its parent is plain old dest. + if err := os.Symlink(victim, filepath.Join(dest, "f.txt")); err != nil { + t.Skipf("symlinks unsupported here: %v", err) + } + + src := writeZip(t, tmp, "a.zip", []zipEntry{{name: "f.txt", body: "new"}}) + if _, err := callUnzip(t, src, dest); err != nil { + t.Fatalf("unzip: %v", err) + } + if got := readFile(t, victim); got != "untouched" { + t.Errorf("victim.txt = %q, want %q — wrote through the symlink", got, "untouched") + } + if got := readFile(t, filepath.Join(dest, "f.txt")); got != "new" { + t.Errorf("f.txt = %q, want %q", got, "new") + } +} + +func TestOsUnzipSkipsSymlinkEntries(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "a.zip", []zipEntry{ + {name: "real.txt", body: "real"}, + {name: "evil-link", body: "/etc/passwd", mode: 0o777 | fs.ModeSymlink}, + }) + dest := filepath.Join(tmp, "out") + + if _, err := callUnzip(t, src, dest); err != nil { + t.Fatalf("unzip: %v", err) + } + if got := readFile(t, filepath.Join(dest, "real.txt")); got != "real" { + t.Errorf("real.txt = %q, want %q", got, "real") + } + if _, err := os.Lstat(filepath.Join(dest, "evil-link")); !os.IsNotExist(err) { + t.Errorf("symlink entry was materialised (lstat err = %v)", err) + } +} + +func TestOsUnzipRejectsDirEntryOverExistingFile(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "a.zip", []zipEntry{{name: "collide", dir: true}}) + dest := filepath.Join(tmp, "out") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("mkdir dest: %v", err) + } + if err := os.WriteFile(filepath.Join(dest, "collide"), []byte("i am a file"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + + // Reporting success here would leave the directory entry's contents with + // nowhere to go. + if _, err := callUnzip(t, src, dest); err == nil { + t.Fatal("expected an error for a dir entry colliding with a file, got nil") + } +} + +// unsupportedMethod is a compression method the reader has no decompressor +// for, so f.Open() fails on the way out. +const unsupportedMethod uint16 = 99 + +func TestOsUnzipKeepsExistingFileWhenEntryCannotBeOpened(t *testing.T) { + tmp := t.TempDir() + // Written with a method registered only on the writer side. + path := filepath.Join(tmp, "odd.zip") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create zip: %v", err) + } + zw := zip.NewWriter(f) + zw.RegisterCompressor(unsupportedMethod, func(w io.Writer) (io.WriteCloser, error) { + return nopWriteCloser{w}, nil + }) + w, err := zw.CreateHeader(&zip.FileHeader{Name: "keep.txt", Method: unsupportedMethod}) + if err != nil { + t.Fatalf("create entry: %v", err) + } + if _, err := w.Write([]byte("replacement")); err != nil { + t.Fatalf("write entry: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + f.Close() + + dest := filepath.Join(tmp, "out") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("mkdir dest: %v", err) + } + target := filepath.Join(dest, "keep.txt") + if err := os.WriteFile(target, []byte("precious"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + + if _, err := callUnzip(t, path, dest); err == nil { + t.Fatal("expected an error for an unreadable entry, got nil") + } + // The extraction failed; it must not have destroyed the old file first. + if got := readFile(t, target); got != "precious" { + t.Errorf("keep.txt = %q, want %q — deleted before the entry failed to open", got, "precious") + } +} + +type nopWriteCloser struct{ io.Writer } + +func (nopWriteCloser) Close() error { return nil } + +func TestOsUnzipArgErrors(t *testing.T) { + tmp := t.TempDir() + src := writeZip(t, tmp, "a.zip", []zipEntry{{name: "f.txt", body: "x"}}) + fn := unzipFn(t) + + cases := []struct { + name string + args []vm.Value + }{ + {"no args", nil}, + {"one arg", []vm.Value{vm.String(src)}}, + {"three args", []vm.Value{vm.String(src), vm.String(tmp), vm.String(tmp)}}, + {"non-string path", []vm.Value{vm.Int(1), vm.String(tmp)}}, + {"non-string dest", []vm.Value{vm.String(src), vm.Int(1)}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := fn.Invoke(c.args); err == nil { + t.Error("expected an error, got nil") + } + }) + } +} + +func TestOsUnzipMissingArchive(t *testing.T) { + tmp := t.TempDir() + if _, err := callUnzip(t, filepath.Join(tmp, "nope.zip"), filepath.Join(tmp, "out")); err == nil { + t.Fatal("expected an error for a missing archive, got nil") + } +} diff --git a/test/os_unzip_test.lg b/test/os_unzip_test.lg new file mode 100644 index 000000000..865ca36ce --- /dev/null +++ b/test/os_unzip_test.lg @@ -0,0 +1,52 @@ +;; os/unzip extracts a zip archive into a destination directory. +;; +;; The fixture is embedded as base64 rather than checked in as a binary +;; blob: `test/` is otherwise all text, and (io/decode :base64 …) returns a +;; raw byte string that spit writes verbatim. It holds two entries — +;; `top.txt` and `greeting/hello.txt` — so both a flat entry and a nested +;; one are covered. The zip-slip and symlink guards are exercised in +;; pkg/rt/os_unzip_test.go, which can build adversarial archives. +(ns test.os-unzip-test + (:require [test :refer :all])) + +(def fixture-b64 + (str "UEsDBBQACAAAAAAAIVwAAAAAAAAAAAAAAAAHAAkAdG9wLnR4dFVUBQABALlVaXRvcCBsZXZlbApQ" + "SwcIz6cu+woAAAAKAAAAUEsDBBQACAAAAAAAIVwAAAAAAAAAAAAAAAASAAkAZ3JlZXRpbmcvaGVs" + "bG8udHh0VVQFAAEAuVVpaGVsbG8gZnJvbSBhIHppcApQSwcINYcATBEAAAARAAAAUEsBAhQDFAAI" + "AAAAAAAhXM+nLvsKAAAACgAAAAcACQAAAAAAAAAAAKSBAAAAAHRvcC50eHRVVAUAAQC5VWlQSwEC" + "FAMUAAgAAAAAACFcNYcATBEAAAARAAAAEgAJAAAAAAAAAAAApIFIAAAAZ3JlZXRpbmcvaGVsbG8u" + "dHh0VVQFAAEAuVVpUEsFBgAAAAACAAIAhwAAAKIAAAAAAA==")) + +(def zip-path "/tmp/let-go-unzip-test.zip") +(def dest-dir "/tmp/let-go-unzip-test-out") + +(deftest unzip-extracts-archive + (spit zip-path (io/decode :base64 fixture-b64)) + + (testing "returns the destination directory" + (is (= dest-dir (os/unzip zip-path dest-dir)))) + + (testing "extracts a top-level entry" + (is (= "top level\n" (slurp (str dest-dir "/top.txt"))))) + + (testing "extracts a nested entry, creating its parent directory" + (is (= "hello from a zip\n" (slurp (str dest-dir "/greeting/hello.txt")))) + (is (:dir? (os/stat (str dest-dir "/greeting"))))) + + (testing "extracting twice overwrites cleanly" + (spit (str dest-dir "/top.txt") "clobbered by the test") + (os/unzip zip-path dest-dir) + (is (= "top level\n" (slurp (str dest-dir "/top.txt"))))) + + (delete-file (str dest-dir "/top.txt")) + (delete-file (str dest-dir "/greeting/hello.txt")) + (delete-file (str dest-dir "/greeting")) + (delete-file dest-dir) + (delete-file zip-path)) + +(deftest unzip-reports-a-missing-archive + ;; let-go has no thrown? — use try/catch directly. + (testing "a missing archive throws" + (is (= :threw (try (os/unzip "/tmp/let-go-no-such-archive.zip" dest-dir) + :no-throw + (catch _ :threw))))))