Compare commits

..
10 Commits
Author SHA1 Message Date
huntedbytheirsandSisyphus deb4af4461 test(e2e): cross-validate emitted manifests/recipes against real Zeta Lua loaders
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-08-19 19:02:29 -04:00
huntedbytheirsandSisyphus 3aa03da5db docs: README with mapping rules and v1 support matrix
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-08-19 19:01:45 -04:00
huntedbytheirsandSisyphus bcd6c9a2e3 feat(arch/src): convert PKGBUILD/.SRCINFO to .recipe + build.sh
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-08-19 18:37:48 -04:00
huntedbytheirsandSisyphus 44c63b5cf7 feat(arch/src): .SRCINFO parser + PKGBUILD fallback
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-08-19 18:31:59 -04:00
huntedbytheirsandSisyphus 8267b45888 feat(arch/src): minimal-makepkg POSIX-sh build.sh generator
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-08-19 18:30:25 -04:00
huntedbytheirsandSisyphus 38ddc7cfb2 feat(cli): wire arch-binary subcommand
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-08-19 18:24:45 -04:00
huntedbytheirsandSisyphus 8ee8ec30db 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]>
2026-08-19 18:21:52 -04:00
huntedbytheirs ba1f93366d feat(depmap): Arch→Zeta dependency name mapping + base-system drop list 2026-08-19 16:43:35 -04:00
huntedbytheirsandSisyphus 32e7f6134a feat(arch/binary): .PKGINFO parser
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-08-19 16:38:58 -04:00
huntedbytheirsandSisyphus 970e002fbb feat(zeta): manifest model, validation, package.lua emitter
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
2026-08-19 16:36:15 -04:00
25 changed files with 3746 additions and 9 deletions
+133
View File
@@ -0,0 +1,133 @@
# zeta-reconstruct
zeta-reconstruct — convert Arch Linux packages (binary `.pkg.tar.zst` and source `PKGBUILD`/`.SRCINFO`) into Zeta package format.
The tool is a translator. It never runs makepkg, never compiles anything, and never publishes packages. It only converts files and writes them to disk, warning on stderr whenever a dependency or edge case cannot be mapped cleanly.
## Build
Requires Go 1.26 or newer. The only external dependency is `github.com/klauspost/compress` (zstd decoding); everything else uses the Go standard library.
go build ./cmd/zeta-reconstruct
This writes the `zeta-reconstruct` binary into the current directory. To place it next to the sources instead:
go build -o ./cmd/zeta-reconstruct/zeta-reconstruct ./cmd/zeta-reconstruct
## Usage
```
$ zeta-reconstruct --help
usage: zeta-reconstruct <command> [flags] <input>
Commands:
arch-binary convert an Arch binary package (.pkg.tar.zst) to Zeta
arch-src convert a PKGBUILD/.SRCINFO source recipe to Zeta
Flags:
--list list registered frontends
-h, --help show this help
arch-binary flags:
--output <dir> output directory (default ".")
--repo <url> repository base URL (default "https://raw.githubusercontent.com/gretagen/zeta-packages/refs/heads/main")
arch-src flags:
--output <dir> output directory (default ".")
--arch <arch> target architecture (default "x86_64")
```
`--list` prints the registered frontends: `arch-binary` and `arch-src`.
zeta-reconstruct --list
zeta-reconstruct arch-binary <input.pkg.tar.zst> --output <dir> [--repo <url>]
zeta-reconstruct arch-src <PKGBUILD|.SRCINFO|dir> --output <dir> [--arch <arch>]
For `arch-binary` the input must end in `.pkg.tar.zst`. For `arch-src` the input may be a `PKGBUILD`, a `.SRCINFO`, or a directory containing either; a `.SRCINFO` is preferred when both are present.
### Outputs
- `arch-binary` writes `packages/<name>/<name>-<version>.tar.gz` and a `package.lua` manifest beside it, under the `--output` directory.
- `arch-src` writes `<name>.recipe` and a generated `build.sh` into the `--output` directory.
### Workflow
- The emitted `package.lua` is an archive-mode manifest consumed by `zeta` (the package manager). `zeta` downloads the tarball at the manifest `url`, verifies its `sha256`, and installs it with `strip = 1`.
- The emitted `.recipe` plus `build.sh` are consumed by `zeta-makepkg` to build the binary package from source. The recipe declares `build_system = "custom"` and `build_script = "build.sh"`; zeta-makepkg fetches the source, extracts it, and runs the script under `sh` with `DESTDIR` set.
## Field mapping: `.PKGINFO` → `package.lua`
| `.PKGINFO` field | `package.lua` field | Notes |
| --- | --- | --- |
| `pkgname` | `name` | |
| `pkgver` | `version` | verbatim, already in epoch+pkgrel form (e.g. `1.0-1`) |
| `pkgdesc` | `summary` | |
| `url` | n/a | not carried over; the manifest `url` is built from `--repo` plus `packages/<name>/<name>-<version>.tar.gz` |
| `depend` | `deps` | each relation mapped through the Arch→Zeta name table; dropped deps are skipped with a warning |
| n/a | `archive` | `{ strip = 1 }` |
| n/a | `sha256` | sha256 of the emitted tarball |
Arch metadata members that are not payload (`.BUILDINFO`, `.MTREE`, `.INSTALL`, `.Changelog`) are skipped during extraction.
## Field mapping: `.SRCINFO`/`PKGBUILD` → `.recipe`
| source field | recipe field | Notes |
| --- | --- | --- |
| `pkgname` | `name` | v1 covers exactly one package per recipe |
| `pkgver` | `version` | upstream version; `pkgrel` is dropped |
| `pkgdesc` | `summary` | falls back to the package name when empty |
| `source[0]` | `url` | the text after a `name::` prefix if present, else the whole string; the URL scheme is never stripped |
| `sha256sums[0]` | `sha256` | the line is omitted entirely when the value is empty or `SKIP`; a non-hex64 value is dropped with a warning |
| `depends` | `deps` | each relation mapped through the Arch→Zeta name table |
| n/a | `build_system` | `"custom"` |
| n/a | `build_script` | `"build.sh"` |
The generated `build.sh` reproduces the PKGBUILD's `prepare()` (if present), `build()` (if present), and `package()` functions with `$pkgdir` pointed at `$DESTDIR`. Function bodies are embedded from the `PKGBUILD` file; a `.SRCINFO`-only input yields stub functions.
## Dependency mapping
Each Arch dependency relation is mapped in this order:
1. An optdepend-style description (`name: description`) is stripped back to the name.
2. If the name matches a Zeta package name verbatim (checked against the Zeta package index), it passes through unchanged. Version constraints are preserved; a bare Arch `=` is normalized to `==` (Zeta's canonical form).
3. Otherwise a curated rename table is applied, with a warning.
4. Otherwise, if the name is on the base-system drop list, it is dropped, with a warning.
5. Otherwise it passes through with a warning (nothing is silently lost).
### Renames
| Arch | Zeta |
| --- | --- |
| `zlib` | `libz` |
| `xz` | `xz-utils` |
| `freetype2` | `freetype` |
| `libxkbcommon` | `xkbcommon` |
| `libice` | `libICE` |
| `libsm` | `libSM` |
| `libx11` … `libxxf86vm` | `libX11` … `libXxf86vm` |
The X11 family is pure case correction: Arch spells it lowercase (`libx11`, `libxau`, `libxext`, `libxrender`, `libxft`, and the rest of the `libx*` set), while the Zeta index uses `libX*`. Names like `libxcb` and `libxml2` are already correct and are deliberately absent from the table.
### Dropped (base system)
These packages are provided outside the package manager, so Zeta has no package for them:
`glibc`, `gcc-libs`, `gcc`, `bash`, `coreutils`, `filesystem`, `linux-api-headers`, `systemd`, `util-linux`, `ncurses`, `readline`, `tzdata`, `ca-certificates`.
## Version and epoch
- Binary packages keep the `.PKGINFO` `pkgver` verbatim, epoch and pkgrel included (`1.0-1`, or `1:1.0-1` when an epoch exists).
- Source recipes use `version = pkgver` (the upstream version); `pkgrel` is dropped, matching how versions appear in the Zeta package index.
- An epoch is never folded into the Zeta version. When a `:` epoch is present, a warning is emitted and the version string keeps its epoch form.
## Not yet supported (v1)
The following shapes are not yet supported in v1. They produce an explicit error, except where noted:
- **Split packages** (more than one `pkgname`): error.
- **VCS sources**: `git+`, `svn+`, `hg+`, `bzr+` protocol prefixes, `<vcs>::<url>` fragments (e.g. `git::https://…`), and dynamic `pkgver()` functions: error.
- **Multiple sources** (more than one `source` entry): error.
- **Zero sources**: error (a recipe requires a `url`).
- **makedepends, optdepends, provides, conflicts, replaces**: parsed but dropped with a warning; Zeta's formats have no fields for them.
- **Bash-only constructs in PKGBUILD function bodies** (arrays, `[[ ]]`, `local`, process substitution): the generated `build.sh` carries a warning that it may need manual adjustment.
- **Gentoo ebuilds and void-src packages**: not supported. The frontend registry interface is in place, so future converters can be added as new frontends, but no such converter ships in v1.
+34 -1
View File
@@ -6,6 +6,7 @@ import (
"flag"
"fmt"
"os"
"strings"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
@@ -39,6 +40,29 @@ func main() {
os.Exit(run(os.Args[1:]))
}
// normalizeArgs reorders args into [flags and their values..., positionals...]
// because stdlib flag.Parse stops at the first non-flag token, which would
// misparse flags placed after the positional input.
func normalizeArgs(args []string, valueFlags map[string]bool) []string {
var flags, positionals []string
for i := 0; i < len(args); i++ {
tok := args[i]
if !strings.HasPrefix(tok, "-") || tok == "-" {
positionals = append(positionals, tok)
continue
}
flags = append(flags, tok)
if !strings.Contains(tok, "=") {
name := tok
if valueFlags[name] && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
}
return append(flags, positionals...)
}
func run(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "error: no command specified")
@@ -67,21 +91,25 @@ func run(args []string) int {
fs := flag.NewFlagSet(frontend.Name(), flag.ContinueOnError)
fs.SetOutput(os.Stderr)
opts := registry.Options{Output: "."}
valueFlags := make(map[string]bool)
switch frontend.Name() {
case "arch-binary":
opts.Repo = defaultRepo
fs.StringVar(&opts.Repo, "repo", opts.Repo, "repository base URL")
valueFlags["--repo"] = true
case "arch-src":
opts.Arch = "x86_64"
fs.StringVar(&opts.Arch, "arch", opts.Arch, "target architecture")
valueFlags["--arch"] = true
}
fs.StringVar(&opts.Output, "output", opts.Output, "output directory")
valueFlags["--output"] = true
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: zeta-reconstruct %s [flags] <input>\n", frontend.Name())
fmt.Fprintln(os.Stderr, "Run 'zeta-reconstruct --help' for usage.")
}
if err := fs.Parse(args[1:]); err != nil {
if err := fs.Parse(normalizeArgs(args[1:], valueFlags)); err != nil {
if err == flag.ErrHelp {
fs.Usage()
return 0
@@ -100,6 +128,11 @@ func run(args []string) int {
fs.Usage()
return 2
}
if frontend.Name() == "arch-binary" && !strings.HasSuffix(rest[0], ".pkg.tar.zst") {
fmt.Fprintln(os.Stderr, "error: arch-binary input must end with .pkg.tar.zst")
fs.Usage()
return 2
}
if err := frontend.Convert(rest[0], opts); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"reflect"
"testing"
)
func TestNormalizeArgs(t *testing.T) {
valueFlags := map[string]bool{
"--output": true,
"--repo": true,
"--arch": true,
}
tests := []struct {
name string
in []string
want []string
}{
{
name: "flags after positional",
in: []string{"input.pkg.tar.zst", "--output", "/tmp/out"},
want: []string{"--output", "/tmp/out", "input.pkg.tar.zst"},
},
{
name: "flags before positional",
in: []string{"--output", "/tmp/out", "input.pkg.tar.zst"},
want: []string{"--output", "/tmp/out", "input.pkg.tar.zst"},
},
{
name: "flag=value form",
in: []string{"input.pkg.tar.zst", "--output=/tmp/out"},
want: []string{"--output=/tmp/out", "input.pkg.tar.zst"},
},
{
name: "unknown flag stays a flag",
in: []string{"input.pkg.tar.zst", "--bogus"},
want: []string{"--bogus", "input.pkg.tar.zst"},
},
{
name: "value-taking flag at end without value",
in: []string{"input.pkg.tar.zst", "--output"},
want: []string{"--output", "input.pkg.tar.zst"},
},
{
name: "mixed flags and positionals keep order",
in: []string{"--repo", "https://x", "a.zst", "--output", "/tmp/out", "b.zst"},
want: []string{"--repo", "https://x", "--output", "/tmp/out", "a.zst", "b.zst"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeArgs(tt.in, valueFlags)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("normalizeArgs(%v) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
+2
View File
@@ -1,3 +1,5 @@
module git.spectoria.dev/huntedbytheirs/zeta-reconstruct
go 1.26.5
require github.com/klauspost/compress v1.19.2
+2
View File
@@ -0,0 +1,2 @@
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+251
View File
@@ -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
}
+204
View File
@@ -0,0 +1,204 @@
package binary
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/klauspost/compress/zstd"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
)
const fixturePkg = "../../../testdata/hello-1.0-1-x86_64.pkg.tar.zst"
func TestConvert(t *testing.T) {
out := t.TempDir()
err := Convert(fixturePkg, registry.Options{Output: out, Repo: "https://example.org/repo"})
if err != nil {
t.Fatalf("Convert() error = %v, want nil", err)
}
manifestPath := filepath.Join(out, "packages", "hello", "package.lua")
tarballPath := filepath.Join(out, "packages", "hello", "hello-1.0-1.tar.gz")
content, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("package.lua not written: %v", err)
}
src := string(content)
if !strings.Contains(src, "archive = { strip = 1 }") {
t.Errorf("package.lua does not contain archive mode marker:\n%s", src)
}
if !strings.Contains(src, `name = "hello"`) {
t.Errorf("package.lua does not contain name field:\n%s", src)
}
if _, err := os.Stat(tarballPath); err != nil {
t.Fatalf("tarball not written: %v", err)
}
// The sha256 field must equal a fresh hash of the written tarball.
got := extractField(t, src, "sha256")
want := fileSHA256(t, tarballPath)
if got != want {
t.Errorf("package.lua sha256 = %q, sha256(tarball) = %q", got, want)
}
// Deps: glibc dropped, zlib renamed to libz.
if !strings.Contains(src, `"libz"`) {
t.Errorf("package.lua deps does not contain mapped libz:\n%s", src)
}
if strings.Contains(src, "glibc") {
t.Errorf("package.lua still contains dropped glibc:\n%s", src)
}
// Cross-tool validation: the emitted manifest must load through the
// real Zeta manifest loader.
lua, err := exec.LookPath("lua")
if err != nil {
t.Skip("lua not available; skipping cross-tool validation")
}
probe := `package.path="/home/specter/Public/ZETA/lib/?.lua;"..package.path; ` +
`local m=require("manifest"); assert(m.load(` + quoteLua(manifestPath) + `))`
cmd := exec.Command(lua, "-e", probe)
if b, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("real Zeta manifest.load rejected the emitted manifest: %v\n%s", err, b)
}
// The tarball must be a valid gzip+tar whose content matches the input
// payload (usr/bin/hello).
verifyTarballPayload(t, tarballPath)
}
func TestConvertMissingPKGINFO(t *testing.T) {
// A zstd-compressed tar without a .PKGINFO member.
var buf strings.Builder
tw := tar.NewWriter(&buf)
body := "#!/bin/sh\n"
if err := tw.WriteHeader(&tar.Header{Name: "usr/bin/x", Mode: 0o755, Size: int64(len(body))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(body)); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
enc, err := zstd.NewWriter(nil)
if err != nil {
t.Fatal(err)
}
compressed := enc.EncodeAll([]byte(buf.String()), nil)
pkg := filepath.Join(t.TempDir(), "noinfo.pkg.tar.zst")
if err := os.WriteFile(pkg, compressed, 0o644); err != nil {
t.Fatal(err)
}
err = Convert(pkg, registry.Options{Output: t.TempDir(), Repo: "https://example.org/repo"})
if err == nil {
t.Fatal("Convert() error = nil, want error for archive without .PKGINFO")
}
if !strings.Contains(err.Error(), "PKGINFO") {
t.Errorf("Convert() error = %q, want it to name the missing .PKGINFO", err)
}
}
func TestConvertMissingInput(t *testing.T) {
err := Convert(filepath.Join(t.TempDir(), "nope.pkg.tar.zst"),
registry.Options{Output: t.TempDir(), Repo: "https://example.org/repo"})
if err == nil {
t.Fatal("Convert() error = nil, want error for missing input file")
}
}
// extractField pulls the value of a `key = "value",` line from an emitted
// package.lua.
func extractField(t *testing.T, src, key string) string {
t.Helper()
for _, line := range strings.Split(src, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, key) {
continue
}
i := strings.Index(line, `"`)
j := strings.LastIndex(line, `"`)
if i < 0 || j <= i {
t.Fatalf("field %q has no quoted value in %q", key, line)
}
return line[i+1 : j]
}
t.Fatalf("field %q not found in package.lua", key)
return ""
}
// fileSHA256 returns the lowercase hex sha256 of the file at path.
func fileSHA256(t *testing.T, path string) string {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
t.Fatal(err)
}
return hex.EncodeToString(h.Sum(nil))
}
// quoteLua renders a path as a safe single-quoted Lua string literal.
func quoteLua(s string) string {
return `'` + strings.ReplaceAll(s, `\`, `\\`) + `'`
}
// verifyTarballPayload re-opens the emitted tarball and asserts the payload
// file survived with the right content.
func verifyTarballPayload(t *testing.T, path string) {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
defer gz.Close()
var found bool
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if strings.TrimPrefix(hdr.Name, "./") == "usr/bin/hello" {
found = true
b, err := io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), "hello") {
t.Errorf("payload usr/bin/hello content = %q", b)
}
}
}
if !found {
t.Error("payload member usr/bin/hello missing from tarball")
}
}
+3 -4
View File
@@ -3,8 +3,6 @@
package binary
import (
"errors"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
)
@@ -13,9 +11,10 @@ type frontend struct{}
func (frontend) Name() string { return "arch-binary" }
// Convert converts an Arch .pkg.tar.zst into a Zeta binary package
// (payload tarball + archive-mode package.lua) under opts.Output.
func (frontend) Convert(input string, opts registry.Options) error {
// Implemented in a later todo (.pkg.tar.zst -> tarball + package.lua).
return errors.New("not implemented")
return Convert(input, opts)
}
func init() {
+74
View File
@@ -0,0 +1,74 @@
package binary
import (
"errors"
"strings"
)
// PKGInfo is the parsed content of an ALPM .PKGINFO file, the metadata
// member at the root of an Arch .pkg.tar.zst package.
type PKGInfo struct {
Name, Version, Desc, URL, Arch string
Depends, OptDepends, Provides, Conflicts, Replaces []string
}
// Parse parses ALPM .PKGINFO content: lines of "key = value", full-line
// `#` comments and blank lines ignored. Multi-value keys (depend,
// optdepend, provides, conflicts, replaces, license) accumulate across
// lines; single-value keys (pkgname, pkgver, pkgdesc, url, arch, pkgbase)
// take the last occurrence. pkgver is the FULL version, already in
// epoch+pkgrel form (e.g. "1.0-1" or "1:1.0.0-1"), and is passed through
// verbatim. Unknown keys are ignored. Malformed lines (no '=') are
// reported through warn and skipped, not fatal.
//
// Parse returns an error if pkgname is missing: a .PKGINFO without a name
// is invalid for conversion.
func Parse(src string, warn func(string)) (*PKGInfo, error) {
info := &PKGInfo{}
haveName := false
for _, line := range strings.Split(src, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.Index(line, "=")
if eq < 0 {
if warn != nil {
warn("malformed .PKGINFO line (no '='): " + line)
}
continue
}
key := strings.TrimSpace(line[:eq])
value := strings.TrimSpace(line[eq+1:])
switch key {
case "pkgname":
info.Name = value
haveName = true
case "pkgver":
info.Version = value
case "pkgdesc":
info.Desc = value
case "url":
info.URL = value
case "arch":
info.Arch = value
case "depend":
info.Depends = append(info.Depends, value)
case "optdepend":
info.OptDepends = append(info.OptDepends, value)
case "provides":
info.Provides = append(info.Provides, value)
case "conflicts":
info.Conflicts = append(info.Conflicts, value)
case "replaces":
info.Replaces = append(info.Replaces, value)
default:
// pkgbase, license, builddate, packager, size, xdata and any
// unknown keys are not represented in PKGInfo; ignore them.
}
}
if !haveName {
return nil, errors.New("pkginfo: missing pkgname")
}
return info, nil
}
+135
View File
@@ -0,0 +1,135 @@
package binary
import (
"os"
"reflect"
"strings"
"testing"
)
// readFixture loads a committed .PKGINFO fixture relative to the repo root.
func readFixture(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %s: %v", path, err)
}
return string(b)
}
func TestPKGInfo(t *testing.T) {
tests := []struct {
name string
src string
want *PKGInfo
}{
{
name: "hello fixture",
src: readFixture(t, "../../../testdata/pkginfo/hello.PKGINFO"),
want: &PKGInfo{
Name: "hello",
Version: "1.0-1",
Desc: "A tiny demonstration program",
URL: "https://example.org",
Arch: "x86_64",
Depends: []string{"glibc", "zlib"},
},
},
{
name: "multi fixture",
src: readFixture(t, "../../../testdata/pkginfo/multi.PKGINFO"),
want: &PKGInfo{
Name: "multiload",
Version: "2.3-1",
Desc: "Multi dep test",
URL: "https://example.org",
Arch: "x86_64",
Depends: []string{"glibc", "pcre2>=10.42"},
OptDepends: []string{"python: for scripting"},
Provides: []string{"multiload"},
},
},
{
name: "epoch fixture keeps colon",
src: readFixture(t, "../../../testdata/pkginfo/epoch.PKGINFO"),
want: &PKGInfo{
Name: "epochtool",
Version: "1:1.0.0-1",
Desc: "Epoch test",
URL: "https://example.org",
Arch: "x86_64",
Depends: []string{"glibc"},
},
},
{
name: "comments blank lines unknown keys and whitespace",
src: strings.Join([]string{
"# full-line comment",
"",
" pkgname = spaced ",
"pkgver = 9.9-9",
"pkgbase = ignored-base",
"license = GPL2",
"license = MIT",
"builddate = 1724000000",
"unknown = whatever",
"conflicts = oldtool",
"replaces = replaced",
}, "\n") + "\n",
want: &PKGInfo{
Name: "spaced",
Version: "9.9-9",
Conflicts: []string{"oldtool"},
Replaces: []string{"replaced"},
},
},
{
name: "last occurrence wins for single-value keys",
src: "pkgname = first\npkgver = 1.0-1\npkgname = second\npkgver = 2.0-2\n",
want: &PKGInfo{
Name: "second",
Version: "2.0-2",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.src, func(msg string) {
t.Errorf("unexpected warn: %s", msg)
})
if err != nil {
t.Fatalf("Parse() error = %v, want nil", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Parse() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestPKGInfoMalformedLineWarns(t *testing.T) {
src := "pkgname = ok\npkgver = 1.0-1\ngarbage no equals\n"
var warns []string
info, err := Parse(src, func(msg string) {
warns = append(warns, msg)
})
if err != nil {
t.Fatalf("Parse() error = %v, want nil", err)
}
if len(warns) != 1 {
t.Fatalf("warn called %d times, want exactly 1: %v", len(warns), warns)
}
if !strings.Contains(warns[0], "garbage no equals") {
t.Errorf("warn message %q does not mention the malformed line", warns[0])
}
if info.Name != "ok" || info.Version != "1.0-1" {
t.Errorf("Parse() = %#v, want name ok / version 1.0-1 despite malformed line", info)
}
}
func TestPKGInfoMissingName(t *testing.T) {
_, err := Parse("pkgver = 1.0-1\n", func(string) {})
if err == nil {
t.Fatal("Parse() error = nil, want error for missing pkgname")
}
}
+193
View File
@@ -0,0 +1,193 @@
// Package src converts Arch Linux source packages (PKGBUILD/.SRCINFO) into
// Zeta recipes.
package src
import (
"errors"
"fmt"
"os"
"regexp"
"strings"
)
// SrcInfo holds the parsed metadata of an Arch source package, produced by
// the .SRCINFO parser / PKGBUILD fallback (todo 10). The field layout is
// fixed by the plan; the parallel task's srcinfo.go populates this struct.
type SrcInfo struct {
PkgBase string
PkgNames []string
Version string
Rel string
Epoch string
Desc string
URL string
Arch []string
Depends []string
Makedepends []string
Optdepends []string
Provides []string
Conflicts []string
Replaces []string
Sources []string
SHA256Sums []string
}
// Generate emits a self-contained POSIX-sh build script (build.sh) that
// reproduces the PKGBUILD's prepare/build/package steps under zeta-makepkg's
// custom build contract:
//
// cd <source_dir> && DESTDIR=<stage> sh build.sh
//
// so $PWD at script start is the extracted source root and $DESTDIR is the
// staging tree. Arch's package() installs into $pkgdir; mapping
// pkgdir="$DESTDIR" is the whole trick.
//
// Function bodies declared by the PKGBUILD at pkgbuildPath are embedded
// verbatim; missing functions become no-op stubs and are not called. If an
// embedded body uses bash-only constructs, a warning is printed to stderr but
// the script is still emitted.
func Generate(s *SrcInfo, pkgbuildPath string) (string, error) {
if s == nil {
return "", errors.New("arch/src: nil SrcInfo")
}
raw, err := os.ReadFile(pkgbuildPath)
if err != nil {
return "", fmt.Errorf("arch/src: read PKGBUILD %s: %w", pkgbuildPath, err)
}
funcs, err := extractFunctions(string(raw))
if err != nil {
return "", err
}
if hasBashOnly(funcs) {
fmt.Fprintln(os.Stderr, "warning: build.sh embeds bash-only constructs; may need manual adjustment under POSIX sh")
}
var b strings.Builder
b.WriteString("#!/bin/sh\n")
b.WriteString("set -eu\n")
b.WriteString("\n")
b.WriteString("pkgname=" + shQuote(pkgName(s)) + "\n")
b.WriteString("pkgver=" + shQuote(s.Version) + "\n")
b.WriteString("pkgrel=" + shQuote(s.Rel) + "\n")
b.WriteString("arch=" + shQuote(strings.Join(s.Arch, " ")) + "\n")
b.WriteString("url=" + shQuote(s.URL) + "\n")
b.WriteString("\n")
b.WriteString("srcdir=\"$PWD\"\n")
b.WriteString("pkgdir=\"$DESTDIR\"\n")
b.WriteString("\n")
b.WriteString("msg() { :; }\n")
b.WriteString("\n")
var calls []string
for _, f := range funcs {
if f.present {
b.WriteString(f.body)
calls = append(calls, f.name)
} else {
fmt.Fprintf(&b, "%s() { :; }\n", f.name)
}
b.WriteString("\n")
}
for _, name := range calls {
b.WriteString(name + "\n")
}
return b.String(), nil
}
// pkgName prefers the pkgname section value; fall back to pkgbase.
func pkgName(s *SrcInfo) string {
if len(s.PkgNames) > 0 {
return s.PkgNames[0]
}
return s.PkgBase
}
// shQuote single-quotes a value for safe embedding in sh source.
func shQuote(v string) string {
return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'"
}
// pkgFunc is one extracted PKGBUILD function: its verbatim text (including
// the "name() {" opening and closing "}") and whether it was present.
type pkgFunc struct {
name string
body string
present bool
}
// extractFunctions pulls prepare/build/package bodies out of a PKGBUILD.
// Each body runs from the "name() {" opener to the first line starting with
// "}" at column 0, the standard makepkg layout heuristic.
func extractFunctions(src string) ([]pkgFunc, error) {
names := []string{"prepare", "build", "package"}
out := make([]pkgFunc, 0, len(names))
for _, name := range names {
body, present, err := extractFunc(src, name)
if err != nil {
return nil, err
}
out = append(out, pkgFunc{name: name, body: body, present: present})
}
return out, nil
}
func extractFunc(src, name string) (string, bool, error) {
startRe := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(name) + `\(\s*\)\s*\{`)
loc := startRe.FindStringIndex(src)
if loc == nil {
return "", false, nil
}
end := findClosingBrace(src, loc[1])
if end < 0 {
return "", false, fmt.Errorf("arch/src: %s() { has no closing '}' at column 0", name)
}
return src[loc[0]:end], true, nil
}
// findClosingBrace returns the offset just past the first line starting with
// "}" (column 0), scanning from from. Returns -1 if none is found.
func findClosingBrace(src string, from int) int {
lineStart := from
for lineStart <= len(src) {
lineEnd := strings.IndexByte(src[lineStart:], '\n')
if lineEnd < 0 {
if strings.HasPrefix(src[lineStart:], "}") {
return len(src)
}
return -1
}
line := src[lineStart : lineStart+lineEnd]
if strings.HasPrefix(line, "}") {
return lineStart + lineEnd + 1 // include the newline
}
lineStart += lineEnd + 1
}
return -1
}
// bashOnlyPatterns matches constructs that are valid in bash but not POSIX sh.
var bashOnlyPatterns = []*regexp.Regexp{
regexp.MustCompile(`\[\[`), // [[ test ]]
regexp.MustCompile(`\$\{[A-Za-z_][A-Za-z0-9_]*\[[^]\n]+\]\}`), // ${arr[@]} / ${arr[0]}
regexp.MustCompile(`(^|[^A-Za-z0-9_])local[ \t]+`), // local var
regexp.MustCompile(`(^|[^A-Za-z0-9_])declare[ \t]+`), // declare -a ...
regexp.MustCompile(`(^|[ \t;&])[A-Za-z_][A-Za-z0-9_]*=\(`), // arr=(...)
regexp.MustCompile(`<\(|>\(`), // process substitution
regexp.MustCompile(`\$\'`), // $'\n' ANSI-C quoting
}
// hasBashOnly reports whether any extracted function body uses a bash-only
// construct.
func hasBashOnly(funcs []pkgFunc) bool {
for _, f := range funcs {
if !f.present {
continue
}
for _, re := range bashOnlyPatterns {
if re.MatchString(f.body) {
return true
}
}
}
return false
}
+146
View File
@@ -0,0 +1,146 @@
package src
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
const (
mytoolSRCINFO = "../../../testdata/srcinfo/mytool/.SRCINFO"
mytoolPKGBUILD = "../../../testdata/srcinfo/mytool/PKGBUILD"
goldenPath = "../../../testdata/golden/mytool.build.sh"
)
// minimalSrcInfo is a test-local .SRCINFO reader. The real parser (todo 10,
// srcinfo.go) may not exist yet when this test compiles, so the test keeps a
// tiny reader for the fields Generate needs. It mirrors the documented SrcInfo
// layout so it converges with the real parser.
func minimalSrcInfo(t *testing.T, path string) *SrcInfo {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
s := &SrcInfo{}
for _, line := range strings.Split(string(data), "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
switch key {
case "pkgbase":
s.PkgBase = value
case "pkgname":
s.PkgNames = append(s.PkgNames, value)
case "pkgver":
s.Version = value
case "pkgrel":
s.Rel = value
case "arch":
s.Arch = append(s.Arch, value)
case "url":
s.URL = value
}
}
return s
}
func generateMytool(t *testing.T) string {
t.Helper()
out, err := Generate(minimalSrcInfo(t, mytoolSRCINFO), mytoolPKGBUILD)
if err != nil {
t.Fatalf("Generate: %v", err)
}
return out
}
func TestBuildScriptGolden(t *testing.T) {
out := generateMytool(t)
if os.Getenv("UPDATE_GOLDEN") == "1" {
if err := os.WriteFile(goldenPath, []byte(out), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
t.Logf("wrote golden %s", goldenPath)
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden (run: UPDATE_GOLDEN=1 go test ./internal/arch/src/ -run TestBuildScript): %v", err)
}
if string(want) != out {
t.Errorf("generated build.sh does not match golden %s", goldenPath)
for i, line := range strings.Split(string(want), "\n") {
gotLine := ""
if ls := strings.Split(out, "\n"); i < len(ls) {
gotLine = ls[i]
}
if line != gotLine {
t.Errorf("first diff at line %d:\n golden: %q\n got: %q", i+1, line, gotLine)
break
}
}
}
}
func TestBuildScriptPassesShSyntax(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not found in PATH")
}
out := generateMytool(t)
script := filepath.Join(t.TempDir(), "build.sh")
if err := os.WriteFile(script, []byte(out), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
cmd := exec.Command(sh, "-n", script)
if output, err := cmd.CombinedOutput(); err != nil {
t.Errorf("sh -n %s failed: %v\n%s", script, err, output)
}
}
func TestBuildScriptContainsDestdir(t *testing.T) {
out := generateMytool(t)
if !strings.Contains(out, `pkgdir="$DESTDIR"`) {
t.Errorf("generated script missing pkgdir=\"$DESTDIR\"")
}
if !strings.Contains(out, "$DESTDIR") {
t.Errorf("generated script missing literal $DESTDIR")
}
}
func TestBuildScriptNoFunctions(t *testing.T) {
dir := t.TempDir()
pkgbuild := filepath.Join(dir, "PKGBUILD")
if err := os.WriteFile(pkgbuild, []byte("pkgname=nofunc\npkgver=2.0\npkgrel=1\narch=(x86_64)\n"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
si := &SrcInfo{PkgNames: []string{"nofunc"}, Version: "2.0", Rel: "1", Arch: []string{"x86_64"}}
out, err := Generate(si, pkgbuild)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !strings.Contains(out, `pkgdir="$DESTDIR"`) {
t.Errorf("script without functions must still define pkgdir=\"$DESTDIR\"")
}
if !strings.Contains(out, "package") {
t.Errorf("script without functions must still reference package (stub)")
}
}
func TestBuildScriptEmbedsBuildBody(t *testing.T) {
out := generateMytool(t)
const wantBuild = "build() {\n cd \"$srcdir\"\n ./configure --prefix=/usr\n make\n}"
const wantPackage = "package() {\n cd \"$srcdir\"\n make DESTDIR=\"$pkgdir\" install\n}"
if !strings.Contains(out, wantBuild) {
t.Errorf("generated script does not embed the fixture's build() body verbatim")
}
if !strings.Contains(out, wantPackage) {
t.Errorf("generated script does not embed the fixture's package() body verbatim")
}
}
+218
View File
@@ -0,0 +1,218 @@
// Package src converts Arch Linux source packages (PKGBUILD/.SRCINFO) into
// Zeta recipes.
package src
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/depmap"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/zeta"
)
// Convert converts an Arch source package (a PKGBUILD, a .SRCINFO, or a
// directory containing either) into a Zeta .recipe with build_system =
// "custom" plus a generated build.sh, both written into opts.Output:
//
// <out>/<name>.recipe
// <out>/build.sh
//
// v1 fidelity guardrails error out on shapes Zeta cannot represent yet:
// split packages, VCS sources (git+/svn+/hg+/bzr+ protocols, <vcs>:: URL
// fragments, or a dynamic pkgver() function), zero sources and multiple
// sources. A nonzero epoch and the dropped metadata fields (makedepends,
// optdepends, provides, conflicts, replaces) are warned about on stderr.
func Convert(input string, opts registry.Options) error {
warn := func(msg string) { fmt.Fprintf(os.Stderr, "warning: %s\n", msg) }
info, err := Load(input, opts.Arch)
if err != nil {
return fmt.Errorf("convert: %w", err)
}
pkgbuildPath, err := resolvePKGBUILD(input)
if err != nil {
return fmt.Errorf("convert: %w", err)
}
// Fidelity guardrails: v1 covers exactly one package built from exactly
// one static tarball source.
if len(info.PkgNames) != 1 {
return errors.New("convert: split packages not yet supported in v1")
}
if hasVCS(info, pkgbuildPath) {
return errors.New("convert: VCS source not yet supported in v1")
}
if len(info.Sources) == 0 {
return errors.New("convert: no sources not yet supported in v1")
}
if len(info.Sources) > 1 {
return errors.New("convert: multiple sources not yet supported in v1")
}
// Warn-only: an epoch cannot be folded into Zeta versioning, and the
// recipe format has no fields for these relations.
if info.Epoch != "" && info.Epoch != "0" {
warn(fmt.Sprintf("epoch %s is not representable in Zeta versioning", info.Epoch))
}
for _, f := range []struct {
field string
vals []string
}{
{"makedepends", info.Makedepends},
{"optdepends", info.Optdepends},
{"provides", info.Provides},
{"conflicts", info.Conflicts},
{"replaces", info.Replaces},
} {
if len(f.vals) > 0 {
warn(fmt.Sprintf("%s dropped (not representable in Zeta)", f.field))
}
}
// 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)
}
}
summary := info.Desc
if summary == "" {
// recipe.lua requires a non-empty summary.
summary = info.PkgNames[0]
}
recipe := &zeta.Recipe{
Name: info.PkgNames[0],
Version: info.Version,
Summary: summary,
URL: sourceURL(info.Sources[0]),
SHA256: pickSHA256(info.SHA256Sums, warn),
Deps: deps,
BuildScript: "build.sh",
}
src, err := recipe.Emit()
if err != nil {
return fmt.Errorf("convert: emit recipe: %w", err)
}
if err := os.MkdirAll(opts.Output, 0o755); err != nil {
return fmt.Errorf("convert: mkdir %s: %w", opts.Output, err)
}
recipePath := filepath.Join(opts.Output, recipe.Name+".recipe")
if err := os.WriteFile(recipePath, []byte(src), 0o644); err != nil {
return fmt.Errorf("convert: write %s: %w", recipePath, err)
}
script, err := Generate(info, pkgbuildPath)
if err != nil {
return fmt.Errorf("convert: generate build.sh: %w", err)
}
scriptPath := filepath.Join(opts.Output, "build.sh")
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
return fmt.Errorf("convert: write %s: %w", scriptPath, err)
}
return nil
}
// resolvePKGBUILD returns the file whose text feeds Generate and the
// pkgver() scan: the PKGBUILD when one exists, else the .SRCINFO path (whose
// content yields no function bodies — stub functions result).
func resolvePKGBUILD(input string) (string, error) {
st, err := os.Stat(input)
if err != nil {
return "", err
}
if !st.IsDir() {
return input, nil
}
pkgbuild := filepath.Join(input, "PKGBUILD")
if fileExists(pkgbuild) {
return pkgbuild, nil
}
srcinfo := filepath.Join(input, ".SRCINFO")
if fileExists(srcinfo) {
return srcinfo, nil
}
return "", fmt.Errorf("src: %s: neither .SRCINFO nor PKGBUILD found", input)
}
// hasVCS reports whether the source package relies on a VCS source or a
// dynamic pkgver() function and therefore cannot be represented in v1.
func hasVCS(info *SrcInfo, pkgbuildPath string) bool {
for _, s := range info.Sources {
if isVCSSource(s) {
return true
}
}
return hasPkgverFunc(pkgbuildPath)
}
// isVCSSource reports whether a makepkg source entry names a VCS checkout:
// the git+/svn+/hg+/bzr+ protocol forms or the `<vcs>::<url>` fragment form
// (e.g. git::https://…). A plain https:// tarball download is a normal
// source, as is a `name::<url>` entry (a download-name prefix, not a VCS).
func isVCSSource(s string) bool {
for _, prefix := range []string{"git+", "svn+", "hg+", "bzr+"} {
if strings.HasPrefix(s, prefix) {
return true
}
}
if frag, _, ok := strings.Cut(s, "::"); ok {
switch frag {
case "git", "svn", "hg", "bzr", "fossil", "cvs", "darcs", "mtn":
return true
}
}
return false
}
// pkgverFuncRE matches a top-level pkgver() function definition in bash
// (column 0), the makepkg marker for a dynamically computed version.
var pkgverFuncRE = regexp.MustCompile(`(?m)^pkgver\(`)
func hasPkgverFunc(path string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
return pkgverFuncRE.Match(data)
}
// sourceURL derives the recipe url from a makepkg source entry: the text
// after a "name::" prefix when present, otherwise the whole entry verbatim.
// The URL scheme is never stripped.
func sourceURL(s string) string {
if _, rest, ok := strings.Cut(s, "::"); ok {
return rest
}
return s
}
// sha256Re matches exactly 64 hex characters, the recipe.lua sha256 shape.
var sha256Re = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
// pickSHA256 returns the source sha256 for the recipe, or "" when absent or
// unusable (an empty value, makepkg's "SKIP" marker, or a non-hex64 string —
// the latter is warned about because the checksum is dropped).
func pickSHA256(sums []string, warn func(string)) string {
if len(sums) == 0 {
return ""
}
s := strings.TrimSpace(sums[0])
if s == "" || strings.EqualFold(s, "SKIP") {
return ""
}
if !sha256Re.MatchString(s) {
warn(fmt.Sprintf("sha256sum %q is not 64 hex characters; checksum dropped", s))
return ""
}
return s
}
+258
View File
@@ -0,0 +1,258 @@
package src
import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
)
const (
srcinfoDir = "../../../testdata/srcinfo"
recipeLua = "/home/specter/Public/zeta-toolchain/toolchain/lib/?.lua"
)
// convertMytool runs the full conversion on the mytool fixture directory
// into a fresh temp dir and returns that dir plus the emitted recipe and
// build.sh contents.
func convertMytool(t *testing.T) (out, recipe, script string) {
t.Helper()
out = t.TempDir()
if err := Convert(filepath.Join(srcinfoDir, "mytool"), registry.Options{Output: out}); err != nil {
t.Fatalf("Convert(mytool): %v", err)
}
rb, err := os.ReadFile(filepath.Join(out, "mytool.recipe"))
if err != nil {
t.Fatalf("mytool.recipe not written: %v", err)
}
sb, err := os.ReadFile(filepath.Join(out, "build.sh"))
if err != nil {
t.Fatalf("build.sh not written: %v", err)
}
return out, string(rb), string(sb)
}
// convertStderr runs Convert with os.Stderr redirected into a pipe and
// returns the captured output along with the error. Tests using this helper
// must not run in parallel.
func convertStderr(t *testing.T, input string, opts registry.Options) (string, error) {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer r.Close()
old := os.Stderr
os.Stderr = w
err = Convert(input, opts)
if cerr := w.Close(); cerr != nil && err == nil {
err = cerr
}
os.Stderr = old
out, rerr := io.ReadAll(r)
if rerr != nil {
t.Fatalf("read stderr: %v", rerr)
}
return string(out), err
}
// quoteLua renders a path as a safe single-quoted Lua string literal.
func quoteLua(s string) string {
return `'` + strings.ReplaceAll(s, `\`, `\\`) + `'`
}
// writePKGBUILD writes a PKGBUILD fixture into a fresh temp dir and returns
// the dir (passed as input to Convert).
func writePKGBUILD(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "PKGBUILD"), []byte(body), 0o644); err != nil {
t.Fatalf("write PKGBUILD fixture: %v", err)
}
return dir
}
func TestConvert(t *testing.T) {
out, recipe, script := convertMytool(t)
for _, want := range []string{
`build_system = "custom"`,
`build_script = "build.sh"`,
`name = "mytool"`,
} {
if !strings.Contains(recipe, want) {
t.Errorf("mytool.recipe does not contain %q:\n%s", want, recipe)
}
}
if !strings.Contains(script, `pkgdir="$DESTDIR"`) {
t.Errorf("build.sh does not contain pkgdir=\"$DESTDIR\"")
}
if _, err := os.Stat(filepath.Join(out, "mytool.recipe")); err != nil {
t.Errorf("mytool.recipe missing: %v", err)
}
if _, err := os.Stat(filepath.Join(out, "build.sh")); err != nil {
t.Errorf("build.sh missing: %v", err)
}
}
func TestConvertRecipeLoadsInZeta(t *testing.T) {
lua, err := exec.LookPath("lua")
if err != nil {
t.Skip("lua not available; skipping cross-tool validation")
}
out := t.TempDir()
if err := Convert(filepath.Join(srcinfoDir, "mytool"), registry.Options{Output: out}); err != nil {
t.Fatalf("Convert(mytool): %v", err)
}
recipePath := filepath.Join(out, "mytool.recipe")
probe := `package.path="` + recipeLua + `;"..package.path; ` +
`local r,e=require("recipe").load(` + quoteLua(recipePath) + `); assert(r,e)`
cmd := exec.Command(lua, "-e", probe)
if b, cerr := cmd.CombinedOutput(); cerr != nil {
t.Fatalf("real zeta-makepkg recipe.load rejected the emitted recipe: %v\n%s", cerr, b)
}
}
func TestConvertSplitPackage(t *testing.T) {
err := Convert(filepath.Join(srcinfoDir, "split"), registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(split) error = nil, want guardrail error")
}
if !strings.Contains(err.Error(), "not yet supported") {
t.Errorf("Convert(split) error = %q, want it to contain %q", err, "not yet supported")
}
if !strings.Contains(err.Error(), "split packages not yet supported in v1") {
t.Errorf("Convert(split) error = %q, want the split guardrail message", err)
}
}
func TestConvertVCS(t *testing.T) {
err := Convert(filepath.Join(srcinfoDir, "vcs"), registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(vcs) error = nil, want guardrail error")
}
if !strings.Contains(err.Error(), "not yet supported") {
t.Errorf("Convert(vcs) error = %q, want it to contain %q", err, "not yet supported")
}
if !strings.Contains(err.Error(), "VCS source not yet supported in v1") {
t.Errorf("Convert(vcs) error = %q, want the VCS guardrail message", err)
}
}
func TestConvertEpochWarns(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=epo
pkgver=1.0
pkgrel=1
epoch=1
pkgdesc="Epoch fixture"
arch=(x86_64)
source=("epo-1.0.tar.gz")
`)
stderr, err := convertStderr(t, dir, registry.Options{Output: t.TempDir()})
if err != nil {
t.Fatalf("Convert(epoch) error = %v, want nil (epoch warns only)", err)
}
if !strings.Contains(stderr, "epoch") {
t.Errorf("stderr = %q, want a warning mentioning epoch", stderr)
}
}
func TestConvertNamedSourceURL(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=namedsrc
pkgver=1.0
pkgrel=1
pkgdesc="Named source fixture"
arch=(x86_64)
source=("name::https://example.com/x.tar.gz")
sha256sums=('SKIP')
`)
out := t.TempDir()
if err := Convert(dir, registry.Options{Output: out}); err != nil {
t.Fatalf("Convert(named source) error = %v, want nil", err)
}
content, err := os.ReadFile(filepath.Join(out, "namedsrc.recipe"))
if err != nil {
t.Fatalf("namedsrc.recipe not written: %v", err)
}
const wantLine = `url = "https://example.com/x.tar.gz",`
if !strings.Contains(string(content), wantLine) {
t.Errorf("recipe url = %q, want the line %q (scheme preserved, name:: stripped):\n%s",
string(content), wantLine, content)
}
if strings.Contains(string(content), "name::") {
t.Errorf("recipe still contains the name:: prefix:\n%s", content)
}
}
func TestConvertMissingInput(t *testing.T) {
err := Convert(filepath.Join(t.TempDir(), "nope"), registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(missing) error = nil, want error for nonexistent input")
}
}
func TestConvertZeroSources(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=nosrc
pkgver=1.0
pkgrel=1
pkgdesc="No sources fixture"
arch=(x86_64)
`)
err := Convert(dir, registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(no sources) error = nil, want guardrail error")
}
if !strings.Contains(err.Error(), "no sources not yet supported in v1") {
t.Errorf("Convert(no sources) error = %q, want the zero-source guardrail message", err)
}
}
func TestConvertMultiSource(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=multisrc
pkgver=1.0
pkgrel=1
pkgdesc="Multi source fixture"
arch=(x86_64)
source=("a-1.0.tar.gz" "b-1.0.tar.gz")
sha256sums=('SKIP' 'SKIP')
`)
err := Convert(dir, registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(multi source) error = nil, want guardrail error")
}
if !strings.Contains(err.Error(), "multiple sources not yet supported in v1") {
t.Errorf("Convert(multi source) error = %q, want the multi-source guardrail message", err)
}
}
func TestConvertDroppedMetadataWarns(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=meta
pkgver=1.0
pkgrel=1
pkgdesc="Dropped metadata fixture"
arch=(x86_64)
source=("meta-1.0.tar.gz")
makedepends=('make')
optdepends=('bonus: optional extra')
provides=('virt')
conflicts=('other')
replaces=('old')
`)
stderr, err := convertStderr(t, dir, registry.Options{Output: t.TempDir()})
if err != nil {
t.Fatalf("Convert(dropped metadata) error = %v, want nil (warns only)", err)
}
for _, field := range []string{"makedepends", "optdepends", "provides", "conflicts", "replaces"} {
want := "warning: " + field + " dropped (not representable in Zeta)"
if !strings.Contains(stderr, want) {
t.Errorf("stderr = %q, want warning %q", stderr, want)
}
}
}
+2 -4
View File
@@ -3,8 +3,6 @@
package src
import (
"errors"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
)
@@ -13,9 +11,9 @@ type frontend struct{}
func (frontend) Name() string { return "arch-src" }
// Convert delegates to the package-level converter (convert.go).
func (frontend) Convert(input string, opts registry.Options) error {
// Implemented in a later todo (PKGBUILD/.SRCINFO -> .recipe + build.sh).
return errors.New("not implemented")
return Convert(input, opts)
}
func init() {
+299
View File
@@ -0,0 +1,299 @@
package src
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// ParseSRCINFO parses makepkg .SRCINFO content: lines of "key = value"
// grouped into a pkgbase section and one or more pkgname sections. Keys in
// the pkgbase section are inherited by every package; the first pkgname
// section extends/overrides them (v1 targets single-package recipes, so
// later pkgname sections only contribute their name to PkgNames — the
// converter rejects split packages before they get here).
//
// Repeatable keys (arch, depends, makedepends, optdepends, provides,
// conflicts, replaces, source, sha256sums) accumulate; single-value keys
// (pkgver, pkgrel, epoch, pkgdesc, url) take the last occurrence. Keys
// suffixed with _<arch> (e.g. depends_x86_64) are folded into their base
// key only when the suffix matches the requested arch; suffixes for other
// arches are ignored. An empty arch defaults to x86_64.
//
// ParseSRCINFO returns an error when no pkgver is present.
func ParseSRCINFO(src string, arch string) (*SrcInfo, error) {
if arch == "" {
arch = "x86_64"
}
info := &SrcInfo{}
pkgnameSections := 0
firstSection := true
for _, line := range strings.Split(src, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.Index(line, "=")
if eq < 0 {
continue // malformed line: skip
}
key := strings.TrimSpace(line[:eq])
value := strings.TrimSpace(line[eq+1:])
switch key {
case "pkgbase":
info.PkgBase = value
case "pkgname":
info.PkgNames = append(info.PkgNames, value)
pkgnameSections++
firstSection = false
default:
// Keys in pkgname sections beyond the first are ignored: v1
// targets single-package recipes.
if !firstSection && pkgnameSections > 1 {
continue
}
if isSingleKey(key) {
applyKey(info, key, value)
continue
}
base, ok := foldArchKey(key, arch)
if ok {
applyKey(info, base, value)
}
// else: unknown key or a different arch's variant — ignore.
}
}
if info.Version == "" {
return nil, errors.New("srcinfo: missing pkgver")
}
return info, nil
}
// isSingleKey reports whether key is a single-value (last-wins) .SRCINFO
// key that never carries an _<arch> suffix.
func isSingleKey(key string) bool {
switch key {
case "pkgver", "pkgrel", "epoch", "pkgdesc", "url", "arch":
return true
}
return false
}
// applyKey records one .SRCINFO key=value into info. Single-value keys take
// the last occurrence; repeatable keys accumulate.
func applyKey(info *SrcInfo, key, value string) {
switch key {
case "pkgver":
info.Version = value
case "pkgrel":
info.Rel = value
case "epoch":
info.Epoch = value
case "pkgdesc":
info.Desc = value
case "url":
info.URL = value
case "arch":
info.Arch = append(info.Arch, value)
case "depends":
info.Depends = append(info.Depends, value)
case "makedepends":
info.Makedepends = append(info.Makedepends, value)
case "optdepends":
info.Optdepends = append(info.Optdepends, value)
case "provides":
info.Provides = append(info.Provides, value)
case "conflicts":
info.Conflicts = append(info.Conflicts, value)
case "replaces":
info.Replaces = append(info.Replaces, value)
case "source":
info.Sources = append(info.Sources, value)
case "sha256sums":
info.SHA256Sums = append(info.SHA256Sums, value)
}
}
// archVariantKeys are the .SRCINFO keys that may carry an _<arch> suffix.
var archVariantKeys = map[string]bool{
"source": true, "depends": true, "makedepends": true, "optdepends": true,
"provides": true, "conflicts": true, "replaces": true,
"sha256sums": true, "md5sums": true, "sha1sums": true, "sha224sums": true,
"sha384sums": true, "sha512sums": true, "b2sums": true,
}
// foldArchKey maps a .SRCINFO key to the base key it contributes to, and
// reports whether it applies to the requested arch. A bare key applies to
// every arch; a `key_<arch>` key applies only when <arch> matches; a
// `key_<other>` key folds to nothing.
func foldArchKey(key, arch string) (string, bool) {
if base, ok := strings.CutSuffix(key, "_"+arch); ok && archVariantKeys[base] {
return base, true
}
if archVariantKeys[key] {
return key, true
}
if base, _, ok := strings.Cut(key, "_"); ok && archVariantKeys[base] {
return "", false
}
return "", false
}
// precompiled assignment regexes. PKGBUILDs are bash; these regexes match
// only top-level `key=value` declarations at column 0, so assignments
// inside function bodies (indented) and `key() {` function definitions are
// never evaluated.
var (
reScalar = map[string]*regexp.Regexp{}
reArray = map[string]*regexp.Regexp{}
)
func init() {
for _, key := range []string{"pkgbase", "pkgname", "pkgver", "pkgrel", "epoch", "pkgdesc", "url"} {
reScalar[key] = regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `\s*=\s*(?:'([^']*)'|"([^"]*)"|([^\s'"]+))`)
}
for _, key := range []string{"pkgname", "arch", "depends", "makedepends", "optdepends", "provides", "conflicts", "replaces", "source", "sha256sums"} {
reArray[key] = regexp.MustCompile(`(?ms)^` + regexp.QuoteMeta(key) + `\s*=\s*\(([^)]*)\)`)
}
}
// ParsePKGBUILD best-effort extracts declaration values from a PKGBUILD by
// regex-matching top-level `key=value` assignments. It NEVER executes bash:
// function bodies, comments, conditionals and variables are left untouched,
// and values referencing variables such as $pkgname are kept verbatim.
// Array assignments (`key=(v1 v2)`, possibly spanning lines) are split on
// whitespace and unquoted; scalar values may be bare or 'single'/"double"
// quoted. Unknown constructs are ignored.
//
// ParsePKGBUILD returns an error when no usable pkgver declaration is
// found (pkgver is mandatory in a PKGBUILD).
func ParsePKGBUILD(src string) (*SrcInfo, error) {
info := &SrcInfo{}
if v, ok := scalar(src, "pkgbase"); ok {
info.PkgBase = v
}
if names, ok := array(src, "pkgname"); ok {
info.PkgNames = names
} else if v, ok := scalar(src, "pkgname"); ok {
info.PkgNames = []string{v}
}
info.Version, _ = scalar(src, "pkgver")
info.Rel, _ = scalar(src, "pkgrel")
info.Epoch, _ = scalar(src, "epoch")
info.Desc, _ = scalar(src, "pkgdesc")
info.URL, _ = scalar(src, "url")
info.Arch, _ = array(src, "arch")
info.Depends, _ = array(src, "depends")
info.Makedepends, _ = array(src, "makedepends")
info.Optdepends, _ = array(src, "optdepends")
info.Provides, _ = array(src, "provides")
info.Conflicts, _ = array(src, "conflicts")
info.Replaces, _ = array(src, "replaces")
info.Sources, _ = array(src, "source")
info.SHA256Sums, _ = array(src, "sha256sums")
if info.Version == "" {
return nil, errors.New("pkgbuild: missing pkgver")
}
return info, nil
}
// scalar returns the value of a top-level `key=value` scalar assignment.
// The value may be bare, 'single'- or "double"-quoted. An array assignment
// is reported as not-found so callers fall back to array().
func scalar(src, key string) (string, bool) {
m := reScalar[key].FindStringSubmatch(src)
if m == nil {
return "", false
}
value := m[1]
if value == "" {
value = m[2]
}
if value == "" {
value = m[3]
}
value = strings.TrimSpace(value)
if strings.HasPrefix(value, "(") {
return "", false // array form; caller should use array()
}
return value, true
}
// array returns the values of a top-level `key=(v1 v2 ...)` assignment,
// possibly spanning multiple lines. Tokens are split on whitespace and
// surrounding single/double quotes are stripped. Best-effort: quoted values
// containing spaces are not preserved.
func array(src, key string) ([]string, bool) {
m := reArray[key].FindStringSubmatch(src)
if m == nil {
return nil, false
}
tokens := strings.Fields(m[1])
values := make([]string, 0, len(tokens))
for _, tok := range tokens {
values = append(values, stripQuotes(tok))
}
return values, true
}
// stripQuotes removes a matching pair of surrounding single or double
// quotes.
func stripQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
return s[1 : len(s)-1]
}
}
return s
}
// Load parses the Arch source package at input. If input is a directory it
// prefers <input>/.SRCINFO and falls back to <input>/PKGBUILD; if input is
// a file, its basename selects the parser (.SRCINFO or PKGBUILD). Load
// returns a descriptive error when neither file is found or the file type
// is unknown.
func Load(input string, arch string) (*SrcInfo, error) {
st, err := os.Stat(input)
if err != nil {
return nil, fmt.Errorf("src: %s: %w", input, err)
}
path := input
useSRCINFO := false
if st.IsDir() {
srcinfo := filepath.Join(input, ".SRCINFO")
pkgbuild := filepath.Join(input, "PKGBUILD")
switch {
case fileExists(srcinfo):
path, useSRCINFO = srcinfo, true
case fileExists(pkgbuild):
path = pkgbuild
default:
return nil, fmt.Errorf("src: %s: neither .SRCINFO nor PKGBUILD found", input)
}
} else {
switch filepath.Base(input) {
case ".SRCINFO":
useSRCINFO = true
case "PKGBUILD":
default:
return nil, fmt.Errorf("src: %s: unknown source file (want .SRCINFO or PKGBUILD)", input)
}
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("src: reading %s: %w", path, err)
}
if useSRCINFO {
return ParseSRCINFO(string(data), arch)
}
return ParsePKGBUILD(string(data))
}
// fileExists reports whether path names a regular file.
func fileExists(path string) bool {
st, err := os.Stat(path)
return err == nil && !st.IsDir()
}
+293
View File
@@ -0,0 +1,293 @@
package src
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// srcinfoFixtures is the committed testdata tree, relative to this package
// directory (internal/arch/src).
const srcinfoFixtures = "../../../testdata/srcinfo"
func readFixture(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %s: %v", path, err)
}
return string(b)
}
// TestSrcInfoParseMytoolSRCINFO parses the committed mytool .SRCINFO with
// arch x86_64 and checks every field the converter consumes.
func TestSrcInfoParseMytoolSRCINFO(t *testing.T) {
src := readFixture(t, filepath.Join(srcinfoFixtures, "mytool", ".SRCINFO"))
got, err := ParseSRCINFO(src, "x86_64")
if err != nil {
t.Fatalf("ParseSRCINFO: %v", err)
}
if got.PkgBase != "mytool" {
t.Errorf("PkgBase = %q, want %q", got.PkgBase, "mytool")
}
if !slices.Equal(got.PkgNames, []string{"mytool"}) {
t.Errorf("PkgNames = %v, want [mytool]", got.PkgNames)
}
if got.Version != "1.0" || got.Rel != "1" {
t.Errorf("Version/Rel = %q/%q, want %q/%q", got.Version, got.Rel, "1.0", "1")
}
if got.Desc != "Example custom-build package" {
t.Errorf("Desc = %q, want %q", got.Desc, "Example custom-build package")
}
if got.URL != "https://example.org" {
t.Errorf("URL = %q, want %q", got.URL, "https://example.org")
}
if !slices.Equal(got.Arch, []string{"x86_64"}) {
t.Errorf("Arch = %v, want [x86_64]", got.Arch)
}
if !slices.Equal(got.Depends, []string{"glibc"}) {
t.Errorf("Depends = %v, want [glibc]", got.Depends)
}
if !slices.Equal(got.Sources, []string{"mytool-1.0.tar.gz"}) {
t.Errorf("Sources = %v, want [mytool-1.0.tar.gz]", got.Sources)
}
if !slices.Equal(got.SHA256Sums, []string{"SKIP"}) {
t.Errorf("SHA256Sums = %v, want [SKIP]", got.SHA256Sums)
}
}
// TestSrcInfoParseMytoolPKGBUILD exercises the best-effort bash fallback on
// the same package and expects the essential fields to agree.
func TestSrcInfoParseMytoolPKGBUILD(t *testing.T) {
src := readFixture(t, filepath.Join(srcinfoFixtures, "mytool", "PKGBUILD"))
got, err := ParsePKGBUILD(src)
if err != nil {
t.Fatalf("ParsePKGBUILD: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"mytool"}) {
t.Errorf("PkgNames = %v, want [mytool]", got.PkgNames)
}
if got.Version != "1.0" || got.Rel != "1" {
t.Errorf("Version/Rel = %q/%q, want %q/%q", got.Version, got.Rel, "1.0", "1")
}
if got.Desc != "Example custom-build package" {
t.Errorf("Desc = %q, want %q", got.Desc, "Example custom-build package")
}
if got.URL != "https://example.org" {
t.Errorf("URL = %q, want %q", got.URL, "https://example.org")
}
if !slices.Equal(got.Arch, []string{"x86_64"}) {
t.Errorf("Arch = %v, want [x86_64]", got.Arch)
}
if !slices.Equal(got.Depends, []string{"glibc"}) {
t.Errorf("Depends = %v, want [glibc]", got.Depends)
}
if !slices.Equal(got.Sources, []string{"mytool-1.0.tar.gz"}) {
t.Errorf("Sources = %v, want [mytool-1.0.tar.gz]", got.Sources)
}
if !slices.Equal(got.SHA256Sums, []string{"SKIP"}) {
t.Errorf("SHA256Sums = %v, want [SKIP]", got.SHA256Sums)
}
}
// TestSrcInfoParseSplitPKGBUILD: a split package must PARSE fine (rejection
// happens later in the converter), yielding both pkgnames.
func TestSrcInfoParseSplitPKGBUILD(t *testing.T) {
src := readFixture(t, filepath.Join(srcinfoFixtures, "split", "PKGBUILD"))
got, err := ParsePKGBUILD(src)
if err != nil {
t.Fatalf("ParsePKGBUILD: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"foo", "bar"}) {
t.Errorf("PkgNames = %v, want [foo bar]", got.PkgNames)
}
if got.PkgBase != "foobar" {
t.Errorf("PkgBase = %q, want %q", got.PkgBase, "foobar")
}
if got.Version != "1.0" {
t.Errorf("Version = %q, want %q", got.Version, "1.0")
}
}
// TestSrcInfoParseVCSPKGBUILD: VCS sources must come through verbatim so the
// converter can detect and reject them with a clear message.
func TestSrcInfoParseVCSPKGBUILD(t *testing.T) {
src := readFixture(t, filepath.Join(srcinfoFixtures, "vcs", "PKGBUILD"))
got, err := ParsePKGBUILD(src)
if err != nil {
t.Fatalf("ParsePKGBUILD: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"mygit"}) {
t.Errorf("PkgNames = %v, want [mygit]", got.PkgNames)
}
if !slices.Contains(got.Sources, "git+https://example.org/repo.git") {
t.Errorf("Sources = %v, want it to contain %q", got.Sources, "git+https://example.org/repo.git")
}
}
// TestSrcInfoArchFilter feeds a .SRCINFO with arch-specific keys and checks
// that only the requested arch's variants are folded in.
func TestSrcInfoArchFilter(t *testing.T) {
src := `pkgbase = demo
pkgver = 2.0
pkgrel = 3
epoch = 1
depends = bash
depends_x86_64 = zsh
depends_aarch64 = sh
source_x86_64 = demo-2.0.tar.gz
source_aarch64 = demo-2.0-aarch64.tar.gz
sha256sums_x86_64 = SKIP
sha256sums_aarch64 = DEADBEEF
pkgname = demo
`
got, err := ParseSRCINFO(src, "x86_64")
if err != nil {
t.Fatalf("ParseSRCINFO(x86_64): %v", err)
}
if !slices.Equal(got.Depends, []string{"bash", "zsh"}) {
t.Errorf("Depends(x86_64) = %v, want [bash zsh] (aarch64 variant must NOT leak in)", got.Depends)
}
if !slices.Equal(got.Sources, []string{"demo-2.0.tar.gz"}) {
t.Errorf("Sources(x86_64) = %v, want [demo-2.0.tar.gz]", got.Sources)
}
if !slices.Equal(got.SHA256Sums, []string{"SKIP"}) {
t.Errorf("SHA256Sums(x86_64) = %v, want [SKIP]", got.SHA256Sums)
}
if got.Version != "2.0" || got.Rel != "3" || got.Epoch != "1" {
t.Errorf("Version/Rel/Epoch = %q/%q/%q, want %q/%q/%q", got.Version, got.Rel, got.Epoch, "2.0", "3", "1")
}
got, err = ParseSRCINFO(src, "aarch64")
if err != nil {
t.Fatalf("ParseSRCINFO(aarch64): %v", err)
}
if !slices.Equal(got.Depends, []string{"bash", "sh"}) {
t.Errorf("Depends(aarch64) = %v, want [bash sh]", got.Depends)
}
if !slices.Equal(got.Sources, []string{"demo-2.0-aarch64.tar.gz"}) {
t.Errorf("Sources(aarch64) = %v, want [demo-2.0-aarch64.tar.gz]", got.Sources)
}
if !slices.Equal(got.SHA256Sums, []string{"DEADBEEF"}) {
t.Errorf("SHA256Sums(aarch64) = %v, want [DEADBEEF]", got.SHA256Sums)
}
}
// TestSrcInfoMultiPkgnameSRCINFO: multiple pkgname sections must not fail
// parsing; every name is collected, only the FIRST section's keys apply.
func TestSrcInfoMultiPkgnameSRCINFO(t *testing.T) {
src := `pkgbase = split
pkgver = 1.0
pkgrel = 1
pkgname = foo
depends = a
pkgname = bar
depends = b
`
got, err := ParseSRCINFO(src, "x86_64")
if err != nil {
t.Fatalf("ParseSRCINFO: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"foo", "bar"}) {
t.Errorf("PkgNames = %v, want [foo bar]", got.PkgNames)
}
if !slices.Equal(got.Depends, []string{"a"}) {
t.Errorf("Depends = %v, want [a] (only the first pkgname section applies)", got.Depends)
}
}
// TestSrcInfoPKGBUILDMissingPkgver: a PKGBUILD without a usable pkgver
// declaration is malformed and must error.
func TestSrcInfoPKGBUILDMissingPkgver(t *testing.T) {
src := `pkgname=foo
pkgrel=1
pkgdesc="no version here"
arch=(x86_64)
depends=('glibc')
`
got, err := ParsePKGBUILD(src)
if err == nil {
t.Fatalf("ParsePKGBUILD = %+v, want error for missing pkgver", got)
}
if !strings.Contains(err.Error(), "pkgver") {
t.Errorf("error %q does not mention pkgver", err)
}
}
// TestSrcInfoLoadDirPrefersSRCINFO: loading a directory uses .SRCINFO first
// (PkgBase is only present in the .SRCINFO fixture, not the PKGBUILD).
func TestSrcInfoLoadDirPrefersSRCINFO(t *testing.T) {
got, err := Load(filepath.Join(srcinfoFixtures, "mytool"), "x86_64")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.PkgBase != "mytool" {
t.Errorf("PkgBase = %q, want %q (proves .SRCINFO was preferred over PKGBUILD)", got.PkgBase, "mytool")
}
if got.Version != "1.0" {
t.Errorf("Version = %q, want %q", got.Version, "1.0")
}
}
// TestSrcInfoLoadDirFallsBackToPKGBUILD: a directory with only a PKGBUILD
// falls back to the best-effort parser.
func TestSrcInfoLoadDirFallsBackToPKGBUILD(t *testing.T) {
got, err := Load(filepath.Join(srcinfoFixtures, "split"), "x86_64")
if err != nil {
t.Fatalf("Load: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"foo", "bar"}) {
t.Errorf("PkgNames = %v, want [foo bar]", got.PkgNames)
}
}
// TestSrcInfoLoadFilePKGBUILD: loading a file named PKGBUILD parses it as
// such regardless of directory.
func TestSrcInfoLoadFilePKGBUILD(t *testing.T) {
got, err := Load(filepath.Join(srcinfoFixtures, "vcs", "PKGBUILD"), "x86_64")
if err != nil {
t.Fatalf("Load: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"mygit"}) {
t.Errorf("PkgNames = %v, want [mygit]", got.PkgNames)
}
}
// TestSrcInfoLoadFileSRCINFO: loading a file named .SRCINFO parses it as
// such.
func TestSrcInfoLoadFileSRCINFO(t *testing.T) {
got, err := Load(filepath.Join(srcinfoFixtures, "mytool", ".SRCINFO"), "x86_64")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.PkgBase != "mytool" {
t.Errorf("PkgBase = %q, want %q", got.PkgBase, "mytool")
}
}
// TestSrcInfoLoadUnknownFile: a file that is neither .SRCINFO nor PKGBUILD
// is rejected with a clear error.
func TestSrcInfoLoadUnknownFile(t *testing.T) {
dir := t.TempDir()
other := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(other, []byte("pkgname=foo\npkgver=1\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, err := Load(other, "x86_64")
if err == nil {
t.Fatalf("Load(%s) = nil error, want unknown-file error", other)
}
if !strings.Contains(err.Error(), "unknown") {
t.Errorf("error %q does not mention the unknown file type", err)
}
}
// TestSrcInfoLoadMissingDir: a nonexistent path errors clearly.
func TestSrcInfoLoadMissingDir(t *testing.T) {
_, err := Load(filepath.Join(t.TempDir(), "does-not-exist"), "x86_64")
if err == nil {
t.Fatalf("Load(nonexistent) = nil error, want error")
}
}
+537
View File
@@ -0,0 +1,537 @@
// Package depmap maps Arch Linux dependency relations onto Zeta package
// names.
//
// The name tables (zetapkgs, renames, dropped) are checked-in literals so the
// mapping is hermetic: zetapkgs mirrors the authoritative
// zeta-packages/packages/index.lua "name = ..." entries.
package depmap
import (
"fmt"
"strings"
)
// MapDep maps a single Arch dependency relation to a Zeta dependency string.
//
// Input forms: "name", "name>=1.2", or the optdepend form "name: description".
// It returns ("", false) when the dependency is dropped (base-system packages
// Zeta provides outside the package manager). Every drop, rename, and
// unmapped passthrough fires warn (nil-safe).
func MapDep(spec string, warn func(string)) (string, bool) {
if warn == nil {
warn = func(string) {}
}
// 1. Optdepend description form "name: description" — keep the
// name+constraint part only.
if i := strings.Index(spec, ": "); i >= 0 {
spec = spec[:i]
}
// 2. Split the base name from the version constraint. Arch dependency
// names never contain the operator characters, so the first one found
// starts the constraint.
name, constraint := splitConstraint(spec)
// Zeta's vercmp.parse_dep accepts "=" but normalizes it to "=="
// internally; do the same so emitted specs match Zeta convention.
constraint = normalizeConstraint(constraint)
// 3. Verbatim match against the Zeta package index → passthrough.
if _, ok := zetapkgs[name]; ok {
return name + constraint, true
}
// 4. Known Arch→Zeta rename (case fixes included) → rename, keep the
// constraint.
if to, ok := renames[name]; ok {
warn(fmt.Sprintf("renamed dependency %q -> %q", name, to))
return to + constraint, true
}
// 5. Base-system packages Zeta has no package for → drop.
if _, ok := dropped[name]; ok {
warn(fmt.Sprintf("dropping base-system dependency %q", name))
return "", false
}
// 6. Not in Zeta at all → passthrough loudly.
warn(fmt.Sprintf("unmapped dependency %q (passed through)", name+constraint))
return name + constraint, true
}
// splitConstraint returns the base package name and the raw constraint
// ("", ">=1.2", ...). The split happens at the first operator character
// ('<', '>', '=', '~'); Arch package names never contain these.
func splitConstraint(spec string) (name, constraint string) {
for i := 0; i < len(spec); i++ {
switch spec[i] {
case '<', '>', '=', '~':
return strings.TrimSpace(spec[:i]), spec[i:]
}
}
return strings.TrimSpace(spec), ""
}
// normalizeConstraint rewrites a bare "=" operator to "==" (Zeta's canonical
// form) and leaves every other operator untouched.
func normalizeConstraint(constraint string) string {
trimmed := strings.TrimLeft(constraint, " \t")
if strings.HasPrefix(trimmed, "=") && !strings.HasPrefix(trimmed, "==") {
return constraint[:len(constraint)-len(trimmed)] + "==" + trimmed[1:]
}
return constraint
}
// zetapkgs is the set of every Zeta package name, checked in verbatim from
// zeta-packages/packages/index.lua ("name = ..." entries).
var zetapkgs = map[string]struct{}{
"a52dec": {},
"acl": {},
"adwaita-icon-theme": {},
"adwaita-icon-theme-legacy": {},
"alacritty": {},
"alsa-lib": {},
"aquamarine": {},
"atkmm": {},
"at-spi2-core": {},
"avahi": {},
"bitstreamvera": {},
"brotli": {},
"btop": {},
"bubblewrap": {},
"bzip2": {},
"cairo": {},
"cairomm": {},
"caja": {},
"cava": {},
"cjson": {},
"cmatrix": {},
"composefs": {},
"cpio": {},
"ctwm": {},
"dav1d": {},
"dbus": {},
"dbus-glib": {},
"dejavusans": {},
"discord": {},
"doas": {},
"double-conversion": {},
"edelib": {},
"egl": {},
"eom": {},
"exo": {},
"expat": {},
"extra-cmake-modules": {},
"faad2": {},
"fastfetch": {},
"fd": {},
"femboysay": {},
"fftw3": {},
"file": {},
"firefox": {},
"fish": {},
"flac": {},
"flatpak": {},
"fltk": {},
"fmt": {},
"fontconfig": {},
"freetype": {},
"fribidi": {},
"fvwm3": {},
"garcon": {},
"gdk-pixbuf": {},
"git": {},
"glib": {},
"glibmm": {},
"gmp": {},
"gnupg": {},
"gnutls": {},
"gobject-introspection": {},
"gpgme": {},
"graphene": {},
"gsettings-desktop-schemas": {},
"gtk3": {},
"gtk4": {},
"gtk-layer-shell": {},
"gtkmm3": {},
"gtkmm4": {},
"gtksourceview4": {},
"gzip": {},
"harfbuzz": {},
"hello": {},
"hicolor-icon-theme": {},
"htop": {},
"hwdata": {},
"hyprgraphics": {},
"hyprland": {},
"hyprlang": {},
"hyprpaper": {},
"hyprtoolkit": {},
"hyprutils": {},
"hyprwayland-scanner": {},
"hyprwire": {},
"icu": {},
"imlib2": {},
"iniparser": {},
"iso-codes": {},
"jam": {},
"json-c": {},
"jsoncpp": {},
"kidletime": {},
"kwindowsystem": {},
"labwc": {},
"larp": {},
"layer-shell-qt": {},
"lcms2": {},
"leancrypto": {},
"less": {},
"libappstream": {},
"libarchive": {},
"libassuan": {},
"libb2": {},
"libcanberra": {},
"libcurl": {},
"libdaemon": {},
"libdbusmenu-lxqt": {},
"libdconf": {},
"libdisplay-info": {},
"libdrm": {},
"libebml": {},
"libepoxy": {},
"libevdev": {},
"libevent": {},
"libexif": {},
"libffi": {},
"libfm-extra": {},
"libfm-qt": {},
"libfontenc": {},
"libfuse3": {},
"libfyaml": {},
"libgcrypt": {},
"libglvnd": {},
"libgpg-error": {},
"libgtop": {},
"libgudev": {},
"libICE": {},
"libidn2": {},
"libinput": {},
"libjpeg-turbo": {},
"libjson-glib": {},
"libksba": {},
"liblxqt": {},
"libmatekbd": {},
"libmateweather": {},
"libmatroska": {},
"libmtdev": {},
"libnl": {},
"libnotify": {},
"libogg": {},
"libostree": {},
"libpciaccess": {},
"libpeas": {},
"libpng": {},
"libpsl": {},
"libqtxdg": {},
"libreoffice": {},
"libreoffice-core": {},
"libreoffice-help": {},
"libreoffice-share": {},
"librsvg": {},
"libseccomp": {},
"libshout": {},
"libsigc++": {},
"libsigc++2": {},
"libSM": {},
"libsndfile": {},
"libsoup2": {},
"libsoup3": {},
"libtasn1": {},
"libtdb": {},
"libtheora": {},
"libtinfo": {},
"libtirpc": {},
"libudev": {},
"libunistring": {},
"libuuid": {},
"libva": {},
"libvorbis": {},
"libvpx": {},
"libwacom": {},
"libwebp": {},
"libwnck3": {},
"libX11": {},
"libXau": {},
"libXaw": {},
"libxcb": {},
"libxcb-xrm": {},
"libXcomposite": {},
"libXcursor": {},
"libxcvt": {},
"libXdamage": {},
"libXdmcp": {},
"libXext": {},
"libxfce4ui": {},
"libxfce4util": {},
"libxfce4windowing": {},
"libXfixes": {},
"libXfont2": {},
"libXft": {},
"libXi": {},
"libXinerama": {},
"libXkbfile": {},
"libxklavier": {},
"libxml2": {},
"libxmlb": {},
"libXmu": {},
"libXpm": {},
"libXpresent": {},
"libXrandr": {},
"libXrender": {},
"libXres": {},
"libxshmfence": {},
"libxss": {},
"libXt": {},
"libXtst": {},
"libXvMC": {},
"libXxf86vm": {},
"libyaml": {},
"libz": {},
"lm-sensors": {},
"lua51": {},
"luajit": {},
"luv": {},
"lxqt": {},
"lxqt-about": {},
"lxqt-build-tools": {},
"lxqt-config": {},
"lxqt-globalkeys": {},
"lxqt-menu-data": {},
"lxqt-notificationd": {},
"lxqt-panel": {},
"lxqt-policykit": {},
"lxqt-powermanagement": {},
"lxqt-qtplugin": {},
"lxqt-runner": {},
"lxqt-session": {},
"lxqt-themes": {},
"lz4": {},
"make": {},
"mango": {},
"marco": {},
"mate": {},
"mate-backgrounds": {},
"mate-calc": {},
"mate-control-center": {},
"mate-desktop": {},
"mate-icon-theme": {},
"mate-menus": {},
"mate-notification-daemon": {},
"mate-panel": {},
"mate-polkit": {},
"mate-power-manager": {},
"mate-session-manager": {},
"mate-settings-daemon": {},
"mate-terminal": {},
"mate-utils": {},
"md4c": {},
"menu-cache": {},
"mesa": {},
"mesa-dri-gallium": {},
"mesa-gl": {},
"micro": {},
"mksh": {},
"mm-common": {},
"mpc": {},
"mpfr": {},
"mpg123": {},
"muparser": {},
"neovim": {},
"nettle": {},
"nghttp2": {},
"npth": {},
"nscde": {},
"nss": {},
"ntbtls": {},
"nvidia": {},
"nvidia-compiler": {},
"nvidia-cuda": {},
"nvidia-firmware": {},
"nvidia-kernel": {},
"nvidia-optix": {},
"nvidia-utils": {},
"openbox": {},
"opencode": {},
"openssl": {},
"opsec": {},
"opus": {},
"p11-kit": {},
"pango": {},
"pangomm": {},
"pcmanfm-qt": {},
"pcre2": {},
"pekwm": {},
"pinentry": {},
"pipewire": {},
"pixman": {},
"plasma-wayland-protocols": {},
"pluma": {},
"polkit": {},
"polkit-qt-1": {},
"procps-ng": {},
"pugixml": {},
"pulseaudio": {},
"qt5": {},
"qtbase": {},
"qtdeclarative": {},
"qterminal": {},
"qtermwidget": {},
"qtshadertools": {},
"qtsvg": {},
"qttools": {},
"qtwayland": {},
"ripgrep": {},
"rofi": {},
"rsync": {},
"scenefx": {},
"sdl2": {},
"seatd": {},
"setxkbmap": {},
"shared-mime-info": {},
"solid": {},
"spdlog": {},
"speex": {},
"spirv-tools": {},
"sqlite": {},
"startup-notification": {},
"swaybg": {},
"taglib": {},
"tango-icon-theme": {},
"tar": {},
"thunar": {},
"tllist": {},
"tree": {},
"tree-sitter": {},
"tslib": {},
"twolame": {},
"unibilium": {},
"unzip": {},
"upower": {},
"util-macros": {},
"vim": {},
"vlc": {},
"vte": {},
"vulkan-headers": {},
"waybar": {},
"wayland": {},
"wayland-protocols": {},
"wget": {},
"which": {},
"wlroots": {},
"wlr-protocols": {},
"wmaker": {},
"wofi": {},
"xauth": {},
"xcb-proto": {},
"xcb-util": {},
"xcb-util-cursor": {},
"xcb-util-image": {},
"xcb-util-keysyms": {},
"xcb-util-renderutil": {},
"xcb-util-wm": {},
"xdg-desktop-portal": {},
"xdg-user-dirs": {},
"xdpyinfo": {},
"xf86-input-libinput": {},
"xf86-video-intel": {},
"xfce4": {},
"xfce4-appfinder": {},
"xfce4-dev-tools": {},
"xfce4-notifyd": {},
"xfce4-panel": {},
"xfce4-power-manager": {},
"xfce4-session": {},
"xfce4-settings": {},
"xfce4-terminal": {},
"xfconf": {},
"xfdesktop": {},
"xfwm4": {},
"xgamma": {},
"xinit": {},
"xkbcommon": {},
"xkbcomp": {},
"xkeyboard-config": {},
"xmessage": {},
"xmodmap": {},
"xorg-full": {},
"xorgproto": {},
"xorg-server": {},
"xprop": {},
"xrandr": {},
"xrdb": {},
"xset": {},
"xshmfence": {},
"xterm": {},
"xtrans": {},
"xwayland": {},
"xz-utils": {},
"zip": {},
"zsh": {},
"zstd": {},
}
// renames maps Arch Linux package names to their Zeta counterparts. Sources
// are Arch's canonical (lowercase) spelling; targets are the verbatim names
// in zetapkgs. The libX* entries are pure case fixes (Arch spells the X11
// family lowercase); libxcb and friends are already correct in Zeta and
// deliberately absent.
var renames = map[string]string{
"zlib": "libz",
"xz": "xz-utils",
"freetype2": "freetype",
"libxkbcommon": "xkbcommon",
"libice": "libICE",
"libsm": "libSM",
"libx11": "libX11",
"libxau": "libXau",
"libxaw": "libXaw",
"libxcomposite": "libXcomposite",
"libxcursor": "libXcursor",
"libxdamage": "libXdamage",
"libxdmcp": "libXdmcp",
"libxext": "libXext",
"libxfixes": "libXfixes",
"libxfont2": "libXfont2",
"libxft": "libXft",
"libxi": "libXi",
"libxinerama": "libXinerama",
"libxkbfile": "libXkbfile",
"libxmu": "libXmu",
"libxpm": "libXpm",
"libxpresent": "libXpresent",
"libxrandr": "libXrandr",
"libxrender": "libXrender",
"libxres": "libXres",
"libxt": "libXt",
"libxtst": "libXtst",
"libxvmc": "libXvMC",
"libxxf86vm": "libXxf86vm",
}
// dropped lists Arch base-system packages that Zeta provides outside the
// package manager and therefore has no package for (mirroring the dbus and
// libudev stubs in the index).
var dropped = map[string]struct{}{
"glibc": {},
"gcc-libs": {},
"gcc": {},
"bash": {},
"coreutils": {},
"filesystem": {},
"linux-api-headers": {},
"systemd": {},
"util-linux": {},
"ncurses": {},
"readline": {},
"tzdata": {},
"ca-certificates": {},
}
+202
View File
@@ -0,0 +1,202 @@
package depmap
import (
"strings"
"testing"
)
func TestMapDep(t *testing.T) {
tests := []struct {
name string
spec string
want string
wantOK bool
warns int
warnMsg string // exact expected warn message ("" = only check count)
}{
{
name: "zlib renames to libz",
spec: "zlib",
want: "libz",
wantOK: true,
warns: 1,
},
{
name: "glibc dropped as base system",
spec: "glibc",
want: "",
wantOK: false,
warns: 1,
warnMsg: `dropping base-system dependency "glibc"`,
},
{
name: "verbatim index name keeps constraint",
spec: "pcre2>=10.42",
want: "pcre2>=10.42",
wantOK: true,
warns: 0,
},
{
name: "optdepend description stripped",
spec: "python: for scripting",
want: "python",
wantOK: true,
warns: 1,
},
{
name: "unknown dep passes through with warn",
spec: "someunknownpkg123",
want: "someunknownpkg123",
wantOK: true,
warns: 1,
warnMsg: `unmapped dependency "someunknownpkg123" (passed through)`,
},
{
name: "rename preserves constraint",
spec: "zlib>=1.3.1",
want: "libz>=1.3.1",
wantOK: true,
warns: 1,
},
{
name: "x11 case fix libx11",
spec: "libx11",
want: "libX11",
wantOK: true,
warns: 1,
},
{
name: "xz renames to xz-utils",
spec: "xz",
want: "xz-utils",
wantOK: true,
warns: 1,
},
{
name: "x11 case fix libice",
spec: "libice",
want: "libICE",
wantOK: true,
warns: 1,
},
{
name: "freetype2 renames to freetype",
spec: "freetype2",
want: "freetype",
wantOK: true,
warns: 1,
},
{
name: "libxkbcommon renames to xkbcommon",
spec: "libxkbcommon",
want: "xkbcommon",
wantOK: true,
warns: 1,
},
{
name: "libxcb already correct not renamed",
spec: "libxcb",
want: "libxcb",
wantOK: true,
warns: 0,
},
{
name: "zeta casing input passes through verbatim",
spec: "libX11",
want: "libX11",
wantOK: true,
warns: 0,
},
{
name: "bare = normalized to ==",
spec: "foo=1.0",
want: "foo==1.0",
wantOK: true,
warns: 1,
},
{
name: "description plus constraint plus rename",
spec: "zlib>=1.2: for compression",
want: "libz>=1.2",
wantOK: true,
warns: 1,
},
{
name: "drop applies regardless of constraint",
spec: "glibc>=2.38",
want: "",
wantOK: false,
warns: 1,
},
{
name: "gcc-libs dropped",
spec: "gcc-libs",
want: "",
wantOK: false,
warns: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var warns []string
got, ok := MapDep(tt.spec, func(msg string) {
warns = append(warns, msg)
})
if got != tt.want {
t.Errorf("MapDep(%q) = %q, want %q", tt.spec, got, tt.want)
}
if ok != tt.wantOK {
t.Errorf("MapDep(%q) ok = %v, want %v", tt.spec, ok, tt.wantOK)
}
if len(warns) != tt.warns {
t.Errorf("MapDep(%q) fired %d warns (%v), want %d",
tt.spec, len(warns), warns, tt.warns)
}
if tt.warnMsg != "" && len(warns) == 1 && warns[0] != tt.warnMsg {
t.Errorf("MapDep(%q) warn = %q, want %q", tt.spec, warns[0], tt.warnMsg)
}
})
}
}
// TestMapDepNilWarn ensures MapDep tolerates a nil warn callback.
func TestMapDepNilWarn(t *testing.T) {
got, ok := MapDep("zlib", nil)
if got != "libz" || !ok {
t.Fatalf("MapDep(zlib, nil) = %q, %v; want %q, true", got, ok, "libz")
}
}
// TestRenameTargetsExistInIndex guards the checked-in tables against typos:
// every rename target must be a real Zeta package, and every dropped name must
// NOT be one.
func TestRenameTargetsExistInIndex(t *testing.T) {
for from, to := range renames {
if _, ok := zetapkgs[to]; !ok {
t.Errorf("rename %q -> %q: target not in zetapkgs index", from, to)
}
if _, ok := zetapkgs[from]; ok {
t.Errorf("rename %q -> %q: source already in zetapkgs (rename is dead code)", from, to)
}
if _, ok := dropped[to]; ok {
t.Errorf("rename %q -> %q: target also in drop list", from, to)
}
}
for name := range dropped {
if _, ok := zetapkgs[name]; ok {
t.Errorf("drop %q: present in zetapkgs index (drop is wrong)", name)
}
}
}
// TestRenamesSortedLint is a cheap self-check that rename sources are
// lowercase (Arch convention) so case fixes are keyed from the Arch spelling.
func TestRenamesLowercaseKeys(t *testing.T) {
for from := range renames {
if strings.ToLower(from) != from {
t.Errorf("rename key %q is not lowercase", from)
}
}
}
+135
View File
@@ -0,0 +1,135 @@
// Package e2e cross-validates the emitted Zeta artifacts against the REAL
// Zeta Lua loaders: the arch-binary package.lua through ZETA/lib/manifest.lua
// and the arch-src .recipe through zeta-toolchain/toolchain/lib/recipe.lua.
//
// The converters are driven as packages (not via the CLI), so this runs
// under plain `go test` with no built binary. Everything lands in
// t.TempDir(); the only absolute paths are the two loader library roots,
// which live in sibling repositories and cannot be expressed relative to
// this module.
package e2e
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/arch/binary"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/arch/src"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
)
const (
// manifestLibRoot lets a Lua probe require("manifest") from the ZETA
// library checkout.
manifestLibRoot = "/home/specter/Public/ZETA/lib/?.lua"
// recipeLibRoot lets a Lua probe require("recipe") from the
// zeta-toolchain library checkout.
recipeLibRoot = "/home/specter/Public/zeta-toolchain/toolchain/lib/?.lua"
)
// runLua executes script with the `lua` interpreter and returns its output.
// The whole suite is skipped when lua is not installed.
func runLua(t *testing.T, script string) (string, error) {
t.Helper()
lua, err := exec.LookPath("lua")
if err != nil {
t.Skipf("lua not installed: %v", err)
}
out, err := exec.Command(lua, "-e", script).Output()
return string(out), err
}
// manifestProbe returns a Lua script that loads the manifest at path through
// the real ZETA manifest.load. assert makes a nil,nil-err result a hard
// failure (non-zero exit), so exit code 0 proves load+normalize succeeded.
// The trailing ";" separates our component from the default package.path.
func manifestProbe(path string) string {
return fmt.Sprintf(
`package.path=%q..package.path; local m=require("manifest"); assert(m.load(%q))`,
manifestLibRoot+";", path,
)
}
// recipeProbe returns a Lua script that loads the recipe at path through the
// real zeta-makepkg recipe.load, with the same assert semantics.
func recipeProbe(path string) string {
return fmt.Sprintf(
`package.path=%q..package.path; local r=require("recipe"); assert(r.load(%q))`,
recipeLibRoot+";", path,
)
}
// TestEndToEnd runs both converters into one temp dir and validates every
// emitted artifact through the real Lua loaders.
func TestEndToEnd(t *testing.T) {
out := t.TempDir()
// arch-binary: committed fixture -> packages/hello/package.lua.
if err := binary.Convert(
filepath.Join("..", "..", "testdata", "hello-1.0-1-x86_64.pkg.tar.zst"),
registry.Options{Output: out, Repo: "https://example.org/repo"},
); err != nil {
t.Fatalf("binary.Convert: %v", err)
}
manifestPath := filepath.Join(out, "packages", "hello", "package.lua")
if _, err := os.Stat(manifestPath); err != nil {
t.Fatalf("expected manifest at %s: %v", manifestPath, err)
}
if stdout, err := runLua(t, manifestProbe(manifestPath)); err != nil {
t.Fatalf("manifest probe failed: %v\nlua output: %s", err, stdout)
}
// arch-src: committed fixture -> <out>/mytool.recipe.
if err := src.Convert(
filepath.Join("..", "..", "testdata", "srcinfo", "mytool"),
registry.Options{Output: out, Arch: "x86_64"},
); err != nil {
t.Fatalf("src.Convert: %v", err)
}
recipePath := filepath.Join(out, "mytool.recipe")
if _, err := os.Stat(recipePath); err != nil {
t.Fatalf("expected recipe at %s: %v", recipePath, err)
}
if stdout, err := runLua(t, recipeProbe(recipePath)); err != nil {
t.Fatalf("recipe probe failed: %v\nlua output: %s", err, stdout)
}
}
// TestEndToEndRejectsBrokenManifest proves the manifest probe is real, not a
// no-op: corrupting the emitted sha256 must make manifest.load (and thus the
// probe) fail. A probe that ignored the file would let this test fail.
func TestEndToEndRejectsBrokenManifest(t *testing.T) {
out := t.TempDir()
if err := binary.Convert(
filepath.Join("..", "..", "testdata", "hello-1.0-1-x86_64.pkg.tar.zst"),
registry.Options{Output: out, Repo: "https://example.org/repo"},
); err != nil {
t.Fatalf("binary.Convert: %v", err)
}
manifestPath := filepath.Join(out, "packages", "hello", "package.lua")
content, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
sha256Re := regexp.MustCompile(`[0-9a-fA-F]{64}`)
broken := sha256Re.ReplaceAllString(string(content), "zzz")
if broken == string(content) || !strings.Contains(broken, "zzz") {
t.Fatal("could not corrupt the manifest: no 64-hex sha256 found")
}
brokenPath := filepath.Join(out, "broken-package.lua")
if err := os.WriteFile(brokenPath, []byte(broken), 0o644); err != nil {
t.Fatalf("write broken manifest: %v", err)
}
stdout, err := runLua(t, manifestProbe(brokenPath))
if err == nil {
t.Fatalf("probe unexpectedly accepted broken manifest\nlua output: %s", stdout)
}
}
+251
View File
@@ -0,0 +1,251 @@
// Package zeta models the Zeta package formats (package.lua manifests and
// .recipe files) and emits them byte-for-byte the way the Zeta toolchain does.
//
// The Manifest here covers archive mode only (v1 of zeta-reconstruct emits
// only archive-mode package.lua); install/build strategies are out of scope.
package zeta
import (
"fmt"
"regexp"
"strings"
)
// Manifest is the Go model of a Zeta package.lua manifest (archive mode).
//
// Arch and Files mirror Zeta's KNOWN_KEYS (ZETA/lib/manifest.lua) and are kept
// for forward compatibility; they are not populated in v1 and never emitted.
// Archive is the (v1-only) install strategy: `archive = { strip = N }`.
type Manifest struct {
Name string
Version string
Summary string
URL string
SHA256 string
Arch string
Deps []string
Files []string
Archive *Archive
}
// Archive declares the tarball strip level, mirroring Zeta's
// `archive = { strip = N }` install strategy.
type Archive struct {
Strip int
}
var (
// nameRe mirrors ZETA/lib/path.lua sanitize_name's character class
// ([%w%._+%-]+, i.e. [A-Za-z0-9_.+-]).
nameRe = regexp.MustCompile(`^[A-Za-z0-9_.+-]+$`)
// sha256Re mirrors manifest.lua's HEX64: ^ + 64 × %x + $.
sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
// httpRe mirrors manifest.lua's `^https?://` remote-URL test.
httpRe = regexp.MustCompile(`^https?://`)
// unsafeURLRe mirrors manifest.lua's local-URL safety test: a URL that is
// not http(s) may not contain whitespace or backslashes.
unsafeURLRe = regexp.MustCompile(`[\s\\]`)
// depSpecRe mirrors vercmp.lua parse_dep's first step:
// leading whitespace, a name of [%w%._%+%-]+ characters, whitespace, and
// the rest of the spec (constraint or empty).
depSpecRe = regexp.MustCompile(`^\s*([A-Za-z0-9_.+-]+)\s*(.*)$`)
// depOpRe and depVerRe mirror parse_dep's operator/version extraction.
depOpRe = regexp.MustCompile(`^([<>=~]+)\s*`)
depVerRe = regexp.MustCompile(`^[<>=~]+\s*([A-Za-z0-9_.+-]+)\s*$`)
// validDepOps mirrors ZETA/lib/vercmp.lua's OPS table.
validDepOps = map[string]bool{
">=": true, "<=": true, "==": true, "~=": true,
">": true, "<": true, "=": true,
}
// cleanReDotSlash/cleanReLeadSlash/cleanReTrailSlash mirror the three
// anchored gsubs of manifest.lua's clean_rel, in the same order.
cleanReDotSlash = regexp.MustCompile(`^\./+`)
cleanReLeadSlash = regexp.MustCompile(`^/+`)
cleanReTrailSlash = regexp.MustCompile(`/+$`)
)
// validName mirrors ZETA/lib/path.lua sanitize_name: non-empty, no leading
// dot, and every character in [A-Za-z0-9_.+-].
func validName(name string) bool {
if name == "" || strings.HasPrefix(name, ".") {
return false
}
return nameRe.MatchString(name)
}
// cleanRel mirrors manifest.lua's clean_rel: strip a "./" prefix, a "/"
// prefix, and a "/" suffix (in that order), producing the canonical
// root-relative form.
func cleanRel(p string) string {
p = cleanReDotSlash.ReplaceAllString(p, "")
p = cleanReLeadSlash.ReplaceAllString(p, "")
p = cleanReTrailSlash.ReplaceAllString(p, "")
return p
}
// relativeInside mirrors ZETA/lib/path.lua relative_inside: true if the
// relative path never climbs above its root via ".." segments.
func relativeInside(p string) bool {
depth := 0
for _, seg := range strings.Split(p, "/") {
switch seg {
case "..":
depth--
if depth < 0 {
return false
}
case ".", "":
// "." and empty segments (from "//") neither climb nor descend.
default:
depth++
}
}
return true
}
// escape mirrors packager.lua's escape: quote backslash-quoting first, then
// newline escaping, in the same order as the Lua gsub chain.
func escape(s string) string {
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", `\n`)
return s
}
// parseDep mirrors ZETA/lib/vercmp.lua parse_dep:
// "pcre2>=10.42" -> ("pcre2", ">=", "10.42"), "libffi" -> ("libffi", "", "").
// A bare "=" operator is normalized to "==", as in vercmp.lua.
func parseDep(spec string) (name, op, version string, err error) {
sm := depSpecRe.FindStringSubmatch(spec)
if sm == nil {
return "", "", "", fmt.Errorf("bad dependency %q", spec)
}
name, rest := sm[1], sm[2]
if rest == "" {
return name, "", "", nil
}
om := depOpRe.FindStringSubmatch(rest)
vm := depVerRe.FindStringSubmatch(rest)
if om == nil || vm == nil || !validDepOps[om[1]] {
return "", "", "", fmt.Errorf("bad dependency constraint %q (expected NAME OP VERSION)", spec)
}
op = om[1]
version = vm[1]
if op == "=" {
op = "=="
}
return name, op, version, nil
}
// Validate checks the manifest against Zeta's own rules, mirroring
// ZETA/lib/manifest.lua normalize exactly (archive path only):
//
// 1. name: non-empty, no leading dot, all of [A-Za-z0-9_.+-]
// 2. version: non-empty
// 3. sha256: when set, exactly 64 hex characters (uppercase accepted and
// normalized to lowercase, as manifest.lua does)
// 4. url: non-http(s) URLs must not contain whitespace or backslashes
// 5. deps: each entry must parse as NAME [OP VERSION] with a known operator,
// and must not depend on the package itself
// 6. files: entries must be non-empty, clean to a path that stays inside the
// install root, and appear only once
// 7. archive.strip: non-negative integer
// 8. archive mode requires a url (v1 is archive-only, so url is always
// required)
// 9. remote http(s) urls must declare sha256
func (m *Manifest) Validate() error {
if !validName(m.Name) {
return fmt.Errorf("manifest: missing or invalid package name")
}
if m.Version == "" {
return fmt.Errorf("manifest: package %q missing version", m.Name)
}
if m.SHA256 != "" {
lower := strings.ToLower(m.SHA256)
if !sha256Re.MatchString(lower) {
return fmt.Errorf("manifest: package %q sha256 must be exactly 64 hex characters", m.Name)
}
m.SHA256 = lower
}
if m.URL != "" && !httpRe.MatchString(m.URL) && unsafeURLRe.MatchString(m.URL) {
return fmt.Errorf("manifest: package %q url %q looks unsafe", m.Name, m.URL)
}
for _, d := range m.Deps {
depName, _, _, err := parseDep(d)
if err != nil {
return fmt.Errorf("manifest: package %q: %w", m.Name, err)
}
if depName == m.Name {
return fmt.Errorf("manifest: package %q depends on itself", m.Name)
}
}
seen := make(map[string]bool, len(m.Files))
for _, f := range m.Files {
if f == "" {
return fmt.Errorf("manifest: package %q has an empty files entry", m.Name)
}
rel := cleanRel(f)
if rel == "" || !relativeInside(rel) {
return fmt.Errorf("manifest: package %q files entry %q escapes the root", m.Name, f)
}
if seen[rel] {
return fmt.Errorf("manifest: package %q lists %q twice in files", m.Name, rel)
}
seen[rel] = true
}
if m.Archive != nil && m.Archive.Strip < 0 {
return fmt.Errorf("manifest: package %q archive.strip must be a non-negative integer", m.Name)
}
if m.URL == "" {
return fmt.Errorf("manifest: package %q archive mode requires a url", m.Name)
}
if httpRe.MatchString(m.URL) && m.SHA256 == "" {
return fmt.Errorf("manifest: package %q must declare sha256 for a remote url", m.Name)
}
return nil
}
// Emit renders the archive-mode package.lua with the exact byte format of
// packager.lua's write_manifest: 2-space indent, "="-aligned field names in
// the order name/version/summary/url/sha256/deps/archive, `archive = { strip = 1 }`,
// and no test function. Summary and deps values are escaped (quote/newline);
// name/version/url/sha256 are emitted raw (they are validated-safe). Emit
// fails if the manifest does not Validate.
func (m *Manifest) Emit() (string, error) {
if err := m.Validate(); err != nil {
return "", err
}
var b strings.Builder
b.WriteString("return {\n")
b.WriteString(fmt.Sprintf(" %-8s= \"%s\",\n", "name", m.Name))
b.WriteString(fmt.Sprintf(" %-8s= \"%s\",\n", "version", m.Version))
b.WriteString(fmt.Sprintf(" %-8s= \"%s\",\n", "summary", escape(m.Summary)))
b.WriteString(fmt.Sprintf(" %-8s= \"%s\",\n", "url", m.URL))
b.WriteString(fmt.Sprintf(" %-8s= \"%s\",\n", "sha256", m.SHA256))
if len(m.Deps) > 0 {
items := make([]string, len(m.Deps))
for i, d := range m.Deps {
items[i] = `"` + escape(d) + `"`
}
b.WriteString(fmt.Sprintf(" %-8s= { %s },\n", "deps", strings.Join(items, ", ")))
} else {
b.WriteString(fmt.Sprintf(" %-8s= {},\n", "deps"))
}
b.WriteString(fmt.Sprintf(" %-8s= { strip = 1 },\n", "archive"))
b.WriteString("}\n")
return b.String(), nil
}
+276
View File
@@ -0,0 +1,276 @@
package zeta
import (
"os"
"path/filepath"
"strings"
"testing"
)
// validManifest returns a fully valid archive-mode manifest that every
// table case can copy-and-mutate.
func validManifest() *Manifest {
return &Manifest{
Name: "hello",
Version: "1.0",
Summary: "A tiny demonstration program",
URL: "https://example.org/packages/hello/hello-1.0.tar.gz",
SHA256: "731887527d1a72c57d1e64bad85cc341a4285354e773b2cd277420b744df2943",
Archive: &Archive{Strip: 1},
}
}
func TestManifest(t *testing.T) {
tests := []struct {
name string
mutate func(m *Manifest)
wantErr bool
wantErrSub string
}{
{
name: "valid archive manifest",
mutate: func(m *Manifest) {},
},
{
name: "missing version",
mutate: func(m *Manifest) {
m.Version = ""
},
wantErr: true,
wantErrSub: "missing version",
},
{
name: "bad sha256",
mutate: func(m *Manifest) {
m.SHA256 = "zzz"
},
wantErr: true,
wantErrSub: "64 hex",
},
{
name: "archive without url",
mutate: func(m *Manifest) {
m.URL = ""
},
wantErr: true,
wantErrSub: "archive mode requires a url",
},
{
name: "remote url without sha256",
mutate: func(m *Manifest) {
m.SHA256 = ""
},
wantErr: true,
wantErrSub: "must declare sha256 for a remote url",
},
{
name: "self dep",
mutate: func(m *Manifest) {
m.Deps = []string{"hello>=1.0"}
},
wantErr: true,
wantErrSub: "depends on itself",
},
{
name: "dep with bad operator",
mutate: func(m *Manifest) {
m.Deps = []string{"pcre2<<10.42"}
},
wantErr: true,
wantErrSub: "bad dependency constraint",
},
{
name: "dep without operator",
mutate: func(m *Manifest) {
m.Deps = []string{"pcre2 10.42"}
},
wantErr: true,
wantErrSub: "bad dependency constraint",
},
{
name: "dotdot in files",
mutate: func(m *Manifest) {
m.Files = []string{"../evil"}
},
wantErr: true,
wantErrSub: "escapes the root",
},
{
name: "empty files entry",
mutate: func(m *Manifest) {
m.Files = []string{""}
},
wantErr: true,
wantErrSub: "empty files entry",
},
{
name: "duplicate files entry",
mutate: func(m *Manifest) {
m.Files = []string{"usr/bin/hello", "usr/bin/hello"}
},
wantErr: true,
wantErrSub: "twice",
},
{
name: "negative strip",
mutate: func(m *Manifest) {
m.Archive = &Archive{Strip: -1}
},
wantErr: true,
wantErrSub: "non-negative integer",
},
{
name: "leading dot name",
mutate: func(m *Manifest) {
m.Name = ".hidden"
},
wantErr: true,
wantErrSub: "invalid package name",
},
{
name: "slash in name",
mutate: func(m *Manifest) {
m.Name = "a/b"
},
wantErr: true,
wantErrSub: "invalid package name",
},
{
name: "unsafe local url",
mutate: func(m *Manifest) {
m.URL = "/tmp/foo bar"
m.SHA256 = ""
},
wantErr: true,
wantErrSub: "looks unsafe",
},
{
name: "valid deps with constraints",
mutate: func(m *Manifest) {
m.Deps = []string{"pcre2>=10.42", "libffi", "libz=1.2.13", "foo == 1.0"}
},
},
{
name: "local url without sha256 ok",
mutate: func(m *Manifest) {
m.URL = "/var/cache/zeta/hello-1.0.tar.gz"
m.SHA256 = ""
},
},
{
name: "valid files entries cleaned",
mutate: func(m *Manifest) {
m.Files = []string{"usr/bin/hello", "/usr/share/doc/", "./etc/zeta"}
},
},
{
name: "empty summary ok",
mutate: func(m *Manifest) {
m.Summary = ""
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := validManifest()
tt.mutate(m)
err := m.Validate()
if tt.wantErr {
if err == nil {
t.Fatalf("Validate() = nil, want error containing %q", tt.wantErrSub)
}
if tt.wantErrSub != "" && !strings.Contains(err.Error(), tt.wantErrSub) {
t.Fatalf("Validate() error = %q, want it to contain %q", err, tt.wantErrSub)
}
return
}
if err != nil {
t.Fatalf("Validate() = %v, want nil", err)
}
})
}
}
func TestManifestSHA256Lowered(t *testing.T) {
m := validManifest()
m.SHA256 = strings.ToUpper(m.SHA256)
if err := m.Validate(); err != nil {
t.Fatalf("Validate() = %v, want nil (uppercase hex accepted)", err)
}
if m.SHA256 != strings.ToLower(m.SHA256) {
t.Fatalf("SHA256 = %q, want it lowercased", m.SHA256)
}
}
func TestManifestEmitFormat(t *testing.T) {
m := &Manifest{
Name: "tool",
Version: "2.0",
Summary: "say \"hi\"\nnewline",
URL: "https://example.org/packages/tool/tool-2.0.tar.gz",
SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Deps: []string{"libz>=1.2.13", "pcre2"},
Archive: &Archive{Strip: 1},
}
want := `return {
name = "tool",
version = "2.0",
summary = "say \"hi\"\nnewline",
url = "https://example.org/packages/tool/tool-2.0.tar.gz",
sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
deps = { "libz>=1.2.13", "pcre2" },
archive = { strip = 1 },
}
`
got, err := m.Emit()
if err != nil {
t.Fatalf("Emit() error = %v", err)
}
if got != want {
t.Fatalf("Emit() output mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func TestManifestEmitInvalid(t *testing.T) {
m := validManifest()
m.Version = ""
if _, err := m.Emit(); err == nil {
t.Fatal("Emit() = nil error, want validation error for missing version")
}
}
func TestManifestGolden(t *testing.T) {
m := &Manifest{
Name: "hello",
Version: "1.0",
Summary: "A tiny demonstration program",
URL: "https://example.org/packages/hello/hello-1.0.tar.gz",
SHA256: "731887527d1a72c57d1e64bad85cc341a4285354e773b2cd277420b744df2943",
Deps: []string{},
}
got, err := m.Emit()
if err != nil {
t.Fatalf("Emit() error = %v", err)
}
goldenPath := filepath.Join("..", "..", "testdata", "golden", "hello.package.lua")
if os.Getenv("UPDATE_GOLDEN") == "1" {
if err := os.MkdirAll(filepath.Dir(goldenPath), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
t.Fatalf("WriteFile golden: %v", err)
}
t.Logf("wrote golden %s", goldenPath)
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("ReadFile golden %s: %v (run UPDATE_GOLDEN=1 to regenerate)", goldenPath, err)
}
if got != string(want) {
t.Fatalf("Emit() does not match golden %s:\n--- got ---\n%s\n--- want ---\n%s", goldenPath, got, want)
}
}
+9
View File
@@ -0,0 +1,9 @@
return {
name = "hello",
version = "1.0",
summary = "A tiny demonstration program",
url = "https://example.org/packages/hello/hello-1.0.tar.gz",
sha256 = "731887527d1a72c57d1e64bad85cc341a4285354e773b2cd277420b744df2943",
deps = {},
archive = { strip = 1 },
}
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -eu
pkgname='mytool'
pkgver='1.0'
pkgrel='1'
arch='x86_64'
url='https://example.org'
srcdir="$PWD"
pkgdir="$DESTDIR"
msg() { :; }
prepare() { :; }
build() {
cd "$srcdir"
./configure --prefix=/usr
make
}
package() {
cd "$srcdir"
make DESTDIR="$pkgdir" install
}
build
package
Binary file not shown.