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