196 lines
4.7 KiB
Go
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)
|
|
}
|