mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(browser): report complete storage key inventory changes (#2861)
* fix(browser): report complete storage key inventory changes * fix(browser): preserve unavailable storage inventory coverage Reuse the key-coverage propagation and independent fingerprint mapping proposed by chAng-L19 in morluto/rea#2491. Propagate failed or malformed inventories into the canonical completeness ledger before comparing membership; preserve independently proven value changes. Add producer-to-comparison regressions and document recapture requirements.
This commit is contained in:
@@ -243,6 +243,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex
|
||||
- Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
|
||||
- Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers.
|
||||
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Script metadata admission bounds retained identity and source-map strings to 8 MiB and 50,000 records. When a source-map URL cannot fit, the script identity remains and source-map coverage reports `resource_budget_exhausted`; rejected script observations are counted without retaining their IDs. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest.
|
||||
|
||||
Frames, resources, scripts, events, and workers outside the selected origin scope are excluded. An explicit `allowed_origins` list can include additional origins; excluded target details are counted without being exposed.
|
||||
|
||||
@@ -70,9 +70,11 @@ const compareDimension = (
|
||||
after: ReadonlyMap<string, string>,
|
||||
complete: boolean,
|
||||
reason: string,
|
||||
inventoryComplete = complete,
|
||||
): Dimension => {
|
||||
// Membership evidence can be complete even when selected content is unknown.
|
||||
const all = compareIdentities(before, after).filter(
|
||||
({ change }) => complete || change === "modified",
|
||||
({ change }) => inventoryComplete || change === "modified",
|
||||
);
|
||||
if (all.length > 0)
|
||||
return {
|
||||
@@ -160,6 +162,7 @@ const storageDimension = (
|
||||
),
|
||||
storageComparable(before, after, keysComplete, usageComplete),
|
||||
"Storage fingerprints, inventories, usage, or quota were unavailable or truncated in at least one observation.",
|
||||
keysComplete,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,185 @@ import {
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
|
||||
const browsers: FakeCdpBrowser[] = [];
|
||||
|
||||
const inspectStorage = async (
|
||||
browser: FakeCdpBrowser,
|
||||
fingerprints = false,
|
||||
) => {
|
||||
const captured = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 0,
|
||||
include_storage_keys: true,
|
||||
include_storage_fingerprints: fingerprints,
|
||||
}),
|
||||
);
|
||||
if (!captured.ok) throw captured.error;
|
||||
return captured.value;
|
||||
};
|
||||
|
||||
const compareStorage = (left: WebPageInspection, right: WebPageInspection) =>
|
||||
compareWebCaptures(
|
||||
compareWebCapturesInputSchema.parse({
|
||||
before: { inspection: left },
|
||||
after: { inspection: right },
|
||||
}),
|
||||
).dimensions.storage;
|
||||
|
||||
it.each([
|
||||
["local", "DOMStorage.getDOMStorageItems", { entries: [null] }],
|
||||
[
|
||||
"session",
|
||||
"DOMStorage.getDOMStorageItems",
|
||||
{ entries: [["known", "value"], null] },
|
||||
],
|
||||
["indexed", "IndexedDB.requestDatabaseNames", { databaseNames: [null] }],
|
||||
["cache", "CacheStorage.requestCacheNames", { caches: [null] }],
|
||||
] as const)(
|
||||
"does not invent membership changes after an incomplete %s inventory",
|
||||
async (kind, method, malformed) => {
|
||||
let failure: "unsupported" | "malformed" | undefined;
|
||||
const matches = (command: {
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
}) => {
|
||||
if (command.method !== method) return false;
|
||||
if (kind !== "local" && kind !== "session") return true;
|
||||
return (
|
||||
(command.params.storageId as { isLocalStorage: boolean })
|
||||
.isLocalStorage ===
|
||||
(kind === "local")
|
||||
);
|
||||
};
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandError: (command) =>
|
||||
failure === "unsupported" && matches(command)
|
||||
? { code: -32601, message: "Method not found" }
|
||||
: undefined,
|
||||
commandResult: (command) =>
|
||||
failure === "malformed" && matches(command) ? malformed : undefined,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const before = await inspectStorage(browser);
|
||||
expect(before.completeness.unavailable_sections).not.toContain(
|
||||
"storage_keys",
|
||||
);
|
||||
for (const [mode, reason] of [
|
||||
["unsupported", "provider_unavailable"],
|
||||
["malformed", "invalid_protocol_value"],
|
||||
] as const) {
|
||||
failure = mode;
|
||||
const after = await inspectStorage(browser);
|
||||
expect(after.completeness.excluded).toContainEqual({
|
||||
section: "storage_keys",
|
||||
reason,
|
||||
count: null,
|
||||
});
|
||||
expect(after.completeness.unavailable_sections).toContain("storage_keys");
|
||||
for (const [left, right] of [
|
||||
[before, after],
|
||||
[after, before],
|
||||
] as const)
|
||||
expect(compareStorage(left, right)).toMatchObject({
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
changes: [],
|
||||
});
|
||||
if (kind === "session" && mode === "malformed")
|
||||
expect(after.storage.session_storage_keys).toEqual(["known"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves shared content changes when a peer inventory is unavailable", async () => {
|
||||
let value = "before";
|
||||
const browser = await startFakeCdpBrowser({
|
||||
unsupportedMethods: ["CacheStorage.requestCacheNames"],
|
||||
commandResult: (command) =>
|
||||
command.method === "DOMStorage.getDOMStorageItems"
|
||||
? { entries: [["shared-key", value]] }
|
||||
: undefined,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const before = await inspectStorage(browser, true);
|
||||
expect(before.completeness.unavailable_sections).toContain("storage_keys");
|
||||
expect(compareStorage(before, before)).toMatchObject({
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
});
|
||||
value = "after";
|
||||
const after = await inspectStorage(browser, true);
|
||||
for (const [left, right] of [
|
||||
[before, after],
|
||||
[after, before],
|
||||
] as const)
|
||||
expect(compareStorage(left, right)).toMatchObject({
|
||||
status: "changed",
|
||||
total_changes: 2,
|
||||
changes: [
|
||||
expect.objectContaining({ change: "modified" }),
|
||||
expect.objectContaining({ change: "modified" }),
|
||||
],
|
||||
});
|
||||
for (const fingerprint of after.storage.content_fingerprints)
|
||||
if (["local_storage", "session_storage"].includes(fingerprint.scope))
|
||||
fingerprint.complete = false;
|
||||
expect(compareStorage(before, after)).toMatchObject({
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports complete storage key additions and removals without content fingerprints", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
browsers.push(browser);
|
||||
const before = await inspectStorage(browser);
|
||||
expect(before.storage.fingerprints_complete).toBe(false);
|
||||
const after = structuredClone(before);
|
||||
after.storage.local_storage_keys.push("new-local");
|
||||
after.storage.session_storage_keys.push("new-session");
|
||||
after.storage.indexed_db_names.push("new-db");
|
||||
after.storage.cache_names.push("new-cache");
|
||||
const identities = [
|
||||
"storage:cache:new-cache",
|
||||
"storage:indexed_db:new-db",
|
||||
"storage:local_storage:new-local",
|
||||
"storage:session_storage:new-session",
|
||||
];
|
||||
for (const [left, right, change] of [
|
||||
[before, after, "added"],
|
||||
[after, before, "removed"],
|
||||
] as const) {
|
||||
expect(compareStorage(left, right)).toMatchObject({
|
||||
status: "changed",
|
||||
total_changes: 4,
|
||||
changes: identities.map((identity) => ({ identity, change })),
|
||||
});
|
||||
}
|
||||
expect(compareStorage(after, after)).toMatchObject({
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
});
|
||||
const missingQuota = structuredClone(after);
|
||||
missingQuota.storage.quota_bytes = null;
|
||||
expect(compareStorage(before, missingQuota)).toMatchObject({
|
||||
status: "changed",
|
||||
total_changes: 4,
|
||||
});
|
||||
const partialInventory = structuredClone(after);
|
||||
partialInventory.completeness.unavailable_sections.push("storage_keys");
|
||||
expect(compareStorage(before, partialInventory)).toMatchObject({
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
});
|
||||
expect(compareStorage(partialInventory, before)).toMatchObject({
|
||||
status: "unknown",
|
||||
total_changes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const expectInvalidDiff = (input: unknown): void => {
|
||||
expect(webCaptureDiffSchema.safeParse(input).success).toBe(false);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user