Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 116 additions & 1 deletion scripts/test-local.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.");
64 changes: 55 additions & 9 deletions src/registry/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

/** 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.
Expand All @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down