package archive import ( "bytes" "io/fs" "os" "path/filepath" "testing" ) func TestRoundTripDir(t *testing.T) { src := t.TempDir() mustWrite(t, filepath.Join(src, "a.txt"), "alpha") mustWrite(t, filepath.Join(src, "sub/b.txt"), "bravo") if err := os.MkdirAll(filepath.Join(src, "empty"), 0o755); err != nil { t.Fatal(err) } var buf bytes.Buffer if err := EncodeDir(&buf, src); err != nil { t.Fatalf("EncodeDir: %v", err) } dest := t.TempDir() if err := DecodeDir(&buf, dest); err != nil { t.Fatalf("DecodeDir: %v", err) } for _, c := range []struct{ rel, want string }{ {"a.txt", "alpha"}, {"sub/b.txt", "bravo"}, } { got, err := os.ReadFile(filepath.Join(dest, c.rel)) if err != nil { t.Errorf("read %s: %v", c.rel, err) continue } if string(got) != c.want { t.Errorf("%s = %q, want %q", c.rel, got, c.want) } } if _, err := os.Stat(filepath.Join(dest, "empty")); err != nil { t.Errorf("empty dir missing: %v", err) } } // The Go module cache is 0555 directories holding 0444 files, and it is // the single most common thing this package is pointed at. Restoring the // archived modes as each entry lands locks the extraction out of the tree // it is still filling in — CI build #295 died on exactly that. func TestRoundTripReadOnlyTree(t *testing.T) { src := t.TempDir() pkg := filepath.Join(src, "mod", "example.com", "pkg@v1.0.0") mustWrite(t, filepath.Join(pkg, "go.mod"), "module example.com/pkg") mustWrite(t, filepath.Join(pkg, "doc.go"), "package pkg") // Tighten from the leaves up, or the chmods can't reach the children. for _, p := range []string{ filepath.Join(pkg, "go.mod"), filepath.Join(pkg, "doc.go"), } { if err := os.Chmod(p, 0o444); err != nil { t.Fatal(err) } } for _, d := range []string{pkg, filepath.Dir(pkg), filepath.Join(src, "mod")} { if err := os.Chmod(d, 0o555); err != nil { t.Fatal(err) } } unlockAfter(t, src) var buf bytes.Buffer if err := EncodeDir(&buf, src); err != nil { t.Fatalf("EncodeDir: %v", err) } dest := t.TempDir() unlockAfter(t, dest) if err := DecodeDir(&buf, dest); err != nil { t.Fatalf("DecodeDir into a fresh dir: %v", err) } rel := "mod/example.com/pkg@v1.0.0" got, err := os.ReadFile(filepath.Join(dest, rel, "doc.go")) if err != nil { t.Fatalf("read restored file: %v", err) } if string(got) != "package pkg" { t.Errorf("doc.go = %q", got) } // The archived modes must survive, not just the bytes: `go` refuses to // use a module cache it can write to. assertMode(t, filepath.Join(dest, rel), 0o555) assertMode(t, filepath.Join(dest, rel, "doc.go"), 0o444) } // Restoring over a populated, read-only cache is normal in CI: the second // extraction must replace the read-only files rather than fail on them. func TestDecodeOverExistingReadOnlyTree(t *testing.T) { src := t.TempDir() mustWrite(t, filepath.Join(src, "sub/f.txt"), "content") if err := os.Chmod(filepath.Join(src, "sub/f.txt"), 0o444); err != nil { t.Fatal(err) } if err := os.Chmod(filepath.Join(src, "sub"), 0o555); err != nil { t.Fatal(err) } unlockAfter(t, src) var first, second bytes.Buffer if err := EncodeDir(&first, src); err != nil { t.Fatalf("EncodeDir: %v", err) } second.Write(first.Bytes()) dest := t.TempDir() unlockAfter(t, dest) if err := DecodeDir(&first, dest); err != nil { t.Fatalf("first DecodeDir: %v", err) } if err := DecodeDir(&second, dest); err != nil { t.Fatalf("second DecodeDir over the restored tree: %v", err) } got, err := os.ReadFile(filepath.Join(dest, "sub/f.txt")) if err != nil || string(got) != "content" { t.Errorf("f.txt = %q, err=%v", got, err) } assertMode(t, filepath.Join(dest, "sub/f.txt"), 0o444) } func assertMode(t *testing.T, path string, want os.FileMode) { t.Helper() st, err := os.Stat(path) if err != nil { t.Fatalf("stat %s: %v", path, err) } if got := st.Mode().Perm(); got != want { t.Errorf("%s mode = %#o, want %#o", path, got, want) } } // unlockAfter makes a read-only tree removable again, so t.TempDir's // cleanup doesn't fail on the very permissions the test is about. func unlockAfter(t *testing.T, root string) { t.Helper() t.Cleanup(func() { _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { return nil } _ = os.Chmod(p, 0o700) return nil }) }) } func TestDecodeRejectsPathEscape(t *testing.T) { // Hand-craft a tarball with a "../evil" entry, then zstd it via EncodeDir // indirection isn't feasible (EncodeDir won't emit ..). Instead, write // a tar.zst by hand with one bad header. t.Skip("path-escape rejection covered by safeJoin unit logic; would need a hand-crafted tar to assert at integration level") } func mustWrite(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } } func TestSafeJoinRejectsEscape(t *testing.T) { base := "/tmp/abc" if _, err := safeJoin(base, "../etc/passwd"); err == nil { t.Error("expected escape rejection for ../etc/passwd") } if _, err := safeJoin(base, "sub/ok"); err != nil { t.Errorf("safe entry rejected: %v", err) } }