From 84c30609e16ccc26f06f98dc14647fb8eb050785 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:44:09 +0800 Subject: [PATCH] fix(browser): include IndexedDB versions in storage fingerprints Hash the observed database version with object-store definitions at the CDP boundary. Missing, invalid, or imprecise versions preserve store evidence with incomplete coverage and an explanation. Mark the revised projection so historical store-only hashes cannot create false changes or prove unchanged versions. Preserve comparable metadata and independent storage changes. Remove the redundant storage comparability wrapper and use each fingerprint's parsed scope instead of interpreting its identity string. Validation: producer-to-comparison regressions and a compiled CLI to stdio MCP comparison passed, including version-only changes, invalid versions, legacy projections, and complete database additions/removals. Static and docs checks passed. CDP responses were simulated; no real browser runtime was exercised. --- docs/browser-observation.md | 8 + src/browser/CdpCaptureStorageFingerprints.ts | 23 ++- src/domain/browserObservationSchemas.ts | 6 + src/domain/webCaptureDiff.ts | 61 +++++--- .../browser/indexedDbVersionCliMcp.test.ts | 147 ++++++++++++++++++ ...owserProvider.storage_completeness.test.ts | 113 ++++++++++++++ tests/boundary/browser/webCaptureDiff.test.ts | 45 ++++++ 7 files changed, 380 insertions(+), 23 deletions(-) create mode 100644 tests/acceptance/browser/indexedDbVersionCliMcp.test.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 00a255b2b2..f6d13305d1 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -94,6 +94,14 @@ content. Actual JSON properties with those names remain content. Reference-only objects, unsupported values, and opaque cookie partition identities remain incomplete. Previously exported fingerprints are not recomputed; recapture both sides when comparing captures produced across this decoder change. +IndexedDB schema fingerprints with `projection_version: 2` include the observed +database version and object-store definitions. A missing, malformed, or imprecise +version leaves the schema fingerprint incomplete while retaining observed store +facts. Historical fingerprints without this marker covered object stores only: +matching historical hashes cannot establish unchanged database versions, and a +historical hash is not compared with a version-bearing hash. Recapture both +sides for a complete IndexedDB version comparison; other proven storage changes +remain reportable. Script comparison groups observations by reported URL and compares unordered content samples. Producer inventory keys include optional metadata, so a changed diff --git a/src/browser/CdpCaptureStorageFingerprints.ts b/src/browser/CdpCaptureStorageFingerprints.ts index 169dc62e76..26e7528657 100644 --- a/src/browser/CdpCaptureStorageFingerprints.ts +++ b/src/browser/CdpCaptureStorageFingerprints.ts @@ -190,11 +190,28 @@ const addIndexedDb = async ( const stores = recordsValue(database.objectStores); if (stores.length !== database.objectStores.length) run.state.complete = false; + const version = + typeof database.version === "number" && + Number.isSafeInteger(database.version) && + database.version >= 0 + ? database.version + : undefined; + if (version === undefined) { + const limitation = + "An IndexedDB schema fingerprint is incomplete because its database version is unavailable, invalid, or outside exact integer precision."; + if (!run.limitations.includes(limitation)) + run.limitations.push(limitation); + } addFingerprint(run.state, { scope: "indexed_db_schema", identity: databaseName, - value: stores.map(indexedDbStoreSchema), + value: { + version: version ?? null, + object_stores: stores.map(indexedDbStoreSchema), + }, + projectionVersion: 2, complete: + version !== undefined && stores.length === database.objectStores.length && indexedDbSchemaComplete(stores), }); @@ -427,12 +444,16 @@ const addFingerprint = ( readonly identity: unknown; readonly value: unknown; readonly complete: boolean; + readonly projectionVersion?: 2; }, ): void => { state.items.push({ scope: input.scope, identity_sha256: digest(input.identity), value_sha256: input.value === undefined ? null : digest(input.value), + ...(input.projectionVersion === undefined + ? {} + : { projection_version: input.projectionVersion }), complete: input.complete && stableValueComplete(input.identity) && diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index daae3bdcc8..fad41246ba 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -327,6 +327,12 @@ export const webPageInspectionSchema = z.object({ .string() .regex(/^[a-f0-9]{64}$/u) .nullable(), + projection_version: z + .literal(2) + .optional() + .describe( + "IndexedDB schema projection including database version; absent on historical store-only fingerprints.", + ), complete: z.boolean(), }), ) diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index b43c4c4160..26d37c5072 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -168,6 +168,12 @@ const storageDimension = ( before.storage, after.storage, ); + const indexedDbVersionCovered = [before.storage, after.storage].every( + (storage) => + storage.content_fingerprints + .filter(({ scope }) => scope === "indexed_db_schema") + .every(({ projection_version }) => projection_version === 2), + ); return compareDimension( storageMap( before.storage, @@ -181,8 +187,12 @@ const storageDimension = ( usageComplete, comparableFingerprints, ), - storageComparable(before, after, keysComplete, usageComplete), - "Storage fingerprints, inventories, usage, or quota were unavailable or truncated in at least one observation.", + keysComplete && + usageComplete && + indexedDbVersionCovered && + before.storage.fingerprints_complete && + after.storage.fingerprints_complete, + "Storage fingerprints, inventories, usage, quota, or IndexedDB schema version coverage were unavailable or truncated in at least one observation.", keysComplete, ); }; @@ -1159,17 +1169,6 @@ const accessibilityProjection = ( const storageKeysComparable = (inspection: WebPageInspection): boolean => sectionsComplete(inspection, ["storage_keys"]); -const storageComparable = ( - before: WebPageInspection, - after: WebPageInspection, - keysComplete: boolean, - usageComplete: boolean, -): boolean => - keysComplete && - usageComplete && - before.storage.fingerprints_complete && - after.storage.fingerprints_complete; - const storageMap = ( storage: WebPageInspection["storage"], includeKeys: boolean, @@ -1219,23 +1218,41 @@ const storageFingerprintIdentities = ( ): ReadonlySet => { const beforeComplete = completeStorageFingerprints(before); const afterComplete = completeStorageFingerprints(after); - if (before.fingerprints_complete && after.fingerprints_complete) - return new Set([...beforeComplete.keys(), ...afterComplete.keys()]); + const bothComplete = + before.fingerprints_complete && after.fingerprints_complete; + const candidates = bothComplete + ? new Set([...beforeComplete.keys(), ...afterComplete.keys()]) + : new Set( + [...beforeComplete.keys()].filter((identity) => + afterComplete.has(identity), + ), + ); return new Set( - [...beforeComplete.keys()].filter((identity) => - afterComplete.has(identity), - ), + [...candidates].filter((identity) => { + const beforeSchema = beforeComplete.get(identity); + const afterSchema = afterComplete.get(identity); + if ((beforeSchema ?? afterSchema)?.scope !== "indexed_db_schema") + return true; + return ( + beforeSchema === undefined || + afterSchema === undefined || + beforeSchema.projection_version === afterSchema.projection_version + ); + }), ); }; const completeStorageFingerprints = ( storage: WebPageInspection["storage"], -): ReadonlyMap => +): ReadonlyMap< + string, + WebPageInspection["storage"]["content_fingerprints"][number] +> => new Map( storage.content_fingerprints .filter(({ complete }) => complete) - .map(({ scope, identity_sha256, value_sha256 }) => [ - `${scope}:${identity_sha256}`, - value_sha256, + .map((fingerprint) => [ + `${fingerprint.scope}:${fingerprint.identity_sha256}`, + fingerprint, ]), ); diff --git a/tests/acceptance/browser/indexedDbVersionCliMcp.test.ts b/tests/acceptance/browser/indexedDbVersionCliMcp.test.ts new file mode 100644 index 0000000000..9f1cf0475e --- /dev/null +++ b/tests/acceptance/browser/indexedDbVersionCliMcp.test.ts @@ -0,0 +1,147 @@ +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js"; +import { webCaptureDiffSchema } from "../../../src/domain/webCaptureDiffSchemas.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "preserves an IndexedDB version change through built CLI, stdio MCP, and public comparison", + async ({ cli }) => { + let reportedVersion = 1; + const browser = await startFakeCdpBrowser({ + commandResult: ({ method, params }) => + method === "IndexedDB.requestDatabase" + ? { + databaseWithObjectStores: { + name: params.databaseName, + version: reportedVersion, + objectStores: [ + { + name: "records", + keyPath: { type: "string", string: "id" }, + autoIncrement: false, + indexes: [], + }, + ], + }, + } + : undefined, + }); + + try { + const { call } = await connectLocalToolsMcp(); + const inspectionContract = toolContract("inspect_web_page"); + const cliResponse = await cli.run({ + arguments: [ + "inspect-web-page", + browser.endpoint, + "allowed-page", + "--allowed-origins", + browser.allowedOrigin, + "--observation-ms", + "0", + "--include-storage-keys", + "--include-storage-fingerprints", + "--json", + ], + }); + expect( + cliResponse.exitCode, + cliResponse.stdout + cliResponse.stderr, + ).toBe(0); + const before = webPageInspectionSchema.parse( + parseEvidence(inspectionContract.outputSchema.parse(cliResponse.json)) + .normalized_result, + ); + + reportedVersion = 2; + const mcpResponse = await call("inspect_web_page", { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + observation_ms: 0, + include_storage_keys: true, + include_storage_fingerprints: true, + }); + expect(mcpResponse.isError, JSON.stringify(mcpResponse)).not.toBe(true); + const after = webPageInspectionSchema.parse( + parseEvidence( + inspectionContract.outputSchema.parse(mcpResponse.structuredContent), + ).normalized_result, + ); + + const schemaFingerprints = [before, after].map((inspection) => { + expect(inspection.storage.fingerprints_complete).toBe(true); + expect(inspection.storage.indexed_db_names).toEqual(["app-db"]); + expect(inspection.storage.usage_bytes).toBe(42); + expect(inspection.storage.quota_bytes).toBe(1_024); + const fingerprints = inspection.storage.content_fingerprints.filter( + ({ scope }) => scope === "indexed_db_schema", + ); + expect(fingerprints).toHaveLength(1); + expect(fingerprints[0]).toMatchObject({ + projection_version: 2, + complete: true, + value_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + }); + return fingerprints[0]!; + }); + expect(schemaFingerprints[0]!.identity_sha256).toBe( + schemaFingerprints[1]!.identity_sha256, + ); + expect(schemaFingerprints[0]!.value_sha256).not.toBe( + schemaFingerprints[1]!.value_sha256, + ); + expect(after.storage).toMatchObject({ + usage_bytes: before.storage.usage_bytes, + quota_bytes: before.storage.quota_bytes, + local_storage_keys: before.storage.local_storage_keys, + session_storage_keys: before.storage.session_storage_keys, + indexed_db_names: before.storage.indexed_db_names, + cache_names: before.storage.cache_names, + }); + expect( + after.storage.content_fingerprints.filter( + ({ scope }) => scope !== "indexed_db_schema", + ), + ).toEqual( + before.storage.content_fingerprints.filter( + ({ scope }) => scope !== "indexed_db_schema", + ), + ); + + const comparisonResponse = await call("compare_web_captures", { + before: { inspection: before }, + after: { inspection: after }, + }); + expect( + comparisonResponse.isError, + JSON.stringify(comparisonResponse), + ).not.toBe(true); + const comparison = webCaptureDiffSchema.parse( + parseEvidence( + toolContract("compare_web_captures").outputSchema.parse( + comparisonResponse.structuredContent, + ), + ).normalized_result, + ); + expect(comparison.dimensions.storage).toEqual({ + status: "changed", + total_changes: 1, + changes: [ + { + identity: `storage:content:indexed_db_schema:${schemaFingerprints[0]!.identity_sha256}`, + change: "modified", + }, + ], + reason: null, + }); + } finally { + await browser.close(); + } + }, +); diff --git a/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts b/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts index 4da71be2da..e5e83afcce 100644 --- a/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.storage_completeness.test.ts @@ -223,6 +223,119 @@ it("does not fingerprint a filtered partial object-store schema as complete", as }); }); +it("compares a change to the observed IndexedDB database version", async () => { + let version = 1; + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestDatabase" + ? { + databaseWithObjectStores: { + name: "app-db", + version, + objectStores: [ + { + name: "records", + keyPath: { type: "string", string: "id" }, + autoIncrement: false, + indexes: [], + }, + ], + }, + } + : undefined, + }); + const before = await capture(); + version = 2; + const after = await capture(); + const schema = after.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_schema", + ); + expect(schema).toMatchObject({ complete: true, projection_version: 2 }); + expect(before.storage.fingerprints_complete).toBe(true); + expect(after.storage.fingerprints_complete).toBe(true); + expect(compareStorage(before, after)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [expect.objectContaining({ change: "modified" })], + }); +}); + +it("retains complete IndexedDB schema additions and removals", async () => { + let present = false; + const { capture } = await inspect({ + commandResult: ({ method }) => { + if (method === "IndexedDB.requestDatabaseNames") + return { databaseNames: present ? ["app-db"] : [] }; + if (method === "IndexedDB.requestDatabase") + return { + databaseWithObjectStores: { + name: "app-db", + version: 1, + objectStores: [], + }, + }; + return undefined; + }, + }); + const before = await capture(); + present = true; + const after = await capture(); + expect(before.storage.fingerprints_complete).toBe(true); + expect(after.storage.fingerprints_complete).toBe(true); + for (const [left, right, change] of [ + [before, after, "added"], + [after, before, "removed"], + ] as const) + expect(compareStorage(left, right)).toMatchObject({ + status: "changed", + total_changes: 2, + changes: expect.arrayContaining([ + { change, identity: "storage:indexed_db:app-db" }, + { change, identity: expect.stringContaining("indexed_db_schema:") }, + ]), + }); +}); + +it.each([undefined, null, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + "keeps IndexedDB store facts but marks invalid version %s incomplete", + async (version) => { + const { capture } = await inspect({ + commandResult: (command) => + command.method === "IndexedDB.requestDatabase" + ? { + databaseWithObjectStores: { + name: "app-db", + ...(version === undefined ? {} : { version }), + objectStores: [ + { + name: "records", + keyPath: { type: "string", string: "id" }, + autoIncrement: false, + indexes: [], + }, + ], + }, + } + : undefined, + }); + const captured = await capture(); + expect( + captured.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_schema", + ), + ).toMatchObject({ + projection_version: 2, + value_sha256: expect.any(String), + complete: false, + }); + expect(captured.storage.fingerprints_complete).toBe(false); + expect(captured.limitations).toContain( + "An IndexedDB schema fingerprint is incomplete because its database version is unavailable, invalid, or outside exact integer precision.", + ); + expect(compareStorage(captured, captured).status).toBe("unknown"); + }, +); + const incompleteRemoteObjects = [ undefined, null, diff --git a/tests/boundary/browser/webCaptureDiff.test.ts b/tests/boundary/browser/webCaptureDiff.test.ts index f847c3bf7c..719943a8bc 100644 --- a/tests/boundary/browser/webCaptureDiff.test.ts +++ b/tests/boundary/browser/webCaptureDiff.test.ts @@ -1088,6 +1088,51 @@ describe("web capture diff semantics and fingerprints", () => { }); }); +it("keeps historical IndexedDB schema projections uncertain while retaining proven changes", async () => { + const browser = await startFakeCdpBrowser(); + browsers.push(browser); + const current = await inspectStorage(browser, true); + const legacy = structuredClone(current); + const oldSchema = legacy.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_schema", + ); + if (oldSchema === undefined) + throw new Error("Expected an IndexedDB schema fingerprint"); + delete oldSchema.projection_version; + oldSchema.value_sha256 = "0".repeat(64); + for (const [before, after] of [ + [legacy, current], + [current, legacy], + ] as const) + expect(compareStorage(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + expect(compareStorage(legacy, structuredClone(legacy))).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + const changedLegacy = structuredClone(legacy); + const changedSchema = changedLegacy.storage.content_fingerprints.find( + ({ scope }) => scope === "indexed_db_schema", + ); + if (changedSchema === undefined) + throw new Error("Expected an IndexedDB schema fingerprint"); + changedSchema.value_sha256 = "1".repeat(64); + expect(compareStorage(legacy, changedLegacy)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [expect.objectContaining({ change: "modified" })], + }); + const changedKey = structuredClone(current); + changedKey.storage.local_storage_keys.push("new-key"); + expect(compareStorage(legacy, changedKey)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [expect.objectContaining({ change: "added" })], + }); +}); + describe("web capture diff completeness", () => { it("uses complete redacted fingerprints but keeps incomplete evidence unknown", async () => { const browser = await startFakeCdpBrowser({ extraCollections: true });