diff --git a/scripts/src/monitor-sqlite-migration.ts b/scripts/src/monitor-sqlite-migration.ts index 590bce1a..72cbdff6 100644 --- a/scripts/src/monitor-sqlite-migration.ts +++ b/scripts/src/monitor-sqlite-migration.ts @@ -214,7 +214,7 @@ function verifySnapshotReferentialIntegrity(snapshot: MonitorSqliteSnapshot): vo const reportSha256 = stringField(row, ["report_json_sha256", "reportJsonSha256"]); const locatorRows = tableRows(snapshot.tables, ["artifact_locators", "locators"]); const expectedLocators = locatorRows.filter((locator) => stringField(locator, ["run_id"]) === runId).map((locator) => locatorBindingFromRow(locator, snapshot)); - if (declaredByRunId.has(runId) || manifest.artifactCount !== (rowCount ?? manifest.locators.length) || manifest.stateDir !== stateDir || manifest.reportSha256 !== reportSha256 || (stateDir.length > 0 && manifest.ownerPvc === null) || manifest.locators.some((locator) => locator.ownerPvc !== manifest.ownerPvc || locator.stateDir !== stateDir) || manifest.locators.length !== manifest.artifactCount || (locatorRows.length > 0 && !sameLocatorBindings(manifest.locators, expectedLocators))) throw Object.assign(new Error("snapshot normal artifact manifest does not match run identity"), { code: "monitor-migration-snapshot-referential-invalid" }); + if (declaredByRunId.has(runId) || manifest.artifactCount !== (rowCount ?? manifest.locators.length) || manifest.stateDir !== stateDir || manifest.reportSha256 !== reportSha256 || (stateDir.length > 0 && manifest.ownerPvc === null) || manifest.locators.some((locator) => locator.ownerPvc !== manifest.ownerPvc || locator.stateDir !== stateDir) || manifest.locators.length !== manifest.artifactCount || (expectedLocators.length > 0 && !sameLocatorBindings(manifest.locators, expectedLocators))) throw Object.assign(new Error("snapshot normal artifact manifest does not match run identity"), { code: "monitor-migration-snapshot-referential-invalid" }); verifyReportLocator(row, manifest.locators); continue; } diff --git a/scripts/src/monitor-terminal-ingest-migration.test.ts b/scripts/src/monitor-terminal-ingest-migration.test.ts index 09ff6823..5e67c9bf 100644 --- a/scripts/src/monitor-terminal-ingest-migration.test.ts +++ b/scripts/src/monitor-terminal-ingest-migration.test.ts @@ -321,6 +321,39 @@ test("normal report locator hash cannot drift from its SQLite row after a rehash } }); +test("mixed per-run locator rows keep fallback runs valid and reject rebinds", async () => { + const directory = mkdtempSync(join(tmpdir(), "monitor-mixed-locator-rows-")); + try { + const sqlitePath = join(directory, "index.sqlite"); + const reports = new Map([["a", Buffer.from("report A\n")], ["b", Buffer.from("report B\n")]]); + const hashes = new Map([...reports].map(([runId, report]) => [runId, `sha256:${createHash("sha256").update(report).digest("hex")}`])); + for (const [runId, report] of reports) { + const reportPath = join(directory, ".state", runId, "analysis", "report.json"); + mkdirSync(join(reportPath, ".."), { recursive: true }); + writeFileSync(reportPath, report); + } + const database = new Database(sqlitePath); + database.run("CREATE TABLE runs (id TEXT, state_dir TEXT, artifact_count INTEGER, report_json_sha256 TEXT, updated_at TEXT)"); + database.run("CREATE TABLE artifact_locators (run_id TEXT, owner_pvc TEXT, state_dir TEXT, relative_path TEXT, sha256 TEXT, size_bytes INTEGER)"); + for (const [runId, report] of reports) database.run("INSERT INTO runs VALUES (?, ?, ?, ?, ?)", [runId, `.state/${runId}`, 1, hashes.get(runId), "2026-07-12T00:00:00.000Z"]); + database.run("INSERT INTO artifact_locators VALUES (?, ?, ?, ?, ?, ?)", ["a", "fixture-pvc", ".state/a", "analysis/report.json", hashes.get("a"), reports.get("a")?.byteLength]); + database.close(); + const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: sqlitePath, runtimeConfigRef: "fixture#runtime", stateRoot: directory, ownerPvc: "fixture-pvc", artifactSources: [{ ownerPvc: "fixture-pvc", mountPath: directory }] }; + const snapshot = exportMonitorSqliteSnapshot(source, join(directory, "snapshot.json")); + assert.equal(verifyMonitorSqliteSnapshot(snapshot).ok, true); + const manifestA = snapshot.artifactManifests.find((manifest) => manifest.runId === "a")!; + const manifestB = snapshot.artifactManifests.find((manifest) => manifest.runId === "b")!; + const reboundUnsigned = { ...snapshot, artifactManifests: snapshot.artifactManifests.map((manifest) => manifest.runId === "a" ? { ...manifest, locators: manifestB.locators } : manifest) }; + assert.equal(verifyMonitorSqliteSnapshot({ ...reboundUnsigned, manifestContentHash: monitorSnapshotContentHash(reboundUnsigned) }).ok, false); + const swappedUnsigned = { ...snapshot, artifactManifests: [{ ...manifestB, runId: "a" }, { ...manifestA, runId: "b" }] }; + assert.equal(verifyMonitorSqliteSnapshot({ ...swappedUnsigned, manifestContentHash: monitorSnapshotContentHash(swappedUnsigned) }).ok, false); + writeFileSync(join(directory, ".state", "b", "added-after-export.bin"), "added\n"); + await assert.rejects(() => importMonitorSqliteSnapshot(snapshot, createInMemoryMonitorCentralStore()), (error: unknown) => (error as { code?: string }).code === "monitor-migration-frozen-artifact-invalid"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("artifact count without a registry-backed locator fails closed", async () => { const directory = mkdtempSync(join(tmpdir(), "monitor-artifact-count-")); try {