feat(tarball): pure-Go ./-rooted tar.gz writer with sha256
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user