diff --git a/internal/zeta/manifest.go b/internal/zeta/manifest.go new file mode 100644 index 0000000..5af2dfa --- /dev/null +++ b/internal/zeta/manifest.go @@ -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 +} diff --git a/internal/zeta/manifest_test.go b/internal/zeta/manifest_test.go new file mode 100644 index 0000000..5747a1c --- /dev/null +++ b/internal/zeta/manifest_test.go @@ -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) + } +} diff --git a/testdata/golden/hello.package.lua b/testdata/golden/hello.package.lua new file mode 100644 index 0000000..e8f2bc9 --- /dev/null +++ b/testdata/golden/hello.package.lua @@ -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 }, +}