clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix/175-template-quality-pass
lofi / package / testing / safety.ts
safety.ts 3.9K
1/**2 * Value-free failure-artifact safety: the {@link ValueFreeState} shape rule for3 * state snapshots, its {@link assertValueFreeState} validator, and the4 * {@link redactDiagnosticText} credential redaction applied to retained5 * diagnostics.6 *7 * @module8 */910/**11 * A JSON-like value restricted to booleans, finite numbers, arrays, and plain12 * objects. Strings are excluded so state snapshots record shape and counts but13 * never user values or credentials.14 */15export type ValueFreeState =16 | null17 | boolean18 | number19 | { readonly [key: string]: ValueFreeState }20 | readonly ValueFreeState[];2122const JSON_SECRET_ASSIGNMENT =23 /(\"(?:password|passwd|secret|token|authorization|cookie|credential|session)\"\s*:\s*)\"(?:\\.|[^\"])*\"/gi;24const SECRET_ASSIGNMENT =25 /\b(password|passwd|secret|token|authorization|cookie|credential|session)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']+)/gi;26const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;27const URL_VALUE = /https?:\/\/[^\s"'<>]+/gi;2829function safeUrl(value: string): string {30 try {31 const url = new URL(value);32 url.username = "";33 url.password = "";34 url.search = "";35 url.hash = "";36 return url.toString();37 } catch {38 return value;39 }40}4142/**43 * Redact common credential forms from diagnostic text: URL userinfo, query,44 * and fragment components; JSON and `key=value` secret assignments; bearer45 * tokens; and every literal in `secretValues`. The fixtures apply this to all46 * retained diagnostics; apply it likewise to any custom capture path.47 *48 * @param value The diagnostic text to redact.49 * @param secretValues Literal credential values to remove wherever they appear.50 * @returns The text with credential forms replaced by `[redacted]` markers.51 */52export function redactDiagnosticText(53 value: string,54 secretValues: readonly string[] = [],55): string {56 let redacted = value57 .replaceAll(URL_VALUE, (url) => safeUrl(url))58 .replaceAll(JSON_SECRET_ASSIGNMENT, '$1"[redacted]"')59 .replaceAll(BEARER_VALUE, "Bearer [redacted]")60 .replaceAll(SECRET_ASSIGNMENT, (_match, key: string) => `${key}=[redacted]`);61 for (const secret of secretValues) {62 if (secret.length > 0) redacted = redacted.replaceAll(secret, "[redacted]");63 }64 return redacted;65}6667/**68 * Assert that `value` satisfies the value-free rule: strings and non-finite69 * numbers are rejected, so state artifacts record shape, counts, and booleans70 * but never user values or credentials. Snapshot callbacks passed to the71 * fixtures are validated with this at capture time; call it directly to72 * pre-validate a `snapshot` callback's output in a fast test.73 *74 * @param value The candidate snapshot value.75 * @param path The label used for the offending location in error messages.76 * @throws {TypeError} When `value` contains a string or an unsupported value.77 */78export function assertValueFreeState(79 value: unknown,80 path = "state",81): asserts value is ValueFreeState {82 if (value === null || typeof value === "boolean") return;83 if (typeof value === "number" && Number.isFinite(value)) return;84 if (typeof value === "string") {85 throw new TypeError(`${path} contains a string value; state artifacts must be value-free`);86 }87 if (Array.isArray(value)) {88 value.forEach((entry, index) => assertValueFreeState(entry, `${path}[${index}]`));89 return;90 }91 if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {92 for (const [key, entry] of Object.entries(value)) {93 assertValueFreeState(entry, `${path}.${key}`);94 }95 return;96 }97 throw new TypeError(`${path} contains an unsupported value`);98}99100/**101 * Normalize a label into a filesystem-safe artifact directory name, falling102 * back to `"failure"` when nothing usable remains.103 */104export function artifactName(value: string): string {105 const safe = value.toLowerCase().replaceAll(/[^a-z0-9._-]+/g, "-").replaceAll(/^-+|-+$/g, "");106 return safe || "failure";107}