feat(arch/binary): convert .pkg.tar.zst to Zeta binary package
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <[email protected]>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
package binary
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
|
||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/depmap"
|
||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/tarball"
|
||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/zeta"
|
||||
)
|
||||
|
||||
// rootMetadata is the set of Arch metadata members that live at the ROOT of
|
||||
// a .pkg.tar.zst and are never part of the installed payload. Only these
|
||||
// names at the root are skipped; a dot-file with the same name deeper in the
|
||||
// tree is real content.
|
||||
var rootMetadata = map[string]bool{
|
||||
".PKGINFO": true,
|
||||
".BUILDINFO": true,
|
||||
".MTREE": true,
|
||||
".INSTALL": true,
|
||||
".Changelog": true,
|
||||
}
|
||||
|
||||
// Convert converts an Arch binary package (.pkg.tar.zst) into a Zeta binary
|
||||
// package:
|
||||
//
|
||||
// 1. Open input with a zstd reader and walk the tar members.
|
||||
// 2. Read and parse the .PKGINFO member (member name ".PKGINFO" or
|
||||
// "./.PKGINFO"). Its pkgver is the Zeta version, verbatim (epoch+pkgrel
|
||||
// form, e.g. "1.0-1").
|
||||
// 3. Extract every other member (root dot-metadata skipped) into a temp dir,
|
||||
// preserving paths, modes and symlinks.
|
||||
// 4. Repack the payload with tarball.Write into
|
||||
// <out>/packages/<name>/<name>-<version>.tar.gz.
|
||||
// 5. Emit an archive-mode package.lua next to the tarball, with depends
|
||||
// mapped through depmap.MapDep.
|
||||
//
|
||||
// An epoch (":" in pkgver) is warned about and never folded: Zeta versioning
|
||||
// cannot represent it.
|
||||
func Convert(input string, opts registry.Options) error {
|
||||
f, err := os.Open(input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: open %s: %w", input, err)
|
||||
}
|
||||
zr, err := zstd.NewReader(f)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("convert: zstd %s: %w", input, err)
|
||||
}
|
||||
defer func() {
|
||||
zr.Close()
|
||||
_ = f.Close()
|
||||
}()
|
||||
tr := tar.NewReader(zr)
|
||||
|
||||
warn := func(msg string) { fmt.Fprintf(os.Stderr, "warning: %s\n", msg) }
|
||||
|
||||
extracted, err := os.MkdirTemp("", "zr-arch-binary-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(extracted)
|
||||
|
||||
var info *PKGInfo
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: read %s: %w", input, err)
|
||||
}
|
||||
|
||||
name := cleanMemberName(hdr.Name)
|
||||
|
||||
// Root dot-metadata: never extracted. .PKGINFO is read from the
|
||||
// stream here; the other four are skipped without reading.
|
||||
if isRootMetadata(name) {
|
||||
if name == ".PKGINFO" {
|
||||
content, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: read .PKGINFO: %w", err)
|
||||
}
|
||||
info, err = Parse(string(content), warn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: parse .PKGINFO: %w", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := extractMember(extracted, name, hdr, tr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if info == nil {
|
||||
return errors.New("convert: archive has no .PKGINFO member")
|
||||
}
|
||||
if info.Name == "" {
|
||||
return errors.New("convert: .PKGINFO has no pkgname")
|
||||
}
|
||||
|
||||
if strings.Contains(info.Version, ":") {
|
||||
warn(fmt.Sprintf("package %s has epoch in version; epoch is not representable in Zeta versioning", info.Name))
|
||||
}
|
||||
|
||||
// Deps: map each declared dependency through the Arch→Zeta name map.
|
||||
// Dropped deps are skipped (MapDep already warned).
|
||||
var deps []string
|
||||
for _, d := range info.Depends {
|
||||
if mapped, ok := depmap.MapDep(d, warn); ok {
|
||||
deps = append(deps, mapped)
|
||||
}
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(opts.Output, "packages", info.Name)
|
||||
if err := os.MkdirAll(pkgDir, 0o755); err != nil {
|
||||
return fmt.Errorf("convert: mkdir %s: %w", pkgDir, err)
|
||||
}
|
||||
|
||||
tarballPath := filepath.Join(pkgDir, info.Name+"-"+info.Version+".tar.gz")
|
||||
sum, err := tarball.Write(extracted, tarballPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: repack payload: %w", err)
|
||||
}
|
||||
|
||||
m := &zeta.Manifest{
|
||||
Name: info.Name,
|
||||
Version: info.Version,
|
||||
Summary: info.Desc,
|
||||
URL: opts.Repo + "/packages/" + info.Name + "/" + info.Name + "-" + info.Version + ".tar.gz",
|
||||
SHA256: sum,
|
||||
Deps: deps,
|
||||
Archive: &zeta.Archive{Strip: 1},
|
||||
}
|
||||
|
||||
src, err := m.Emit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: emit manifest: %w", err)
|
||||
}
|
||||
manifestPath := filepath.Join(pkgDir, "package.lua")
|
||||
if err := os.WriteFile(manifestPath, []byte(src), 0o644); err != nil {
|
||||
return fmt.Errorf("convert: write %s: %w", manifestPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanMemberName strips the "./" prefix (and any leading slashes) GNU tar
|
||||
// prepends, yielding the canonical root-relative member name.
|
||||
func cleanMemberName(name string) string {
|
||||
for strings.HasPrefix(name, "./") {
|
||||
name = name[2:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// isRootMetadata reports whether name is one of the Arch metadata members at
|
||||
// the ROOT of the archive (no directory component).
|
||||
func isRootMetadata(name string) bool {
|
||||
if name == "" || name == "." {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return false
|
||||
}
|
||||
return rootMetadata[name]
|
||||
}
|
||||
|
||||
// extractMember extracts a single tar member under root, preserving paths,
|
||||
// modes and symlinks. Directories are created; hard links, FIFOs and device
|
||||
// nodes are skipped, matching tarball.Write's policy.
|
||||
func extractMember(root, name string, hdr *tar.Header, r io.Reader) error {
|
||||
if name == "" || name == "." {
|
||||
return nil // the archive root entry itself
|
||||
}
|
||||
if strings.HasPrefix(name, "/") || !relativeInside(name) {
|
||||
return fmt.Errorf("convert: member %q escapes the extraction root", name)
|
||||
}
|
||||
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
mode := os.FileMode(hdr.Mode).Perm()
|
||||
if mode == 0 {
|
||||
mode = 0o755
|
||||
}
|
||||
return os.MkdirAll(target, mode)
|
||||
|
||||
case tar.TypeReg, tar.TypeRegA:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := os.FileMode(hdr.Mode).Perm()
|
||||
if mode == 0 {
|
||||
mode = 0o644
|
||||
}
|
||||
_ = os.Remove(target) // never write through a pre-existing symlink
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: extract %s: %w", name, err)
|
||||
}
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("convert: extract %s: %w", name, err)
|
||||
}
|
||||
return f.Close()
|
||||
|
||||
case tar.TypeSymlink:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(target)
|
||||
if err := os.Symlink(hdr.Linkname, target); err != nil {
|
||||
return fmt.Errorf("convert: extract symlink %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
// Hard links, FIFOs, devices, sparse files: skipped like the
|
||||
// repack step.
|
||||
return 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 "", ".":
|
||||
default:
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user