Files
2026-08-17 19:23:32 -04:00

109 lines
3.4 KiB
Lua

-- hooks.lua -- scan and validate post-transactional hook files in the stage.
--
-- Zeta packages may ship hooks under usr/share/zeta/hooks/*.hook. A hook is
-- a Lua file returning a table of the form:
--
-- return {
-- order = 100,
-- trigger = { ... },
-- action = { ... },
-- }
--
-- scan_hooks(stage_dir) finds every *.hook file under the stage's
-- usr/share/zeta/hooks/ directory, loads each one in an empty environment
-- (the same lockdown as recipe.load), verifies it returns a table carrying
-- `trigger` and `action` tables, and returns the stage-relative paths
-- (e.g. "usr/share/zeta/hooks/demo.hook"). A malformed hook — bad syntax,
-- runtime error, non-table result, or missing trigger/action — raises.
local hooks = {}
local path = require("path")
local log = require("log")
----------------------------------------------------------------------------
-- Private helpers
----------------------------------------------------------------------------
-- Load a single hook file under an empty environment and perform minimal
-- validation. Returns the hook table or raises on any failure.
local function load_hook(filepath)
local f, err = io.open(filepath, "rb")
if not f then
error(("hook %q: cannot open: %s"):format(filepath, tostring(err)), 0)
end
local src = f:read("*a")
f:close()
-- Hooks run in a minimal environment — they're pure data, but we still
-- lock down globals to prevent accidental side effects (same as recipes).
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
error(("hook %q: syntax error: %s"):format(filepath, tostring(err)), 0)
end
local ok, raw = pcall(chunk)
if not ok then
error(("hook %q: runtime error: %s"):format(filepath, tostring(raw)), 0)
end
if type(raw) ~= "table" then
error(("hook %q: must return a table"):format(filepath), 0)
end
if type(raw.trigger) ~= "table" then
error(("hook %q: missing or invalid `trigger` table"):format(filepath), 0)
end
if type(raw.action) ~= "table" then
error(("hook %q: missing or invalid `action` table"):format(filepath), 0)
end
return raw
end
----------------------------------------------------------------------------
-- Public API
----------------------------------------------------------------------------
-- Scan the stage for usr/share/zeta/hooks/*.hook, validate each one, and
-- return their stage-relative paths (sorted for deterministic manifests).
-- Returns an empty list when no hooks directory exists. Raises on the first
-- malformed hook.
function hooks.scan_hooks(stage_dir)
local hooks_dir = path.join(stage_dir, "usr/share/zeta/hooks")
if not path.exists(hooks_dir) then
return {}
end
log.step("scanning hooks in " .. hooks_dir)
local found = {}
local f = io.popen("find " .. path.quote(hooks_dir)
.. " -type f -name '*.hook' 2>/dev/null | sort")
if f then
for line in f:lines() do
if line ~= "" then found[#found + 1] = line end
end
f:close()
end
local base = stage_dir:gsub("/+$", "")
local rels = {}
for _, abs in ipairs(found) do
load_hook(abs) -- raises on malformed hooks
local rel = abs:sub(#base + 2) -- strip "<stage_dir>/" prefix
rels[#rels + 1] = rel
log.ok(("hook %s"):format(rel))
end
return rels
end
return hooks