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.
This commit is contained in:
morluto
2026-10-25 01:44:09 +08:00
parent 8b57dc630e
commit 84c30609e1
7 changed files with 380 additions and 23 deletions
+8
View File
@@ -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
+22 -1
View File
@@ -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) &&
+6
View File
@@ -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(),
}),
)
+39 -22
View File
@@ -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<string> => {
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<string, string | null> =>
): 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,
]),
);
@@ -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();
}
},
);
@@ -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,
@@ -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 });