// The plug-in loader (REQ-PLUG-1). It switches on the modules the // organization file's `plugins` object names, one key per kind, and loads // only packages/plugins///index.mjs from the tree this file runs // from. It takes no path from config and loads nothing outside that tree // (lead decision 143). A loaded module runs with the host's authority. // // A refusal keeps the kind's last good modules and is logged. A kind refused // on its first load has nothing switched on. `loaded()` reports what each // kind actually has switched on. import { lstatSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { KINDS, NAME, PluginError, namesFor } from "./kinds.mjs"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const refuse = (code, message, name) => { throw new PluginError(code, message, name); }; // Run one filesystem read. Any error becomes a refusal for this path, so load() // logs it and keeps the kind's old modules (Darkwing N1, Filbert F2, sage-0314). function fsCall(path, read) { try { return read(path); } catch (e) { if (e.code === "ENOENT" || e.code === "ENOTDIR") refuse("missing", `${path} is missing`); refuse("unreadable", `${path} can't be read (${e.code ?? e.message})`); } } // lstat one path component: it must exist, be the expected type, and not be a symlink. function component(path, directory) { const st = fsCall(path, lstatSync); if (st.isSymbolicLink()) refuse("symlink", `${path} is a symlink`); if (directory ? !st.isDirectory() : !st.isFile()) { refuse("missing", `${path} is not a ${directory ? "directory" : "regular file"}`); } } async function loadModule(spec, name, barrier) { const kindDir = join(ROOT, spec.kind); const nameDir = join(kindDir, name); const index = join(nameDir, "index.mjs"); component(ROOT, true); component(kindDir, true); component(nameDir, true); component(index, false); barrier("checked", { kind: spec.kind, name }); // Last check before import: the resolved file is still the one checked // above. Ancestors of packages/plugins are not checked (sage-0301). const expected = join(fsCall(ROOT, realpathSync), spec.kind, name, "index.mjs"); const real = fsCall(index, realpathSync); if (real !== expected) refuse("symlink", `${index} resolves to ${real}`); let module; try { module = await import(pathToFileURL(expected).href); } catch (e) { refuse("load-failed", `${spec.kind} ${name} failed to load: ${e.message}`); } for (const key of ["interfaceVersion", ...spec.exports]) { if (!Object.hasOwn(module, key)) refuse("missing-export", `${spec.kind} ${name} doesn't export ${key}`); } for (const key of spec.exports) { if (typeof module[key] !== "function") refuse("missing-export", `${spec.kind} ${name} export ${key} isn't a function`); } if (module.interfaceVersion !== spec.interfaceVersion) { refuse("interface-version", `${spec.kind} ${name} implements interface ${module.interfaceVersion}, not ${spec.interfaceVersion}`); } return Object.freeze({ name, module }); } // True when packages/plugins///index.mjs exists in this tree as a real file, with no // symlink on the way down. The setters use it to refuse a name no module answers to before it is // written; the loader's own checks still run at load. export function hasModule(kind, name) { try { if (!Object.hasOwn(KINDS, kind) || !NAME.test(name)) return false; for (const dir of [ROOT, join(ROOT, kind), join(ROOT, kind, name)]) component(dir, true); component(join(ROOT, kind, name, "index.mjs"), false); return true; } catch { return false; } } export function createLoader({ log, barrier = () => {} }) { if (typeof log !== "function") throw new TypeError("createLoader needs a log callback"); const current = new Map(Object.keys(KINDS).map(kind => [kind, Object.freeze([])])); let queue = Promise.resolve(); function refused(kind, name, e, report) { if (!(e instanceof PluginError)) throw e; // kept: what stays switched on, for the kind or (whole setting refused) for every kind. const entry = { event: "plugin-refused", kind, name: e.plugin ?? name, code: e.code, message: e.message, kept: kind === null ? loaded() : current.has(kind) ? current.get(kind).map(m => m.name) : [], }; log(entry); report.push(entry); } async function apply(plugins) { const report = []; // A malformed whole setting refuses every kind; an unknown kind refuses only itself. if (plugins === null || typeof plugins !== "object" || Array.isArray(plugins)) { refused(null, null, new PluginError("bad-setting", "plugins must be an object with one key per kind"), report); return { loaded: loaded(), refused: report }; } for (const kind of Object.keys(plugins)) { if (!Object.hasOwn(KINDS, kind)) { refused(kind, null, new PluginError("unknown-kind", `plugins names unknown kind ${JSON.stringify(kind)}`), report); } } for (const [kind, spec] of Object.entries(KINDS)) { // An absent key switches the kind off. let names = []; try { if (Object.hasOwn(plugins, kind)) names = namesFor(spec, plugins[kind], `plugins.${kind}`, refuse); } catch (e) { refused(kind, typeof plugins[kind] === "string" ? plugins[kind] : null, e, report); continue; } const next = []; let ok = true; for (const name of names) { try { next.push(await loadModule(spec, name, barrier)); } catch (e) { refused(kind, name, e, report); ok = false; break; } } if (ok) current.set(kind, Object.freeze(next)); } return { loaded: loaded(), refused: report }; } // Loads run one at a time, so a kind's switch never interleaves. function load(plugins) { const run = queue.then(() => apply(plugins)); queue = run.catch(() => {}); return run; } function loaded() { return Object.fromEntries([...current].map(([kind, modules]) => [kind, modules.map(m => m.name)])); } function modules(kind) { if (!current.has(kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`); return current.get(kind).map(m => m.module); } // Capture names and modules together, without exposing mutable state or a dispatch lock. function snapshot(kind) { if (!current.has(kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`); return current.get(kind); } return { load, loaded, modules, snapshot }; }