Files
jason.woltje 755ba3fa8b fix(plugins): starter-pack re-check round (#1873 SP1)
Recognise only a native promise from starterPack() and attach the catch
through Promise.prototype.then, so an installed pack's own then or then
getter never runs; such a pack is omitted and logged again instead of
failing the listing. A link count of 0 after the open reports the file
changed, not a hard link. Tests: a throwing then getter, a self-resolving
thenable, and a 0000 file refusing as unreadable.
2026-11-12 15:38:01 -06:00

483 lines
27 KiB
JavaScript

// Starter packs, slice SP1 (#1873): the pack kind's checker and its three readers. Design:
// starter-packs-design section 2 and SP1 in section 8.
//
// A pack is a declarative document that lists definitions, hires, a board style, workflows and
// skills to copy into an organization or a project. It grants nothing and never stays switched on.
// Three sources, each with the origin its reader sets (a closed set, never a field the document
// supplies):
// shipped a core module packages/plugins/starter-pack/<name>/index.mjs, through the loader;
// installed a PL1 hash-pinned plug-in of kind starter-pack, through the same loader;
// saved <configDir>/templates/<account>/<name>.json, read as data, never imported.
// Origin `saved` relaxes exactly two rules: it may carry `packs`, and a definition may have no
// `from`. Every other check runs for every origin.
//
// A refusal names a position (a key, an index) and names that already passed a strict pattern,
// never a value from the document and never a host path.
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { types } from "node:util";
import { ENTRY_KEYS, checkDefinition, readTemplate } from "../../organization/src/definitions.mjs";
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { ID_PATTERN, NETWORKS } from "../../organization/src/vocabulary.mjs";
import { NAME, PluginError } from "./kinds.mjs";
import { hasModule, loadInstalled } from "./loader.mjs";
export const KIND = "starter-pack";
export const PACK_VERSION = 1;
export const ORIGINS = Object.freeze(["shipped", "installed", "saved"]);
// The task tracker's five states. The same list as STORE_STATES in packages/bus/src/broker.mjs;
// plugins does not import the bus, so the test pins the two together.
export const STORE_STATES = Object.freeze(["todo", "in-progress", "in-review", "blocked", "done"]);
export const LAYOUTS = Object.freeze(["kanban", "list"]);
export const HUMAN_STEP = "human";
export const PACK_BOUNDS = Object.freeze({
hires: 8, reason: 500, label: 64, description: 1000, columnLabel: 32,
steps: 20, stepTitle: 120, stepRoles: 8, workflowDescription: 1000,
skills: 64, packs: 32, version: 64,
});
export const SAVED_MAX_BYTES = 256 * 1024;
const TOP_KEYS = Object.freeze(["packVersion", "id", "version", "label", "description", "scope", "definitions",
"hires", "board", "workflows", "skills"]);
const SAVED_KEYS = Object.freeze([...TOP_KEYS, "packs"]);
const SCOPES = Object.freeze(["org", "project"]);
const REF_KEYS = Object.freeze(["template", "sha256"]);
// A pack definition is a T1 entry without rev; apply sets rev 1.
const PACK_ENTRY_KEYS = Object.freeze(ENTRY_KEYS.filter((key) => key !== "rev"));
const HIRE_KEYS = Object.freeze(["name", "definition", "reason"]);
const BOARD_KEYS = Object.freeze(["columns", "layout"]);
const COLUMN_KEYS = Object.freeze(["state", "label"]);
const WORKFLOW_KEYS = Object.freeze(["description", "steps"]);
const STEP_KEYS = Object.freeze(["id", "title", "roles", "state"]);
const PACKS_ENTRY_KEYS = Object.freeze(["id", "version"]);
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
const HEX64 = /^[0-9a-f]{64}$/;
// Semantic Versioning 2.0.0, the pattern semver.org gives.
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
// The shipped roles, as organization-service.mjs resolves them.
const ROLES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../../roles");
const fail = (code, message, name = null) => {
throw new PluginError(code, message, name);
};
const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
const isSemver = (value) => typeof value === "string" && value.length <= PACK_BOUNDS.version && SEMVER.test(value);
// Non-blank, one line, no NUL, at most max characters.
const lineText = (value, max) => typeof value === "string" && value.trim().length > 0 && value.length <= max
&& !/[\r\n\0]/.test(value);
// ---- Narrowing ----
function credentialLines(credentials) {
const out = [];
for (const { service, scopes } of credentials ?? []) {
if (scopes === undefined) out.push([service, service]);
else if (Array.isArray(scopes)) for (const scope of scopes) out.push([service, scope]);
else for (const [group, verbs] of Object.entries(scopes)) for (const verb of verbs) out.push([service, `${group}.${verb}`]);
}
return out;
}
const listOf = (value) => (Array.isArray(value) ? value : []);
// Every line of `role` its template does not hold, as [{ field, value }], in the order
// checkComposition's step 7 reads them. A within-role action is held only within-role; a
// cross-role one either way. A template with no messaging key lets the role message anyone; a role
// with no messaging key against a template that has one is wider ("*"). `role` is a composition
// (checkDefinition's result, or a T1 role); `template` is a version 2 role document.
export function widerLines(role, template) {
const out = [];
const add = (field, value) => out.push({ field, value });
const within = listOf(template.authority?.withinRole);
const any = [...within, ...listOf(template.authority?.crossRole)];
listOf(role.authority?.withinRole).forEach((a) => { if (!within.includes(a)) add("authority.withinRole", a); });
listOf(role.authority?.crossRole).forEach((a) => { if (!any.includes(a)) add("authority.crossRole", a); });
const tools = listOf(template.tools);
listOf(role.tools).forEach((t) => { if (!tools.includes(t)) add("tools", t); });
const network = NETWORKS.indexOf(role.network);
if (network === -1 || network > NETWORKS.indexOf(template.network)) add("network", role.network);
const held = new Set(credentialLines(template.credentials).map(([s, v]) => `${s}\n${v}`));
for (const [service, value] of credentialLines(role.credentials)) {
if (!held.has(`${service}\n${value}`)) add(`credentials.${service}`, value);
}
const templateMessaging = template.messaging ?? null;
const roleMessaging = role.messaging ?? null;
if (templateMessaging) {
for (const key of MESSAGING_KEYS) {
if (roleMessaging === null) add(`messaging.${key}`, "*");
else listOf(roleMessaging[key]).forEach((t) => { if (!listOf(templateMessaging[key]).includes(t)) add(`messaging.${key}`, t); });
}
}
const skills = listOf(template.skills);
listOf(role.skills).forEach((s) => { if (!skills.includes(s)) add("skills", s); });
return out;
}
// True when every tool, action, skill, messaging target and credential request line of `role` is
// in `template`, and its network is no wider: "no grant above the templates".
export function narrowsTemplate(role, template) {
return widerLines(role, template).length === 0;
}
// ---- The checker ----
function keysOnly(object, allowed, where, id) {
Object.keys(object).forEach((key, i) => {
if (!allowed.includes(key)) fail("starter-pack-invalid", `${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`, id);
});
}
function requireKeys(object, required, where, id) {
for (const key of required) {
if (!Object.hasOwn(object, key)) fail("starter-pack-invalid", `${where} requires "${key}"`, id);
}
}
// One template, read now: { role, sha256 } or null when the name is no usable version 2 template.
function templateOrNull(rolesDir, name) {
if (typeof name !== "string" || !ID_PATTERN.test(name)) return null;
try {
return readTemplate(rolesDir, name);
} catch (error) {
if (error?.name === "BusinessError") return null;
throw error;
}
}
function checkDefinitions(definitions, { origin, rolesDir, id }) {
if (!plain(definitions)) fail("starter-pack-invalid", "starter pack definitions must be a JSON object", id);
const templates = new Map();
const template = (name) => {
if (!templates.has(name)) templates.set(name, templateOrNull(rolesDir, name));
return templates.get(name);
};
Object.keys(definitions).forEach((name, i) => {
if (!ID_PATTERN.test(name)) fail("starter-pack-invalid", `starter pack definitions key ${i + 1} must be a definition name matching ${ID_PATTERN}`, id);
const at = `starter pack definitions.${name}`;
const entry = definitions[name];
if (!plain(entry)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
const keys = Object.keys(entry);
if (keys.length === REF_KEYS.length && REF_KEYS.every((k) => keys.includes(k))) {
// A template reference: the whole template, pinned by its hash.
if (typeof entry.template !== "string" || !ID_PATTERN.test(entry.template)) fail("starter-pack-invalid", `${at}.template must be a template name matching ${ID_PATTERN}`, id);
if (typeof entry.sha256 !== "string" || !HEX64.test(entry.sha256)) fail("starter-pack-invalid", `${at}.sha256 must be 64 lowercase hex characters`, id);
const t = template(entry.template);
if (!t) fail("starter-pack-invalid", `${at} names template ${entry.template}, which is not a version 2 role`, id);
if (t.sha256 !== entry.sha256) fail("starter-pack-template-changed", `${at} pins template ${entry.template} at a hash it no longer has`, id);
return;
}
// A full entry in the T1 shape, without rev.
keysOnly(entry, PACK_ENTRY_KEYS, at, id);
let composition;
try {
({ composition } = checkDefinition(name, { ...entry, rev: 1 }, { where: "starter pack definitions" }));
} catch (error) {
// definitions.mjs and composition.mjs quote only names that passed a strict pattern.
if (error?.name === "BusinessError") fail("starter-pack-invalid", error.message, id);
throw error;
}
if (entry.from === undefined) {
if (origin !== "saved") fail("starter-pack-invalid", `${at} has no "from"; only a saved template may carry a role that copies no template`, id);
return;
}
const t = template(entry.from.template);
if (!t) fail("starter-pack-invalid", `${at}.from names template ${entry.from.template}, which is not a version 2 role`, id);
if (t.sha256 !== entry.from.sha256) fail("starter-pack-template-changed", `${at}.from pins template ${entry.from.template} at a hash it no longer has`, id);
const wider = widerLines(composition, t.role);
if (wider.length) {
const fields = [...new Set(wider.map((l) => l.field))].join(", ");
fail("starter-pack-invalid", `${at}.role is wider than template ${entry.from.template} (${fields})`, id);
}
});
}
function checkHires(hires, definitions, id) {
if (!Array.isArray(hires)) fail("starter-pack-invalid", "starter pack hires must be an array", id);
if (hires.length > PACK_BOUNDS.hires) fail("starter-pack-invalid", `starter pack hires must hold at most ${PACK_BOUNDS.hires} entries`, id);
const names = new Set();
hires.forEach((hire, i) => {
const at = `starter pack hires[${i}]`;
if (!plain(hire)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
// No level and no route: a hire is org-level in an org pack, project-level in a project pack,
// never root, and the raiser supplies the route.
keysOnly(hire, HIRE_KEYS, at, id);
requireKeys(hire, HIRE_KEYS, at, id);
if (typeof hire.name !== "string" || !ID_PATTERN.test(hire.name)) fail("starter-pack-invalid", `${at}.name must match ${ID_PATTERN}`, id);
if (names.has(hire.name)) fail("starter-pack-invalid", `${at}.name repeats an earlier hire`, id);
names.add(hire.name);
if (typeof hire.definition !== "string" || !Object.hasOwn(definitions, hire.definition)) {
fail("starter-pack-invalid", `${at}.definition must name a definition in this pack`, id);
}
if (typeof hire.reason !== "string" || hire.reason.trim().length === 0 || hire.reason.length > PACK_BOUNDS.reason || hire.reason.includes("\0")) {
fail("starter-pack-invalid", `${at}.reason must be a non-blank string of at most ${PACK_BOUNDS.reason} characters`, id);
}
});
}
function checkBoard(board, id) {
if (!plain(board)) fail("starter-pack-invalid", "starter pack board must be a JSON object", id);
keysOnly(board, BOARD_KEYS, "starter pack board", id);
requireKeys(board, BOARD_KEYS, "starter pack board", id);
if (!LAYOUTS.includes(board.layout)) fail("starter-pack-invalid", `starter pack board.layout must be one of ${LAYOUTS.join(", ")}`, id);
const columns = board.columns;
if (!Array.isArray(columns) || columns.length !== STORE_STATES.length) {
fail("starter-pack-invalid", `starter pack board.columns must hold the ${STORE_STATES.length} task states, each once`, id);
}
const seen = new Set();
columns.forEach((column, i) => {
const at = `starter pack board.columns[${i}]`;
if (!plain(column)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
keysOnly(column, COLUMN_KEYS, at, id);
requireKeys(column, COLUMN_KEYS, at, id);
if (!STORE_STATES.includes(column.state)) fail("starter-pack-invalid", `${at}.state must be one of ${STORE_STATES.join(", ")}`, id);
if (seen.has(column.state)) fail("starter-pack-invalid", `${at}.state ${column.state} repeats an earlier column`, id);
seen.add(column.state);
if (!lineText(column.label, PACK_BOUNDS.columnLabel)) fail("starter-pack-invalid", `${at}.label must be one line of 1 to ${PACK_BOUNDS.columnLabel} characters`, id);
});
}
function checkWorkflows(workflows, definitions, { rolesDir, id }) {
if (!plain(workflows)) fail("starter-pack-invalid", "starter pack workflows must be a JSON object", id);
const templates = new Map();
const isTemplate = (name) => {
if (!templates.has(name)) templates.set(name, templateOrNull(rolesDir, name) !== null);
return templates.get(name);
};
Object.keys(workflows).forEach((name, i) => {
if (!NAME.test(name)) fail("starter-pack-invalid", `starter pack workflows key ${i + 1} must match ${NAME}`, id);
const at = `starter pack workflows.${name}`;
const workflow = workflows[name];
if (!plain(workflow)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
keysOnly(workflow, WORKFLOW_KEYS, at, id);
requireKeys(workflow, WORKFLOW_KEYS, at, id);
if (typeof workflow.description !== "string" || workflow.description.trim().length === 0
|| workflow.description.length > PACK_BOUNDS.workflowDescription || workflow.description.includes("\0")) {
fail("starter-pack-invalid", `${at}.description must be a non-blank string of at most ${PACK_BOUNDS.workflowDescription} characters`, id);
}
const steps = workflow.steps;
if (!Array.isArray(steps) || steps.length < 1 || steps.length > PACK_BOUNDS.steps) {
fail("starter-pack-invalid", `${at}.steps must hold 1 to ${PACK_BOUNDS.steps} steps`, id);
}
const ids = new Set();
steps.forEach((step, j) => {
const where = `${at}.steps[${j}]`;
if (!plain(step)) fail("starter-pack-invalid", `${where} must be a JSON object`, id);
keysOnly(step, STEP_KEYS, where, id);
requireKeys(step, STEP_KEYS, where, id);
if (typeof step.id !== "string" || !NAME.test(step.id)) fail("starter-pack-invalid", `${where}.id must match ${NAME}`, id);
if (ids.has(step.id)) fail("starter-pack-invalid", `${where}.id repeats an earlier step`, id);
ids.add(step.id);
if (!lineText(step.title, PACK_BOUNDS.stepTitle)) fail("starter-pack-invalid", `${where}.title must be one line of 1 to ${PACK_BOUNDS.stepTitle} characters`, id);
if (!STORE_STATES.includes(step.state)) fail("starter-pack-invalid", `${where}.state must be one of ${STORE_STATES.join(", ")}`, id);
const roles = step.roles;
if (!Array.isArray(roles) || roles.length < 1 || roles.length > PACK_BOUNDS.stepRoles) {
fail("starter-pack-invalid", `${where}.roles must hold 1 to ${PACK_BOUNDS.stepRoles} names`, id);
}
const seen = new Set();
roles.forEach((role, k) => {
const ok = typeof role === "string"
&& (role === HUMAN_STEP || Object.hasOwn(definitions, role) || isTemplate(role));
if (!ok) fail("starter-pack-invalid", `${where}.roles[${k}] must be a definition in this pack, a version 2 template or "${HUMAN_STEP}"`, id);
if (seen.has(role)) fail("starter-pack-invalid", `${where}.roles[${k}] repeats an earlier name`, id);
seen.add(role);
});
});
});
}
function checkNameList(value, where, max, id) {
if (!Array.isArray(value)) fail("starter-pack-invalid", `${where} must be an array`, id);
if (value.length > max) fail("starter-pack-invalid", `${where} must hold at most ${max} entries`, id);
const seen = new Set();
value.forEach((name, i) => {
if (typeof name !== "string" || !NAME.test(name)) fail("starter-pack-invalid", `${where}[${i}] must match ${NAME}`, id);
if (seen.has(name)) fail("starter-pack-invalid", `${where}[${i}] repeats an earlier entry`, id);
seen.add(name);
});
}
function checkPacksList(packs, id) {
if (!Array.isArray(packs)) fail("starter-pack-invalid", "starter pack packs must be an array", id);
if (packs.length > PACK_BOUNDS.packs) fail("starter-pack-invalid", `starter pack packs must hold at most ${PACK_BOUNDS.packs} entries`, id);
packs.forEach((entry, i) => {
const at = `starter pack packs[${i}]`;
if (!plain(entry)) fail("starter-pack-invalid", `${at} must be a JSON object`, id);
keysOnly(entry, PACKS_ENTRY_KEYS, at, id);
requireKeys(entry, PACKS_ENTRY_KEYS, at, id);
if (typeof entry.id !== "string" || !NAME.test(entry.id)) fail("starter-pack-invalid", `${at}.id must match ${NAME}`, id);
if (!isSemver(entry.version)) fail("starter-pack-invalid", `${at}.version must be a semantic version`, id);
});
}
// Checks one pack document for its origin and returns a checked copy; the input is not read again
// after the copy. options.rolesDir is the shipped roles directory (tests pass their own);
// options.name, for a module, is the module's name, which the pack's id must equal.
export function checkPack(doc, { origin, rolesDir = ROLES_DIR, name } = {}) {
if (!ORIGINS.includes(origin)) fail("starter-pack-invalid", `starter pack origin must be one of ${ORIGINS.join(", ")}`);
let pack;
try {
pack = JSON.parse(JSON.stringify(doc));
} catch {
fail("starter-pack-invalid", "starter pack must be a JSON document");
}
if (!plain(pack)) fail("starter-pack-invalid", "starter pack must be a JSON object");
const id = typeof pack.id === "string" && NAME.test(pack.id) ? pack.id : null;
keysOnly(pack, origin === "saved" ? SAVED_KEYS : TOP_KEYS, "starter pack", id);
requireKeys(pack, TOP_KEYS, "starter pack", id);
if (pack.packVersion !== PACK_VERSION) fail("starter-pack-invalid", `starter pack packVersion must be ${PACK_VERSION}`, id);
if (id === null) fail("starter-pack-invalid", `starter pack id must match ${NAME}`);
if (name !== undefined && id !== name) fail("starter-pack-invalid", `starter pack id must equal its module name ${name}`, id);
if (!isSemver(pack.version)) fail("starter-pack-invalid", "starter pack version must be a semantic version", id);
if (!lineText(pack.label, PACK_BOUNDS.label)) fail("starter-pack-invalid", `starter pack label must be one line of 1 to ${PACK_BOUNDS.label} characters`, id);
if (!lineText(pack.description, PACK_BOUNDS.description)) fail("starter-pack-invalid", `starter pack description must be one line of 1 to ${PACK_BOUNDS.description} characters`, id);
if (!SCOPES.includes(pack.scope)) fail("starter-pack-invalid", `starter pack scope must be one of ${SCOPES.join(", ")}`, id);
checkDefinitions(pack.definitions, { origin, rolesDir, id });
checkHires(pack.hires, pack.definitions, id);
checkBoard(pack.board, id);
checkWorkflows(pack.workflows, pack.definitions, { rolesDir, id });
checkNameList(pack.skills, "starter pack skills", PACK_BOUNDS.skills, id);
if (Object.hasOwn(pack, "packs")) checkPacksList(pack.packs, id);
return pack;
}
// ---- Shipped and installed packs ----
// Lists shipped and installed packs through the loader, each checked with the origin of where it
// came from. A refused core pack fails the listing, as system types do. A refused installed pack is
// omitted and logged. Returns { packs: [{ id, origin, pack }], refused }.
export async function installedStarterPacks({ dataRoot = null, rolesDir = ROLES_DIR, log = () => {} } = {}) {
const { modules, refused: loadRefused } = await loadInstalled(KIND, { dataRoot });
const refused = [];
const omit = (name, error) => {
const entry = { event: "starter-pack-refused", kind: KIND, name, code: error.code, message: error.message };
log(entry);
refused.push(entry);
};
for (const entry of loadRefused) {
// A refusal with no name, or under a core module's name, is the core tree: fail closed.
if (entry.name === null || hasModule(KIND, entry.name)) {
fail("starter-pack-invalid", "a shipped starter pack was refused", entry.name);
}
omit(entry.name, entry);
}
const packs = [];
for (const { name, module, installed } of modules) {
const origin = installed ? "installed" : "shipped";
try {
let doc;
try {
doc = module.starterPack();
} catch {
fail("starter-pack-invalid", `starter pack ${name} failed to describe itself`, name);
}
// A pack is returned, never awaited: a promise refuses below, and a later rejection of it
// must not reach the process as an unhandled one. Only a native promise is recognised, and
// the built-in then is used, so the module's own then or getter never runs here; any other
// object goes to checkPack, whose closed key sets refuse it.
if (types.isPromise(doc)) {
Promise.prototype.then.call(doc, undefined, () => {});
fail("starter-pack-invalid", `starter pack ${name} must return its document, not a promise`, name);
}
packs.push({ id: name, origin, pack: checkPack(doc, { origin, rolesDir, name }) });
} catch (error) {
if (!(error instanceof PluginError)) throw error;
if (origin === "shipped") throw error;
omit(name, error);
}
}
return { packs, refused };
}
// ---- Saved templates ----
function lstatOrNull(path) {
try {
return lstatSync(path);
} catch (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
fail("starter-pack-invalid", "the saved template can't be read");
}
}
// A directory on the way to a saved template: a real directory owned by the instance's user that
// no group or other user can write. `private` also refuses group and other read and list, for the
// account's own directory, which the writer creates 0700.
function checkDirectory(path, uid, { private: closed = false } = {}) {
const st = lstatOrNull(path);
if (st === null) fail("starter-pack-not-found", "no saved template has that name");
if (st.isSymbolicLink() || !st.isDirectory()) fail("starter-pack-invalid", "the saved templates directory must be a real directory");
if (st.uid !== uid) fail("starter-pack-invalid", "the saved templates directory must belong to the instance's user");
if ((st.mode & 0o022) !== 0) fail("starter-pack-invalid", "the saved templates directory must not be writable by group or others");
if (closed && (st.mode & 0o077) !== 0) fail("starter-pack-invalid", "the account's saved templates directory must be mode 0700 or narrower");
}
// Reads <configDir>/templates/<account>/<name>.json as data and checks it with origin `saved`. The
// account is the calling owner's id (s.human), checked against ID_PATTERN; the name is checked
// against NAME. Both are checked before any path is built, so neither ".." nor "/" reaches the file
// system. The account directory must be mode 0700 or narrower. The file must be a regular file, not
// a symlink or a hard link, owned by the instance's user, mode 0600 or narrower, at most 256 KiB,
// checked before the read. Returns { name, origin: "saved", pack }.
export function readSavedTemplate({ configDir, account, name, rolesDir = ROLES_DIR, uid = process.getuid() } = {}) {
if (typeof account !== "string" || !ID_PATTERN.test(account)) fail("starter-pack-invalid", `the saved template account must match ${ID_PATTERN}`);
if (typeof name !== "string" || !NAME.test(name)) fail("starter-pack-invalid", `the saved template name must match ${NAME}`);
if (typeof configDir !== "string" || !isAbsolute(configDir)) fail("starter-pack-invalid", "the configuration directory must be an absolute path");
const templates = join(configDir, "templates");
const accountDir = join(templates, account);
checkDirectory(templates, uid);
checkDirectory(accountDir, uid, { private: true });
const file = join(accountDir, `${name}.json`);
const before = lstatOrNull(file);
if (before === null) fail("starter-pack-not-found", "no saved template has that name", name);
if (before.isSymbolicLink() || !before.isFile()) fail("starter-pack-invalid", "the saved template must be a regular file, not a symlink", name);
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (error) {
if (error.code === "ENOENT") fail("starter-pack-not-found", "no saved template has that name", name);
if (error.code === "ELOOP") fail("starter-pack-invalid", "the saved template must be a regular file, not a symlink", name);
fail("starter-pack-invalid", "the saved template can't be read", name);
}
let bytes;
try {
const st = fstatSync(fd);
if (!st.isFile() || st.dev !== before.dev || st.ino !== before.ino) fail("starter-pack-invalid", "the saved template changed while it was read", name);
if (st.uid !== uid) fail("starter-pack-invalid", "the saved template must belong to the instance's user", name);
if (st.nlink === 0) fail("starter-pack-invalid", "the saved template changed while it was read", name);
if (st.nlink !== 1) fail("starter-pack-invalid", "the saved template must not be a hard link", name);
if ((st.mode & 0o077) !== 0) fail("starter-pack-invalid", "the saved template must be mode 0600 or narrower", name);
if (st.size > SAVED_MAX_BYTES) fail("starter-pack-invalid", `the saved template must be at most ${SAVED_MAX_BYTES} bytes`, name);
// Read at most one byte past the limit, so a file that grew after the fstat still refuses.
const buffer = Buffer.alloc(SAVED_MAX_BYTES + 1);
let length = 0;
for (;;) {
const n = readSync(fd, buffer, length, buffer.length - length, null);
if (n === 0) break;
length += n;
if (length > SAVED_MAX_BYTES) fail("starter-pack-invalid", `the saved template grew past ${SAVED_MAX_BYTES} bytes while it was read`, name);
}
bytes = buffer.subarray(0, length);
} finally {
closeSync(fd);
}
let text;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
fail("starter-pack-invalid", "the saved template is not valid UTF-8", name);
}
let doc;
try {
doc = parseJsonFileText(text, file, "saved template");
} catch {
fail("starter-pack-invalid", "the saved template is not valid JSON", name);
}
return { name, origin: "saved", pack: checkPack(doc, { origin: "saved", rolesDir }) };
}