// 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//index.mjs, through the loader; // installed a PL1 hash-pinned plug-in of kind starter-pack, through the same loader; // saved /templates//.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 /templates//.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 }) }; }