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 });