Files
zeta-reconstructor/internal/tarball/tarball.go
T

196 lines
4.7 KiB
Go

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