109 lines
3.4 KiB
Lua
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
|