-- 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 "/" prefix rels[#rels + 1] = rel log.ok(("hook %s"):format(rel)) end return rels end return hooks