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)
}
}