diff --git a/scripts/test-local.mjs b/scripts/test-local.mjs index 8e058a1..7ee44eb 100644 --- a/scripts/test-local.mjs +++ b/scripts/test-local.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -331,9 +331,124 @@ async function runWarningTests() { } } +// Runs `callback` with console.warn captured (and silenced), returning the +// collected warning messages so tests can assert a warning was surfaced +// without polluting test output. +async function withCapturedWarnings(callback) { + const messages = []; + const originalWarn = console.warn; + console.warn = (...args) => messages.push(args.join(" ")); + + try { + await callback(); + } finally { + console.warn = originalWarn; + } + + return messages; +} + +async function runWarningRegistryHardeningTests() { + const validWarning = { + messageId: "CVE_SCHEMA_DEPRECATION", + notificationMessage: "This field will be removed in a future schema version.", + notificationDetails: "Use the replacement field before the warning end date.", + schemaPath: "#/definitions/example/properties/deprecatedField", + dateAdded: "2026-07-01T00:00:00.000Z", + dateUpdated: "2026-07-02T00:00:00.000Z", + dateStart: "2026-08-01T00:00:00.000Z", + dateEnd: "2027-08-01T00:00:00.000Z", + priority: 1, + }; + + const temporaryDirectory = await mkdtemp(join(tmpdir(), "cve-warning-registry-hardening-")); + const malformedJsonPath = join(temporaryDirectory, "malformed.json"); + const wrongShapePath = join(temporaryDirectory, "wrong-shape.json"); + + try { + // A registry file containing invalid JSON must not throw on construction, + // and must surface the problem via console.warn while yielding no warnings. + writeFileSync(malformedJsonPath, "{ this is not valid json", "utf8"); + const malformedWarnings = await withCapturedWarnings(() => { + const registry = new WarningRegistry(malformedJsonPath); + assert.deepEqual(registry.warnings, []); + + // Diagnostics construction reads the registry; it must not throw either. + const diagnostics = Diagnostics.success(new WarningRegistry(malformedJsonPath)); + assert.deepEqual(diagnostics.warnings, []); + }); + assert.ok( + malformedWarnings.some((message) => message.includes(malformedJsonPath)), + "expected a console.warn mentioning the malformed registry path", + ); + + // Valid JSON of the wrong shape is treated the same way: fail soft, no throw, + // and the problem is surfaced on stderr. + writeFileSync(wrongShapePath, JSON.stringify([{ notAWarning: true }]), "utf8"); + const wrongShapeWarnings = await withCapturedWarnings(() => { + const registry = new WarningRegistry(wrongShapePath); + assert.deepEqual(registry.warnings, []); + }); + assert.ok( + wrongShapeWarnings.some((message) => message.includes(wrongShapePath)), + "expected a console.warn mentioning the wrong-shape registry path", + ); + + // addWarning must refuse to overwrite an unreadable registry so its contents + // are never silently destroyed; the file stays byte-for-byte unchanged. This + // holds for both malformed JSON and valid-JSON-of-the-wrong-shape. + for (const corruptPath of [malformedJsonPath, wrongShapePath]) { + const before = readFileSync(corruptPath, "utf8"); + await withCapturedWarnings(() => { + const registry = new WarningRegistry(corruptPath); + assert.throws(() => registry.addWarning(validWarning), /refusing to modify/); + }); + assert.equal(readFileSync(corruptPath, "utf8"), before); + } + + // A corruption episode is reported once (deduped), but after a successful + // read the dedupe entry is cleared, so a later re-corruption warns again and + // is not silently swallowed. + const recurringPath = join(temporaryDirectory, "recurring.json"); + + writeFileSync(recurringPath, "broken", "utf8"); + const firstCorruption = await withCapturedWarnings(() => { + new WarningRegistry(recurringPath); + // A second construction during the same episode must not warn again. + new WarningRegistry(recurringPath); + }); + assert.equal( + firstCorruption.filter((message) => message.includes(recurringPath)).length, + 1, + "a corruption episode should warn exactly once", + ); + + // Repair with a valid (empty) registry: reads cleanly, no warning. + writeFileSync(recurringPath, "[]", "utf8"); + const afterRepair = await withCapturedWarnings(() => { + const registry = new WarningRegistry(recurringPath); + assert.deepEqual(registry.warnings, []); + }); + assert.equal(afterRepair.length, 0, "a valid registry must not warn"); + + // Re-corrupt: the new episode must be reported again. + writeFileSync(recurringPath, "broken again", "utf8"); + const secondCorruption = await withCapturedWarnings(() => { + new WarningRegistry(recurringPath); + }); + assert.ok( + secondCorruption.some((message) => message.includes(recurringPath)), + "a new corruption episode after repair must warn again", + ); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + runSmokeTests(); runSchemaValidationIntegrationTests(); runBusinessRuleIntegrationTests(); await runWarningTests(); +await runWarningRegistryHardeningTests(); console.log("Local tests passed."); diff --git a/src/registry/warnings.ts b/src/registry/warnings.ts index 00dee83..15c23c2 100644 --- a/src/registry/warnings.ts +++ b/src/registry/warnings.ts @@ -74,10 +74,20 @@ function defaultWarningRegistryPath(): string { ); } +/** + * Registry paths already reported as unreadable during the current corruption + * episode. Validation constructs a WarningRegistry per Diagnostics result, so + * this keeps a corrupt registry from emitting one identical stderr line per + * validated record. Entries are cleared on the next successful read, so a later + * re-corruption of the same path is reported again. + */ +const warnedRegistryPaths = new Set(); + /** File-backed registry for non-fatal warning messages. */ export class WarningRegistry { readonly #registryPath: string; readonly #warnings: DiagnosticWarning[]; + readonly #loadError: Error | null; /** * Creates a warning registry. @@ -88,7 +98,10 @@ export class WarningRegistry { this.#registryPath = registryPath instanceof URL ? fileURLToPath(registryPath) : registryPath; - this.#warnings = this.read(); + + const { warnings, error } = this.read(); + this.#warnings = warnings; + this.#loadError = error; } /** All warnings currently registered. */ @@ -141,22 +154,55 @@ export class WarningRegistry { throw new TypeError('Invalid diagnostic warning'); } + if (this.#loadError) { + // The existing file could not be parsed. Refuse to append, otherwise + // save() would overwrite the unreadable file and destroy its contents. + throw new Error( + `WarningRegistry: refusing to modify ${this.#registryPath} because it could not be read (${this.#loadError.message}).`, + ); + } + this.#warnings.push({ ...warning }); this.save(); } - private read(): DiagnosticWarning[] { + private read(): { warnings: DiagnosticWarning[]; error: Error | null } { if (!existsSync(this.#registryPath)) { - return []; + // A missing file is a valid empty state, not corruption; clear any prior + // dedupe entry so a later re-corruption of this path is reported again. + warnedRegistryPaths.delete(this.#registryPath); + return { warnings: [], error: null }; } - const data: unknown = JSON.parse(readFileSync(this.#registryPath, 'utf8')); - - if (!Array.isArray(data) || !data.every(isDiagnosticWarning)) { - throw new TypeError(`Invalid warning registry: ${this.#registryPath}`); + try { + const data: unknown = JSON.parse(readFileSync(this.#registryPath, 'utf8')); + + if (!Array.isArray(data) || !data.every(isDiagnosticWarning)) { + throw new TypeError(`Invalid warning registry: ${this.#registryPath}`); + } + + // A successful read ends any prior corruption episode for this path. + warnedRegistryPaths.delete(this.#registryPath); + return { warnings: data.map((warning) => ({ ...warning })), error: null }; + } catch (error) { + // Fail soft: validation constructs a WarningRegistry for every Diagnostics + // result, so a malformed registry file must not throw and take down + // otherwise-successful validation. Surface the problem on stderr and + // continue with no warnings; addWarning still refuses to overwrite the + // unreadable file. Warn once per corruption episode per path (the entry is + // cleared on the next successful read) to avoid flooding stderr while a + // corrupt registry persists. + const readError = error instanceof Error ? error : new Error(String(error)); + + if (!warnedRegistryPaths.has(this.#registryPath)) { + warnedRegistryPaths.add(this.#registryPath); + console.warn( + `WarningRegistry: could not read ${this.#registryPath} (${readError.message}); continuing with no warnings.`, + ); + } + + return { warnings: [], error: readError }; } - - return data.map((warning) => ({ ...warning })); } private save(): void {