import { invalid } from "../errors"; import type { Param } from "./vocabulary"; /** * Validate caller-supplied values against the vocabulary table. * * Default-deny throughout: an unknown operation, an unknown parameter, or a value of the wrong shape * is refused. Nothing is coerced and nothing is silently dropped — a typo'd parameter that was * quietly ignored would look exactly like an option that does not work. * * Error messages name the exact path (`pipeline[1].resize.width`) and say what was expected, because * the thing on the other end is usually relaying them to a person. */ const ID_PATTERN = /^as_[0-9a-f]{32}$/; /** sharp accepts a CSS colour string or {r,g,b,alpha}. Both are data — neither can reference a file. */ function validateColor(value: unknown, path: string): unknown { if (typeof value === "string") { if (value.length > 64) throw invalid("Colour string is too long", path); return value; } if (value && typeof value === "object" && !Array.isArray(value)) { const obj = value as Record; for (const key of Object.keys(obj)) { if (!["r", "g", "b", "alpha"].includes(key)) { throw invalid(`Unknown colour component ${JSON.stringify(key)}. Expected r, g, b or alpha.`, path); } const component = obj[key]; if (typeof component !== "number" || !Number.isFinite(component)) { throw invalid(`Colour component ${key} must be a number`, `${path}.${key}`); } } return obj; } throw invalid("Expected a colour: a CSS string like '#ffffff', or {r, g, b, alpha}", path); } export function validateValue(spec: Param, value: unknown, path: string): unknown { switch (spec.kind) { case "int": { if (typeof value !== "number" || !Number.isInteger(value)) { throw invalid(`${path} must be an integer`, path); } if (value < spec.min || value > spec.max) { throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path); } return value; } case "number": { if (typeof value !== "number" || !Number.isFinite(value)) { throw invalid(`${path} must be a number`, path); } if (value < spec.min || value > spec.max) { throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path); } return value; } case "bool": { if (typeof value !== "boolean") throw invalid(`${path} must be true or false`, path); return value; } case "enum": { if (typeof value !== "string" || !spec.values.includes(value)) { throw invalid(`${path} must be one of: ${spec.values.join(", ")}`, path); } return value; } case "color": return validateColor(value, path); case "numberArray": { if (!Array.isArray(value)) throw invalid(`${path} must be an array of numbers`, path); if (value.length < spec.minLength || value.length > spec.maxLength) { throw invalid( `${path} must have between ${spec.minLength} and ${spec.maxLength} entries, got ${value.length}`, path, ); } value.forEach((entry, i) => { if (typeof entry !== "number" || !Number.isFinite(entry)) { throw invalid(`${path}[${i}] must be a number`, `${path}[${i}]`); } if (entry < spec.min || entry > spec.max) { throw invalid(`${path}[${i}] must be between ${spec.min} and ${spec.max}`, `${path}[${i}]`); } }); return value; } case "assetId": { // The ONLY way a caller may reference another file. Checked here against the same pattern the // store issues, then resolved to a path by the store and nowhere else. See docs/SECURITY.md. if (typeof value !== "string" || !ID_PATTERN.test(value)) { throw invalid(`${path} must be an asset id (as_ followed by 32 hex characters)`, path); } return value; } case "string": { if (typeof value !== "string") throw invalid(`${path} must be a string`, path); if (value.length > spec.maxLength) { throw invalid(`${path} must be at most ${spec.maxLength} characters`, path); } return value; } } } /** Validate an options object against a table of parameter specs. Unknown keys are refused. */ export function validateObject( params: Record, value: unknown, path: string, ): Record { if (value === undefined || value === null) return {}; if (typeof value !== "object" || Array.isArray(value)) { throw invalid(`${path} must be an object`, path); } const input = value as Record; const out: Record = {}; for (const [key, raw] of Object.entries(input)) { const spec = params[key]; if (!spec) { const known = Object.keys(params); throw invalid( known.length ? `Unknown option ${JSON.stringify(key)}. Accepted here: ${known.join(", ")}` : `${path} takes no options, got ${JSON.stringify(key)}`, `${path}.${key}`, ); } if (raw === undefined) continue; out[key] = validateValue(spec, raw, `${path}.${key}`); } return out; }