Files

252 lines
8.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}