feat(tarball): pure-Go ./-rooted tar.gz writer with sha256

This commit is contained in:
2026-08-19 16:34:35 -04:00
parent 45831809f6
commit 0938503a5a
2 changed files with 406 additions and 0 deletions
+195
View File
@@ -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)
}
+211
View File
@@ -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)
}
}