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 // /packages//-.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 }