// The library project entry of a business file (design 1.1): her folders mapped onto universes, // books, type folders, manuscript folders and playground prefixes at three levels (ruling after r11, // C2-01c): inside one of the entry's books, outside every book, or in a draft universe. Supporting // prefixes (C2-01d) follow the same rules and are where agents may write (M12). A pure validator. // Business file loading wires it in C2-04, which passes its own `fail` so the refusal reads as a // business file problem. Agents can't write this entry (M9, C2-04). import { raise } from './problems.mjs'; import { typeForm } from './scope.mjs'; export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']); const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/; // C2-03a (sage-2021): the bot account sync answers git's Username prompt with. Row 199's NAME. const USER = /^[A-Za-z0-9_.-]{1,100}$/; const CONSTRAINT_KEYS = ['exclude', 'pin']; const KINDS = ['hybrid', 'draft']; // C2-01d (F41): the editor ident her Console saves commit under. Git drops `<` and `>` from an ident // and commits under a different name, so they refuse here (webby-0169 R1). const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/; // Git's ident.c crud() strips any byte <= 0x20 and , : ; < > " \ ' from both ends of an ident, so a // value that starts or ends with one would save one name and commit another (sage-3601, sage-3606). const IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/; // A folder segment from her vault: spaces and capitals are fine, separators, control characters // and dot names are not. function segment(value) { return typeof value === 'string' && value.length > 0 && value.length <= 255 && value !== '.' && value !== '..' && !/[\/\\\u0000-\u001f\u007f]/.test(value) && value.trim() === value; } function folder(value) { return typeof value === 'string' && value.length <= 1024 && value.split('/').every(segment); } const holds = (outer, inner) => inner.startsWith(outer.endsWith('/') ? outer : `${outer}/`); export function checkMapping(entry, where = 'library', { fail = raise } = {}) { const bad = message => fail('library-mapping-invalid', `${where} ${message}`); const object = (value, name) => { if (value === null || typeof value !== 'object' || Array.isArray(value)) bad(`${name} must be an object`); }; const only = (value, keys, name) => { for (const key of Object.keys(value)) if (!keys.includes(key)) bad(`${name} has unknown key ${JSON.stringify(key)}`); }; const list = (value, name, check, what) => { if (!Array.isArray(value)) bad(`${name} must be a list`); for (const item of value) if (!check(item)) bad(`${name} holds ${JSON.stringify(item)}, which is not ${what}`); if (new Set(value).size !== value.length) bad(`${name} names an entry twice`); return [...value]; }; object(entry, ''); only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'editor', 'universes', 'types', 'manuscript'], 'entry'); if (entry.kind !== 'library') bad('kind must be "library"'); let url; try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); } if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || entry.remote.includes('\0')) bad('remote must be an https:// URL with no credentials, query or fragment'); if (typeof entry.branch !== 'string' || !BRANCH.test(entry.branch) || entry.branch.includes('..') || entry.branch.includes('//') || entry.branch.endsWith('/') || entry.branch.endsWith('.lock')) bad('branch must be a plain branch name'); if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user))) bad('user must be a plain account name'); if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name'); if (Object.hasOwn(entry, 'editor')) { object(entry.editor, 'editor'); only(entry.editor, ['name', 'email', 'user'], 'editor'); // Her Gitea login for the library-human slot, which C2-02 passes in (sage-2925). Optional here. if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' || !USER.test(entry.editor.user))) bad('editor.user must be a plain account name'); for (const key of ['name', 'email']) { const value = entry.editor[key]; if (typeof value !== 'string' || !value.trim() || IDENT_BAD.test(value)) bad(`editor.${key} must be non-empty text with no control character, < or >`); if (value.trim() !== value || IDENT_CRUD_END.test(value)) bad(`editor.${key} must not start or end with whitespace or any of , : ; " ' \\`); } } object(entry.types, 'types'); for (const [name, type] of Object.entries(entry.types)) { if (!segment(name)) bad(`types has ${JSON.stringify(name)}, which is not one folder name`); if (!TYPES.includes(type)) bad(`types.${name} must be one of ${TYPES.join(', ')}`); } const manuscript = list(entry.manuscript, 'manuscript', segment, 'one folder name'); for (const name of manuscript) if (Object.hasOwn(entry.types, name)) bad(`manuscript folder ${JSON.stringify(name)} is also a type folder`); object(entry.universes, 'universes'); if (!Object.keys(entry.universes).length) bad('universes must name at least one universe'); const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map(); const supports = [], supportOwner = new Map(); for (const [id, universe] of Object.entries(entry.universes)) { const name = `universes.${id}`; if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`); object(universe, name); if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`); const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft'; if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`); only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'supporting', 'constraints', 'notes'] : draft ? ['kind', 'name', 'playground', 'supporting'] : ['name', 'books', 'playground', 'supporting'], name); if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name)) bad(`${name}.name must be a short display name`); const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path'); if (!hybrid && !draft && !books.length) bad(`${name}.books must name at least one book folder`); for (const book of books) { if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`); bookOwner.set(book, id); } const prefix = value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)); const playground = list(universe.playground ?? [], `${name}.playground`, prefix, 'a folder path ending in /'); if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`); for (const p of playground) { prefixes.push(p); prefixOwner.set(p, id); } const supporting = list(universe.supporting ?? [], `${name}.supporting`, prefix, 'a folder path ending in /'); for (const p of supporting) { supports.push(p); supportOwner.set(p, id); } if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground, supporting }; continue; } const out = { name: universe.name, books, playground, supporting }; if (hybrid) { out.kind = 'hybrid'; out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id'); if (out.sources.length < 2) bad(`${name}.sources must name two or more universes`); if (!Array.isArray(universe.constraints ?? [])) bad(`${name}.constraints must be a list`); out.constraints = (universe.constraints ?? []).map((constraint, i) => { const at = `${name}.constraints[${i}]`; object(constraint, at); only(constraint, CONSTRAINT_KEYS, at); if (Object.keys(constraint).length !== 1) bad(`${at} must hold exactly one of ${CONSTRAINT_KEYS.join(', ')}`); if (Object.hasOwn(constraint, 'exclude')) { if (!folder(constraint.exclude)) bad(`${at}.exclude must be a source path`); return { exclude: constraint.exclude }; } object(constraint.pin, `${at}.pin`); only(constraint.pin, ['entity', 'field', 'value'], `${at}.pin`); if (!folder(constraint.pin.entity) || typeof constraint.pin.field !== 'string' || !constraint.pin.field || !['string', 'number', 'boolean'].includes(typeof constraint.pin.value)) bad(`${at}.pin needs an entity path, a field name and a scalar value`); return { pin: { ...constraint.pin } }; }); if (Object.hasOwn(universe, 'notes') && (typeof universe.notes !== 'string' || universe.notes.length > 4000)) bad(`${name}.notes must be text of at most 4000 characters`); if (Object.hasOwn(universe, 'notes')) out.notes = universe.notes; } universes[id] = out; } for (const [id, universe] of Object.entries(universes)) { for (const source of universe.sources ?? []) { if (!Object.hasOwn(universes, source) || Object.hasOwn(universes[source], 'kind')) bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a plain universe`); } } // C2-15 (design 1.4): each constraint against the hybrid's sources, now that every source is read. An // exclude reads as the type form `source/Type` or as a path in a source's book, never both. A path, or // a pinned entity, under a source's playground is one the hybrid never reads (REQ-LIB-3). Whether the // path exists at a commit is the reader's to check. for (const [id, universe] of Object.entries(universes)) { if (universe.kind !== 'hybrid') continue; const sources = universe.sources.map(source => [source, universes[source]]); const inSourceBook = path => sources.some(([, s]) => s.books.some(book => path === book || holds(book, path))); const underPlayground = path => sources.find(([, s]) => s.playground.some(p => path === p.slice(0, -1) || path.startsWith(p)))?.[0]; const sourcePath = (path, at) => { if (!inSourceBook(path)) bad(`${at} ${JSON.stringify(path)} lies inside no book of the hybrid's sources`); const owner = underPlayground(path); if (owner) bad(`${at} ${JSON.stringify(path)} lies under a playground of universes.${owner}, which the hybrid never reads`); }; universe.constraints.forEach((constraint, i) => { const at = `universes.${id}.constraints[${i}]`; if (constraint.pin) { if (!constraint.pin.entity.endsWith('.md')) bad(`${at}.pin.entity ${JSON.stringify(constraint.pin.entity)} is not a .md file`); return sourcePath(constraint.pin.entity, `${at}.pin.entity`); } const type = typeForm(entry, universe, constraint.exclude); if (type && inSourceBook(constraint.exclude)) bad(`${at}.exclude ${JSON.stringify(constraint.exclude)} reads as both a source/type and a source path`); if (!type) sourcePath(constraint.exclude, `${at}.exclude`); }); } const books = [...bookOwner.keys()]; for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`); for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`); if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice'); for (const a of supports) for (const b of supports) if (a !== b && holds(a, b)) bad(`supporting ${JSON.stringify(a)} holds supporting ${JSON.stringify(b)}`); if (new Set(supports).size !== supports.length) bad('a supporting prefix is listed twice'); // A prefix's level comes from where it sits (ruling after r11). Inside one of its own entry's books // is book level, outside every book is universe level, and a draft's prefixes are all outside. Scope // checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book // into playground or supporting. const againstBooks = (owners, list, code) => { for (const [prefix, id] of owners) { for (const [book, owner] of bookOwner) { const at = `universes.${id}.${list} ${JSON.stringify(prefix)}`; if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`); const inside = holds(book, prefix), holding = holds(prefix, book); if ((inside || holding) && owner !== id) fail(code, `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`); if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`); } } }; againstBooks(prefixOwner, 'playground', 'library-playground-other-universe'); againstBooks(supportOwner, 'supporting', 'library-supporting-other-universe'); // C2-01d, between the lists. A supporting prefix may equal a playground prefix of its own entry, and // a playground may hold one of its own entry's supporting prefixes. A supporting prefix that holds a // playground would hide it, since scope checks supporting first. Any overlap across entries would // give one path two universes. for (const [s, sid] of supportOwner) { for (const [p, pid] of prefixOwner) { const at = `universes.${sid}.supporting ${JSON.stringify(s)}`; if (sid !== pid && (s === p || holds(s, p) || holds(p, s))) bad(`${at} overlaps playground ${JSON.stringify(p)} of universes.${pid}`); if (sid === pid && s !== p && holds(s, p)) bad(`${at} holds playground ${JSON.stringify(p)}`); } } return deepFreeze({ kind: 'library', remote: entry.remote, branch: entry.branch, ...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}), ...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}), ...(Object.hasOwn(entry, 'editor') ? { editor: { name: entry.editor.name, email: entry.editor.email, ...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {}) } } : {}), universes, types: { ...entry.types }, manuscript, }); } function deepFreeze(value) { if (value && typeof value === 'object') { for (const item of Object.values(value)) deepFreeze(item); Object.freeze(value); } return value; }