Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb4af4461 | ||
|
|
3aa03da5db | ||
|
|
bcd6c9a2e3 | ||
|
|
44c63b5cf7 | ||
|
|
8267b45888 | ||
|
|
38ddc7cfb2 | ||
|
|
8ee8ec30db | ||
|
|
ba1f93366d | ||
|
|
32e7f6134a | ||
|
|
970e002fbb |
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
module git.spectoria.dev/huntedbytheirs/zeta-reconstruct
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require github.com/klauspost/compress v1.19.2
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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": {},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -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 },
|
||||
}
|
||||
Vendored
+29
@@ -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
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user