228 lines
7.2 KiB
Lua
228 lines
7.2 KiB
Lua
-- recipe.lua -- loading and validation of declarative .recipe files.
|
|
--
|
|
-- A recipe is a Lua file returning a table. It is purely data — no functions,
|
|
-- no side effects. The recipe describes *what* to build; zeta-makepkg handles
|
|
-- the *how*. This keeps recipes readable, reviewable, and safe to execute.
|
|
--
|
|
-- return {
|
|
-- name = "mypkg",
|
|
-- version = "1.2.3",
|
|
-- summary = "Short description",
|
|
-- url = "https://.../mypkg-1.2.3.tar.gz",
|
|
-- sha256 = nil, -- optional source checksum
|
|
-- deps = { "libfoo" },
|
|
-- build_system = "meson", -- autotools | cmake | meson | make | cargo | zig | build.sh | custom
|
|
-- configure_args = { ... }, -- optional extra args
|
|
-- build_script = "build.sh", -- required for custom
|
|
-- test = "test -f ${DESTDIR}/usr/bin/mypkg",
|
|
-- files = { "usr/bin/mypkg" },
|
|
-- }
|
|
|
|
local recipe = {}
|
|
|
|
local path = require("path")
|
|
local log = require("log")
|
|
|
|
local KNOWN_KEYS = {
|
|
name = true, version = true, summary = true, url = true,
|
|
sha256 = true, deps = true, build_system = true,
|
|
configure_args = true, build_script = true, test = true, files = true,
|
|
}
|
|
|
|
local BUILD_SYSTEMS = {
|
|
autotools = true, cmake = true, meson = true,
|
|
make = true, cargo = true, zig = true,
|
|
["build.sh"] = true, custom = true,
|
|
}
|
|
|
|
local HEX64 = "^" .. string.rep("%x", 64) .. "$"
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Normalization & validation
|
|
---------------------------------------------------------------------------
|
|
|
|
function recipe.normalize(raw)
|
|
if type(raw) ~= "table" then
|
|
error("recipe must return a table", 2)
|
|
end
|
|
|
|
-- name (required, must be a valid package name)
|
|
local name = raw.name
|
|
if type(name) ~= "string" or not path.sanitize_name(name) then
|
|
error("recipe: missing or invalid package name", 2)
|
|
end
|
|
|
|
-- version (required, non-empty)
|
|
local version = raw.version
|
|
if type(version) ~= "string" or version == "" then
|
|
error(("recipe %q: version is required and must be a non-empty string"):format(name), 2)
|
|
end
|
|
|
|
-- summary (required)
|
|
local summary = raw.summary
|
|
if type(summary) ~= "string" or summary == "" then
|
|
error(("recipe %q: summary is required"):format(name), 2)
|
|
end
|
|
|
|
-- url (required, must be a string)
|
|
local url = raw.url
|
|
if type(url) ~= "string" or url == "" then
|
|
error(("recipe %q: url is required"):format(name), 2)
|
|
end
|
|
|
|
-- sha256 (optional, must be 64 hex chars if present)
|
|
local sha256 = nil
|
|
if raw.sha256 ~= nil then
|
|
if type(raw.sha256) ~= "string" or not raw.sha256:lower():match(HEX64) then
|
|
error(("recipe %q: sha256 must be exactly 64 hex characters or nil"):format(name), 2)
|
|
end
|
|
sha256 = raw.sha256:lower()
|
|
end
|
|
|
|
-- deps (optional, list of strings)
|
|
local deps = {}
|
|
if raw.deps ~= nil then
|
|
if type(raw.deps) ~= "table" then
|
|
error(("recipe %q: deps must be a list of strings"):format(name), 2)
|
|
end
|
|
for _, d in ipairs(raw.deps) do
|
|
if type(d) ~= "string" or d == "" then
|
|
error(("recipe %q: each dep must be a non-empty string"):format(name), 2)
|
|
end
|
|
deps[#deps + 1] = d
|
|
end
|
|
end
|
|
|
|
-- build_system (required, must be valid)
|
|
local build_system = raw.build_system
|
|
if type(build_system) ~= "string" or not BUILD_SYSTEMS[build_system] then
|
|
error(("recipe %q: build_system must be one of: %s"):format(
|
|
name, table.concat(sorted_keys(BUILD_SYSTEMS), ", ")), 2)
|
|
end
|
|
|
|
-- configure_args (optional, list of strings)
|
|
local configure_args = {}
|
|
if raw.configure_args ~= nil then
|
|
if type(raw.configure_args) ~= "table" then
|
|
error(("recipe %q: configure_args must be a list of strings"):format(name), 2)
|
|
end
|
|
for _, a in ipairs(raw.configure_args) do
|
|
configure_args[#configure_args + 1] = tostring(a)
|
|
end
|
|
end
|
|
|
|
-- build_script (required for custom, ignored otherwise)
|
|
local build_script = nil
|
|
if raw.build_script ~= nil then
|
|
if type(raw.build_script) ~= "string" or raw.build_script == "" then
|
|
error(("recipe %q: build_script must be a non-empty string"):format(name), 2)
|
|
end
|
|
build_script = raw.build_script
|
|
end
|
|
if build_system == "custom" and not build_script then
|
|
error(("recipe %q: build_script is required when build_system is 'custom'"):format(name), 2)
|
|
end
|
|
|
|
-- test (optional, shell command string)
|
|
local test_cmd = nil
|
|
if raw.test ~= nil then
|
|
if type(raw.test) ~= "string" or raw.test == "" then
|
|
error(("recipe %q: test must be a non-empty shell command string"):format(name), 2)
|
|
end
|
|
test_cmd = raw.test
|
|
end
|
|
|
|
-- files (optional, whitelist of relative paths)
|
|
local files = nil
|
|
if raw.files ~= nil then
|
|
if type(raw.files) ~= "table" then
|
|
error(("recipe %q: files must be a list of relative paths"):format(name), 2)
|
|
end
|
|
files = {}
|
|
for _, f in ipairs(raw.files) do
|
|
if type(f) ~= "string" or f == "" then
|
|
error(("recipe %q: each files entry must be a non-empty string"):format(name), 2)
|
|
end
|
|
local rel = f:gsub("^/*", ""):gsub("/+$", "")
|
|
if rel == "" then
|
|
error(("recipe %q: files entry %q is invalid"):format(name, f), 2)
|
|
end
|
|
files[#files + 1] = rel
|
|
end
|
|
end
|
|
|
|
-- Flag unknown fields
|
|
for k in pairs(raw) do
|
|
if not KNOWN_KEYS[k] then
|
|
log.warn(("recipe %q: unknown field %q (ignored)"):format(name, tostring(k)))
|
|
end
|
|
end
|
|
|
|
return {
|
|
name = name,
|
|
version = version,
|
|
summary = summary,
|
|
url = url,
|
|
sha256 = sha256,
|
|
deps = deps,
|
|
build_system = build_system,
|
|
configure_args = configure_args,
|
|
build_script = build_script,
|
|
test = test_cmd,
|
|
files = files,
|
|
}
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Loading
|
|
---------------------------------------------------------------------------
|
|
|
|
function recipe.load(filepath)
|
|
local f, err = io.open(filepath, "rb")
|
|
if not f then
|
|
return nil, ("cannot open recipe %q: %s"):format(filepath, tostring(err))
|
|
end
|
|
local src = f:read("*a")
|
|
f:close()
|
|
|
|
-- Recipes run in a minimal environment — they're pure data, but we still
|
|
-- lock down globals to prevent accidental side effects.
|
|
local env = {}
|
|
local chunk
|
|
if setfenv then
|
|
chunk, err = loadstring(src, "@" .. filepath)
|
|
if chunk then setfenv(chunk, env) end
|
|
else
|
|
chunk, err = load(src, "@" .. filepath, "t", env)
|
|
end
|
|
if not chunk then
|
|
return nil, ("recipe %q: syntax error: %s"):format(filepath, tostring(err))
|
|
end
|
|
|
|
local ok, raw = pcall(chunk)
|
|
if not ok then
|
|
return nil, ("recipe %q: runtime error: %s"):format(filepath, tostring(raw))
|
|
end
|
|
|
|
local ok2, normalized = pcall(recipe.normalize, raw)
|
|
if not ok2 then
|
|
return nil, ("recipe %q: " .. tostring(normalized)):format(filepath)
|
|
end
|
|
|
|
log.ok(("loaded recipe %s-%s"):format(normalized.name, normalized.version))
|
|
return normalized
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Helpers
|
|
---------------------------------------------------------------------------
|
|
|
|
function sorted_keys(t)
|
|
local keys = {}
|
|
for k in pairs(t) do keys[#keys + 1] = k end
|
|
table.sort(keys)
|
|
return keys
|
|
end
|
|
|
|
return recipe
|