diff --git a/internal/tarball/tarball.go b/internal/tarball/tarball.go new file mode 100644 index 0000000..4ae076a --- /dev/null +++ b/internal/tarball/tarball.go @@ -0,0 +1,195 @@ +// Package tarball repacks a directory tree into a "./"-rooted .tar.gz +// archive matching Zeta's binary-package convention: `tar -czf dest -C stage .`, +// consumed on install with `archive = { strip = 1 }`. +// +// The "./" prefix on every entry is what makes --strip-components=1 produce +// clean relative paths (usr/bin/foo, not ./usr/bin/foo). +package tarball + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// rootDotMetadata is the set of Arch metadata files excluded at the ROOT of +// the staging tree only. Deeper dot-files (e.g. usr/share/.hidden) are real +// content and are archived. +var rootDotMetadata = map[string]bool{ + ".PKGINFO": true, + ".BUILDINFO": true, + ".MTREE": true, + ".INSTALL": true, + ".Changelog": true, +} + +// Write walks srcDir and writes a "./"-rooted, gzip-compressed tar archive to +// destPath. It returns the lowercase hex sha256 of the written file. +// +// File modes are preserved from the filesystem; symlinks are stored as +// symlinks with their targets; directory entries are included. Root dot +// metadata (.PKGINFO, .BUILDINFO, .MTREE, .INSTALL, .Changelog) is skipped. +// Paths that would climb above the root are rejected. +func Write(srcDir, destPath string) (string, error) { + f, err := os.Create(destPath) + if err != nil { + return "", fmt.Errorf("tarball: create %s: %w", destPath, err) + } + + h := sha256.New() + gz := gzip.NewWriter(io.MultiWriter(f, h)) + tw := tar.NewWriter(gz) + + walkErr := filepath.Walk(srcDir, func(p string, fi os.FileInfo, werr error) error { + if werr != nil { + return werr + } + + rel, err := filepath.Rel(srcDir, p) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if !relativeInside(rel) { + return fmt.Errorf("tarball: path %q escapes the root", rel) + } + + // The root itself, mirroring `tar -czf dest -C stage .`. + if rel == "." { + return tw.WriteHeader(&tar.Header{ + Name: "./", + Typeflag: tar.TypeDir, + Mode: int64(fi.Mode().Perm()), + ModTime: time.Time{}, + }) + } + + // Arch metadata lives only at the root; skip it there. + if !strings.Contains(rel, "/") && rootDotMetadata[filepath.Base(rel)] { + return nil + } + + base := &tar.Header{ + Mode: int64(fi.Mode().Perm()), + ModTime: time.Time{}, + } + + switch { + case fi.Mode()&os.ModeSymlink != 0: + target, err := os.Readlink(p) + if err != nil { + return err + } + if symlinkEscapes(rel, target) { + return fmt.Errorf("tarball: symlink %q -> %q escapes the root", rel, target) + } + base.Name = "./" + rel + base.Typeflag = tar.TypeSymlink + base.Linkname = target + if err := tw.WriteHeader(base); err != nil { + return err + } + + case fi.IsDir(): + base.Name = "./" + rel + "/" + base.Typeflag = tar.TypeDir + if err := tw.WriteHeader(base); err != nil { + return err + } + + case fi.Mode().IsRegular(): + base.Name = "./" + rel + base.Typeflag = tar.TypeReg + base.Size = fi.Size() + if err := tw.WriteHeader(base); err != nil { + return err + } + src, err := os.Open(p) + if err != nil { + return err + } + _, cerr := io.Copy(tw, src) + if e := src.Close(); cerr == nil { + cerr = e + } + if cerr != nil { + return cerr + } + + default: + // Skip sockets, FIFOs, devices and other special files, the + // same way GNU tar warns and continues. + } + return nil + }) + + // Always flush and close in order so the hash captures every byte. + cerr := tw.Close() + if cerr == nil { + cerr = gz.Close() + } else { + _ = gz.Close() + } + if cerr == nil { + cerr = f.Close() + } else { + _ = f.Close() + } + + if walkErr != nil { + return "", fmt.Errorf("tarball: walk %s: %w", srcDir, walkErr) + } + if cerr != nil { + return "", fmt.Errorf("tarball: write %s: %w", destPath, cerr) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// relativeInside reports whether the slash-separated relative path p never +// climbs above its root. Port of ZETA/lib/path.lua relative_inside. +func relativeInside(p string) bool { + depth := 0 + for _, seg := range strings.Split(p, "/") { + switch seg { + case "..": + depth-- + if depth < 0 { + return false + } + case "", ".": + // Empty segments and "." are ignored, like the Lua port. + default: + depth++ + } + } + return true +} + +// symlinkEscapes reports whether a symlink at rel whose target is target +// would escape the root. Absolute targets always escape; relative targets are +// resolved against the symlink's directory. Port of ZETA/lib/path.lua +// symlink_escapes. +func symlinkEscapes(rel, target string) bool { + if target == "" { + return false + } + if strings.HasPrefix(target, "/") { + return true + } + dir := "" + if i := strings.LastIndex(rel, "/"); i >= 0 { + dir = rel[:i] + } + combined := target + if dir != "" { + combined = dir + "/" + target + } + return !relativeInside(combined) +} diff --git a/internal/tarball/tarball_test.go b/internal/tarball/tarball_test.go new file mode 100644 index 0000000..3ed0ae8 --- /dev/null +++ b/internal/tarball/tarball_test.go @@ -0,0 +1,211 @@ +package tarball + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeFile creates path inside srcDir with the given content and mode. +func writeFile(t *testing.T, srcDir, rel, content string, mode os.FileMode) { + t.Helper() + p := filepath.Join(srcDir, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir for %s: %v", rel, err) + } + if err := os.WriteFile(p, []byte(content), mode); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + if err := os.Chmod(p, mode); err != nil { + t.Fatalf("chmod %s: %v", rel, err) + } +} + +// readEntries opens a gzip+tar archive and returns all headers, names, and +// file contents keyed by header name. +func readEntries(t *testing.T, dest string) (map[string]*tar.Header, map[string]string, []string) { + t.Helper() + f, err := os.Open(dest) + if err != nil { + t.Fatalf("open %s: %v", dest, err) + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + t.Fatalf("gzip open: %v", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + hdrs := map[string]*tar.Header{} + contents := map[string]string{} + var names []string + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("tar next: %v", err) + } + names = append(names, hdr.Name) + hdrs[hdr.Name] = hdr + if hdr.Typeflag == tar.TypeReg { + b, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("read %s: %v", hdr.Name, err) + } + contents[hdr.Name] = string(b) + } + } + return hdrs, contents, names +} + +func TestWriteRoundtrip(t *testing.T) { + src := t.TempDir() + dest := filepath.Join(t.TempDir(), "pkg.tar.gz") + + writeFile(t, src, "usr/bin/tool", "#!/bin/sh\necho hi\n", 0o755) + writeFile(t, src, "usr/lib/libtool.so", "ELFDATA", 0o644) + if err := os.Symlink("../lib/libtool.so", filepath.Join(src, "usr/bin/link")); err != nil { + t.Fatalf("symlink: %v", err) + } + writeFile(t, src, ".PKGINFO", "pkgname = hi\n", 0o644) + + sum, err := Write(src, dest) + if err != nil { + t.Fatalf("Write: %v", err) + } + + // Returned sha256 must equal the sha256 of the written file. + raw, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read dest: %v", err) + } + want := sha256.Sum256(raw) + if got := hex.EncodeToString(want[:]); got != sum { + t.Fatalf("sha256 mismatch:\n got %s\n want %s", sum, got) + } + if sum != strings.ToLower(sum) { + t.Fatalf("sha256 not lowercase hex: %q", sum) + } + + hdrs, contents, names := readEntries(t, dest) + + // Every entry must be rooted at "./" (Zeta archive={strip=1} convention). + if len(names) == 0 { + t.Fatal("archive is empty") + } + for _, n := range names { + if !strings.HasPrefix(n, "./") { + t.Errorf("entry %q does not start with ./", n) + } + } + + // Root directory entry present. + if h := hdrs["./"]; h == nil || h.Typeflag != tar.TypeDir { + t.Errorf("root ./ dir entry missing or wrong type: %+v", h) + } + + // Directory entries included (GNU tar style: trailing slash). + for _, want := range []string{"./usr/", "./usr/bin/", "./usr/lib/"} { + if h := hdrs[want]; h == nil || h.Typeflag != tar.TypeDir { + t.Errorf("dir entry %q missing or wrong type: %+v", want, h) + } + } + + // Root dot-metadata must never be included. + for n := range hdrs { + if strings.HasSuffix(n, ".PKGINFO") { + t.Errorf("root metadata %q must be excluded", n) + } + } + + // Executable keeps mode 0755 and its content round-trips. + h := hdrs["./usr/bin/tool"] + if h == nil { + t.Fatal("./usr/bin/tool missing") + } + if h.Typeflag != tar.TypeReg { + t.Errorf("./usr/bin/tool typeflag = %c, want %c", h.Typeflag, tar.TypeReg) + } + if got := h.Mode & 0o777; got != 0o755 { + t.Errorf("./usr/bin/tool mode = %#o, want 0755", got) + } + if contents["./usr/bin/tool"] != "#!/bin/sh\necho hi\n" { + t.Errorf("./usr/bin/tool content = %q", contents["./usr/bin/tool"]) + } + + // Plain file keeps 0644. + if h := hdrs["./usr/lib/libtool.so"]; h != nil { + if got := h.Mode & 0o777; got != 0o644 { + t.Errorf("./usr/lib/libtool.so mode = %#o, want 0644", got) + } + } + + // Symlink preserved with its target. + h = hdrs["./usr/bin/link"] + if h == nil { + t.Fatal("./usr/bin/link missing") + } + if h.Typeflag != tar.TypeSymlink { + t.Errorf("./usr/bin/link typeflag = %c, want %c", h.Typeflag, tar.TypeSymlink) + } + if h.Linkname != "../lib/libtool.so" { + t.Errorf("./usr/bin/link linkname = %q, want %q", h.Linkname, "../lib/libtool.so") + } + + // Determinism: a second write yields the same sha256. + sum2, err := Write(src, dest) + if err != nil { + t.Fatalf("second Write: %v", err) + } + if sum2 != sum { + t.Fatalf("nondeterministic output: %s != %s", sum, sum2) + } +} + +func TestWriteRejectsEscapingSymlink(t *testing.T) { + src := t.TempDir() + dest := filepath.Join(t.TempDir(), "pkg.tar.gz") + + if err := os.Symlink("../../etc/passwd", filepath.Join(src, "evil")); err != nil { + t.Fatalf("symlink: %v", err) + } + + _, err := Write(src, dest) + if err == nil { + t.Fatal("Write accepted a symlink whose target escapes the root") + } + if !strings.Contains(err.Error(), "escape") { + t.Errorf("error should mention escape, got: %v", err) + } +} + +func TestWriteKeepsNestedDotFiles(t *testing.T) { + src := t.TempDir() + dest := filepath.Join(t.TempDir(), "pkg.tar.gz") + + // Dot-files below the root are real content and must NOT be skipped. + writeFile(t, src, "usr/share/.hidden", "dotfile below root\n", 0o644) + writeFile(t, src, "usr/share/visible", "visible\n", 0o644) + + if _, err := Write(src, dest); err != nil { + t.Fatalf("Write: %v", err) + } + + hdrs, contents, _ := readEntries(t, dest) + if hdrs["./usr/share/.hidden"] == nil { + t.Fatal("nested dot-file ./usr/share/.hidden was wrongly skipped") + } + if got := contents["./usr/share/.hidden"]; got != "dotfile below root\n" { + t.Errorf("./usr/share/.hidden content = %q", got) + } +}