Compare commits
10
Commits
5823b35373
...
main
| 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"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
||||||
|
|
||||||
@@ -39,6 +40,29 @@ func main() {
|
|||||||
os.Exit(run(os.Args[1:]))
|
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 {
|
func run(args []string) int {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fmt.Fprintln(os.Stderr, "error: no command specified")
|
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 := flag.NewFlagSet(frontend.Name(), flag.ContinueOnError)
|
||||||
fs.SetOutput(os.Stderr)
|
fs.SetOutput(os.Stderr)
|
||||||
opts := registry.Options{Output: "."}
|
opts := registry.Options{Output: "."}
|
||||||
|
valueFlags := make(map[string]bool)
|
||||||
switch frontend.Name() {
|
switch frontend.Name() {
|
||||||
case "arch-binary":
|
case "arch-binary":
|
||||||
opts.Repo = defaultRepo
|
opts.Repo = defaultRepo
|
||||||
fs.StringVar(&opts.Repo, "repo", opts.Repo, "repository base URL")
|
fs.StringVar(&opts.Repo, "repo", opts.Repo, "repository base URL")
|
||||||
|
valueFlags["--repo"] = true
|
||||||
case "arch-src":
|
case "arch-src":
|
||||||
opts.Arch = "x86_64"
|
opts.Arch = "x86_64"
|
||||||
fs.StringVar(&opts.Arch, "arch", opts.Arch, "target architecture")
|
fs.StringVar(&opts.Arch, "arch", opts.Arch, "target architecture")
|
||||||
|
valueFlags["--arch"] = true
|
||||||
}
|
}
|
||||||
fs.StringVar(&opts.Output, "output", opts.Output, "output directory")
|
fs.StringVar(&opts.Output, "output", opts.Output, "output directory")
|
||||||
|
valueFlags["--output"] = true
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr, "usage: zeta-reconstruct %s [flags] <input>\n", frontend.Name())
|
fmt.Fprintf(os.Stderr, "usage: zeta-reconstruct %s [flags] <input>\n", frontend.Name())
|
||||||
fmt.Fprintln(os.Stderr, "Run 'zeta-reconstruct --help' for usage.")
|
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 {
|
if err == flag.ErrHelp {
|
||||||
fs.Usage()
|
fs.Usage()
|
||||||
return 0
|
return 0
|
||||||
@@ -100,6 +128,11 @@ func run(args []string) int {
|
|||||||
fs.Usage()
|
fs.Usage()
|
||||||
return 2
|
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 {
|
if err := frontend.Convert(rest[0], opts); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
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
|
module git.spectoria.dev/huntedbytheirs/zeta-reconstruct
|
||||||
|
|
||||||
go 1.26.5
|
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
|
package binary
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,9 +11,10 @@ type frontend struct{}
|
|||||||
|
|
||||||
func (frontend) Name() string { return "arch-binary" }
|
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 {
|
func (frontend) Convert(input string, opts registry.Options) error {
|
||||||
// Implemented in a later todo (.pkg.tar.zst -> tarball + package.lua).
|
return Convert(input, opts)
|
||||||
return errors.New("not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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
|
package src
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,9 +11,9 @@ type frontend struct{}
|
|||||||
|
|
||||||
func (frontend) Name() string { return "arch-src" }
|
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 {
|
func (frontend) Convert(input string, opts registry.Options) error {
|
||||||
// Implemented in a later todo (PKGBUILD/.SRCINFO -> .recipe + build.sh).
|
return Convert(input, opts)
|
||||||
return errors.New("not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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