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