import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; /** * Publish-time gate on a module build command's filesystem references. * * A `build:` block runs when the module is packaged (publish/package/import), * inside a staged copy of the module, with `CELILO_MODULE_SOURCE_DIR` pointing * back at the authored source tree. A `cd` in that command is the build's * claim about where its inputs live; a target that does not resolve there * means the module ships inputs it does not carry, which is how celilo-registry * was unbuildable on every real install for four months without anyone finding * out (celilo#1307). The packager refuses up front and names the path. * * The gate does not interpret shell. It finds `cd` commands lexically, knows * the two variables the packager supplies, and probes what those resolve to. * A target it cannot resolve (an untracked shell variable, a command * substitution, a glob) is SKIPPED, not failed: guessing would reject working * modules, and a guard that rejects working modules is worse than none. */ export interface BuildPathViolation { /** The path exactly as the build command wrote it, before substitution. */ rawPath: string; /** The absolute path the packaging runtime would resolve it to. */ resolvedPath: string; } export interface ResolveBuildCommandPathsOptions { /** What `$CELILO_MODULE_SOURCE_DIR` resolves to when the command runs. */ moduleSourceDir: string; /** The staged tree the command runs in (its initial cwd, and `$STAGE`). */ buildDir: string; /** Injectable existence probe; defaults to existsSync. */ exists?: (path: string) => boolean; } /** * Extract the argument of every unquoted `cd` command in the string, with the * quoting preserved so later substitution can strip it. * * A `cd` counts only at a command position: string start, or after `&&`, `||`, * `;`, `|`, `(`, or a newline, ignoring whitespace. Quoted spans (single and * double) are skipped whole, so a `cd` inside a string is not mistaken for a * command. */ export function findCdTargets(command: string): string[] { const targets: string[] = []; let atCommandStart = true; let i = 0; while (i < command.length) { const ch = command[i]; if (ch === "'") { const end = command.indexOf("'", i + 1); i = end === -1 ? command.length : end + 1; atCommandStart = false; continue; } if (ch === '"') { const end = command.indexOf('"', i + 1); i = end === -1 ? command.length : end + 1; atCommandStart = false; continue; } if (ch === '\\' && i + 1 < command.length) { i += 2; atCommandStart = false; continue; } if (ch === '&' || ch === '|' || ch === ';' || ch === '\n' || ch === '(') { atCommandStart = true; i += 1; continue; } if (/\s/.test(ch)) { i += 1; continue; } if (atCommandStart && command.startsWith('cd', i) && /\s/.test(command[i + 2] ?? '')) { const { target, next } = readCdTarget(command, i + 2); if (target.length > 0) targets.push(target); i = next; atCommandStart = false; continue; } atCommandStart = false; i += 1; } return targets; } /** * Read one `cd` argument starting just after the `cd` keyword. Stops at an * unquoted command terminator. Backslash escapes are carried through so the * caller sees exactly what the shell would. */ function readCdTarget(command: string, start: number): { target: string; next: number } { let i = start; while (i < command.length && /\s/.test(command[i])) i += 1; const begin = i; let end = i; while (i < command.length) { const ch = command[i]; if (ch === "'") { const close = command.indexOf("'", i + 1); i = close === -1 ? command.length : close + 1; end = i; continue; } if (ch === '"') { const close = command.indexOf('"', i + 1); i = close === -1 ? command.length : close + 1; end = i; continue; } if (ch === '\\' && i + 1 < command.length) { i += 2; end = i; continue; } if (ch === '&' || ch === '|' || ch === ';' || ch === '\n') break; i += 1; if (!/\s/.test(ch) || command.slice(begin, i).trim().length > 0) end = i; } return { target: command.slice(begin, end).trim(), next: i }; } /** * Substitute the packager-supplied variables, strip quoting, and drop the * target to `null` when anything unresolvable remains. `null` means "this * gate cannot answer whether this path exists", never "it does not exist". */ function resolveTarget(target: string, options: ResolveBuildCommandPathsOptions): string | null { const substituted = target .replaceAll('$CELILO_MODULE_SOURCE_DIR', options.moduleSourceDir) .replaceAll('$(pwd)', options.buildDir) .replaceAll('$STAGE', options.buildDir) .replaceAll('"', '') .replaceAll("'", ''); if (/[$`*?~[(]/.test(substituted)) return null; if (substituted.startsWith('-')) return null; // `cd -` / flags, not a path return substituted; } export function resolveBuildCommandPaths( command: string, options: ResolveBuildCommandPathsOptions, ): BuildPathViolation[] { const exists = options.exists ?? existsSync; const violations: BuildPathViolation[] = []; // The simulated working directory of the build. `null` means unknown: the // previous cd could not be resolved, so a later relative cd cannot be either. let cwd: string | null = options.buildDir; for (const rawTarget of findCdTargets(command)) { const target = resolveTarget(rawTarget, options); if (target === null) { cwd = null; continue; } const resolved: string | null = target.startsWith('/') ? target : cwd === null ? null : resolve(cwd, target); if (resolved === null) { cwd = null; continue; } if (!exists(resolved)) { violations.push({ rawPath: rawTarget, resolvedPath: resolved }); } cwd = resolved; } return violations; }