From 64ebdf45016bda679674cbd2287576eadf66a1fc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:23:37 +0800 Subject: [PATCH] refactor(domain): extract source-map envelope walk and disambiguate stringValue (#828) Move the indexed sections walk shared by the browser fetcher and the artifact reader into domain/sourceMapEnvelope with the fetchers stricter offset validation as an option; both validators keep their leaf strictness. Rename the browser CDP stringValue to cdpStringValue across its 18 files, fold the fingerprint duplicate into the static helper, and rename the version-keys shadow to nonEmptyString. --- src/application/JavaScriptArtifactAnalysis.ts | 17 +----- src/browser/CdpCaptureDocuments.ts | 34 +++++------ src/browser/CdpCaptureEventBodyShapes.ts | 6 +- src/browser/CdpCaptureEventHandlers.ts | 52 ++++++++--------- src/browser/CdpCaptureEventHelpers.ts | 12 ++-- src/browser/CdpCaptureStorage.ts | 12 ++-- src/browser/CdpCaptureStorageFingerprints.ts | 36 +++++++----- src/browser/CdpCaptureValues.ts | 4 +- src/browser/CdpElectronInspection.ts | 20 +++---- src/browser/CdpElectronScriptEvents.ts | 16 +++-- src/browser/CdpElectronScripts.ts | 4 +- src/browser/CdpElectronWorkers.ts | 12 ++-- src/browser/CdpObservationSession.ts | 33 ++++++----- src/browser/CdpPageCaptureScripts.ts | 4 +- src/browser/CdpPageCaptureWorkers.ts | 18 +++--- src/browser/CdpSafeMetadata.ts | 4 +- src/browser/CdpScreenshot.ts | 10 ++-- src/browser/CdpWebMcpDiscovery.ts | 31 +++++----- src/browser/V8InspectorProvider.ts | 8 +-- src/browser/WebSourceMapFetcher.ts | 36 +++--------- .../javascriptApplicationVersionKeys.ts | 12 ++-- src/domain/javascriptAstFingerprint.ts | 4 +- src/domain/sourceMapEnvelope.test.ts | 57 ++++++++++++++++++ src/domain/sourceMapEnvelope.ts | 58 +++++++++++++++++++ 24 files changed, 300 insertions(+), 200 deletions(-) create mode 100644 src/domain/sourceMapEnvelope.test.ts create mode 100644 src/domain/sourceMapEnvelope.ts diff --git a/src/application/JavaScriptArtifactAnalysis.ts b/src/application/JavaScriptArtifactAnalysis.ts index e9bb474457..444118ebe2 100644 --- a/src/application/JavaScriptArtifactAnalysis.ts +++ b/src/application/JavaScriptArtifactAnalysis.ts @@ -4,6 +4,7 @@ import { analyzeParsedJavaScriptStaticSource } from "../domain/javascriptStaticA import { analyzeParsedJavaScriptSemantics } from "../domain/javascriptSemanticAnalysis.js"; import { parseJavaScriptSource } from "../domain/javascriptSourceParser.js"; import { hasValidSourceMapContents } from "../domain/sourceMapContents.js"; +import { flattenSourceMapLeaves } from "../domain/sourceMapEnvelope.js"; import type { JavaScriptSourceRange, JavaScriptSourcePoint, @@ -300,20 +301,8 @@ const unavailableSourceMap = ( const flattenSourceMaps = ( root: unknown, ): Readonly>[] | undefined => { - if (!isRecord(root) || root.version !== 3) return undefined; - const maps: Readonly>[] = []; - const pending = [root]; - while (pending.length > 0) { - const map = pending.pop(); - if (map === undefined || map.version !== 3) return undefined; - if (Array.isArray(map.sections)) { - for (const section of map.sections) { - if (!isRecord(section) || !isRecord(section.map)) return undefined; - pending.push(section.map); - } - } else maps.push(map); - } - return maps; + const leaves = flattenSourceMapLeaves(root); + return leaves === undefined ? undefined : [...leaves]; }; const invalidSourceMap = ( diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 95749beeb0..1358deb448 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -8,7 +8,7 @@ import { recordValue, recordsValue, requiredRecord, - stringValue, + cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js"; @@ -38,7 +38,7 @@ export const captureFrames = ( completeness?.exclude("frames", "invalid_protocol_value"), )) { const frame = recordValue(tree.frame); - const frameId = stringValue(frame?.id); + const frameId = cdpStringValue(frame?.id); const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins); if (frame === undefined || frameId === undefined) { completeness?.exclude("frames", "invalid_protocol_value"); @@ -47,14 +47,14 @@ export const captureFrames = ( if (sanitized === undefined || sanitized.origin === null) { completeness?.exclude( "frames", - exclusionReasonForUrl(stringValue(frame.url)), + exclusionReasonForUrl(cdpStringValue(frame.url)), ); continue; } if (maximum !== undefined && items.length >= maximum) continue; items.push({ frame_id: frameId, - parent_frame_id: stringValue(frame.parentId) ?? null, + parent_frame_id: cdpStringValue(frame.parentId) ?? null, url: sanitized.url, origin: sanitized.origin, }); @@ -64,7 +64,7 @@ export const captureFrames = ( /** Read the current main-frame URL from an untrusted Page.getFrameTree result. */ export const mainFrameUrl = (result: unknown): string | undefined => - stringValue( + cdpStringValue( recordValue(recordValue(requiredRecord(result).frameTree)?.frame)?.url, ); @@ -90,17 +90,17 @@ export const captureResources = ( if (url === undefined || url.origin === null) { completeness?.exclude( "resources", - exclusionReasonForUrl(stringValue(resource.url)), + exclusionReasonForUrl(cdpStringValue(resource.url)), ); continue; } const contentSize = numberValue(resource.contentSize); items.push({ - rawUrl: stringValue(resource.url) ?? "", + rawUrl: cdpStringValue(resource.url) ?? "", url: url.url, origin: url.origin, - type: stringValue(resource.type) ?? "Other", - mime_type: stringValue(resource.mimeType) ?? "", + type: cdpStringValue(resource.type) ?? "Other", + mime_type: cdpStringValue(resource.mimeType) ?? "", content_size: contentSize === undefined ? null : Math.max(0, contentSize), }); @@ -149,7 +149,7 @@ export const captureDom = ( } => { const root = requiredRecord(result); const strings = Array.isArray(root.strings) - ? root.strings.map((value) => stringValue(value) ?? "") + ? root.strings.map((value) => cdpStringValue(value) ?? "") : []; const nodes: WebPageInspection["dom"]["nodes"] = []; const urls: WebPageInspection["metadata"]["dom_urls"] = []; @@ -240,14 +240,14 @@ export const captureAccessibility = ( ); const nodeIds = new Set( all.flatMap((node) => { - const nodeId = stringValue(node.nodeId); + const nodeId = cdpStringValue(node.nodeId); return nodeId === undefined ? [] : [nodeId]; }), ); let excludedFields = 0; const nodes = all.map((node) => { const captureText = (value: unknown): string | null => { - const raw = stringValue(recordValue(value)?.value); + const raw = cdpStringValue(recordValue(value)?.value); if (raw === undefined) return null; if (!options.includeText) { excludedFields += 1; @@ -256,8 +256,8 @@ export const captureAccessibility = ( return raw; }; return { - node_id: stringValue(node.nodeId) ?? "", - parent_id: stringValue(node.parentId) ?? null, + node_id: cdpStringValue(node.nodeId) ?? "", + parent_id: cdpStringValue(node.parentId) ?? null, role: axText(node.role), name: captureText(node.name), description: captureText(node.description), @@ -272,7 +272,7 @@ export const captureAccessibility = ( (node) => Array.isArray(node.childIds) && node.childIds.some((childId) => { - const id = stringValue(childId); + const id = cdpStringValue(childId); return id !== undefined && !nodeIds.has(id); }), ), @@ -326,7 +326,7 @@ const accessibilityStates = ( ): WebPageInspection["accessibility"]["nodes"][number]["states"] => { const states = new Map(); for (const property of recordsValue(value)) { - const name = stringValue(property.name); + const name = cdpStringValue(property.name); if (name === undefined || !ACCESSIBILITY_STATE_NAMES.has(name)) continue; const raw = recordValue(property.value)?.value; const state = @@ -345,7 +345,7 @@ const accessibilityStates = ( }; const axText = (value: unknown): string | null => - stringValue(recordValue(value)?.value) ?? null; + cdpStringValue(recordValue(value)?.value) ?? null; const indexedString = (strings: readonly string[], index: unknown): string => { const integer = numberValue(index); diff --git a/src/browser/CdpCaptureEventBodyShapes.ts b/src/browser/CdpCaptureEventBodyShapes.ts index 6d788dfab4..39f4ab5a5d 100644 --- a/src/browser/CdpCaptureEventBodyShapes.ts +++ b/src/browser/CdpCaptureEventBodyShapes.ts @@ -1,7 +1,7 @@ import { inferJsonShape, type JsonShape } from "../domain/jsonShape.js"; import { recordValue, - stringValue, + cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; import { decodeBase64, isJsonContentType } from "./CdpCaptureEventHelpers.js"; @@ -14,7 +14,7 @@ export const ingestResponseBodyShape = ( value: unknown, ): void => { const result = recordValue(value); - const body = stringValue(result?.body); + const body = cdpStringValue(result?.body); if (body === undefined) { invalidResponseBodyShape(state, requestId); return; @@ -37,7 +37,7 @@ export const requestBodyShape = ( ): NetworkState["body_shapes"] => { if (!state.input.include_json_body_shapes) return { status: "not_approved", request: null, response: null }; - const body = stringValue(request.postData); + const body = cdpStringValue(request.postData); if (!isJsonContentType(recordValue(request.headers)) || body === undefined) return { status: "unavailable", request: null, response: null }; const inferred = inferBodyShape(body); diff --git a/src/browser/CdpCaptureEventHandlers.ts b/src/browser/CdpCaptureEventHandlers.ts index 9d9f3a7dc5..0b93053664 100644 --- a/src/browser/CdpCaptureEventHandlers.ts +++ b/src/browser/CdpCaptureEventHandlers.ts @@ -7,7 +7,7 @@ import { numberValue, recordValue, recordsValue, - stringValue, + cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; import { requestBodyShape } from "./CdpCaptureEventBodyShapes.js"; @@ -30,7 +30,7 @@ export const handleExecutionContextCreated = ( ): void => { const context = recordValue(params.context); const identifier = numberValue(context?.id); - const frameId = stringValue(recordValue(context?.auxData)?.frameId); + const frameId = cdpStringValue(recordValue(context?.auxData)?.frameId); if ( identifier === undefined || !Number.isSafeInteger(identifier) || @@ -53,8 +53,8 @@ export const handleScriptParsed = ( state: CdpCaptureEventsState, params: UnknownRecord, ): void => { - const scriptId = stringValue(params.scriptId); - const rawUrl = stringValue(params.url) ?? ""; + const scriptId = cdpStringValue(params.scriptId); + const rawUrl = cdpStringValue(params.url) ?? ""; const sanitized = allowedSanitizedUrl(rawUrl, state.allowedOrigins); if (scriptId === undefined) { state.completeness.exclude("scripts", "invalid_protocol_value"); @@ -70,7 +70,7 @@ export const handleScriptParsed = ( rawUrl, url: sanitized.url, origin: sanitized.origin, - hash: stringValue(params.hash) ?? "", + hash: cdpStringValue(params.hash) ?? "", length: Math.max( 0, Math.min( @@ -79,7 +79,7 @@ export const handleScriptParsed = ( ), ), isModule: params.isModule === true, - language: stringValue(params.scriptLanguage) ?? null, + language: cdpStringValue(params.scriptLanguage) ?? null, sourceMapUrl: sourceMap.sanitized, sourceMapRawUrl: sourceMap.raw, executionContextKey: executionContextKey(params.executionContextId), @@ -92,7 +92,7 @@ const sourceMapForScript = ( scriptUrl: string, state: CdpCaptureEventsState, ): { readonly sanitized: string | null; readonly raw: string | null } => { - const declaredUrl = stringValue(value); + const declaredUrl = cdpStringValue(value); if (declaredUrl === undefined || declaredUrl === "") return { sanitized: null, raw: null }; let rawUrl: string; @@ -114,7 +114,7 @@ export const handleRequestWillBeSent = ( state: CdpCaptureEventsState, params: UnknownRecord, ): void => { - const requestId = stringValue(params.requestId); + const requestId = cdpStringValue(params.requestId); const request = recordValue(params.request); if (requestId === undefined) { state.completeness.exclude("network_requests", "invalid_protocol_value"); @@ -129,14 +129,14 @@ export const handleRequestWillBeSent = ( if (sanitized === undefined) { state.completeness.exclude( "network_requests", - exclusionReasonForUrl(stringValue(request.url)), + exclusionReasonForUrl(cdpStringValue(request.url)), ); state.network.delete(requestId); return; } const initiator = recordValue(params.initiator); const initiatorFrame = initiatorLocation(initiator); - const rawInitiatorUrl = stringValue(initiatorFrame?.url); + const rawInitiatorUrl = cdpStringValue(initiatorFrame?.url); const initiatorUrl = allowedSanitizedUrl( rawInitiatorUrl, state.allowedOrigins, @@ -150,13 +150,13 @@ export const handleRequestWillBeSent = ( request_id: requestId, url: sanitized.url, origin: sanitized.origin ?? "", - method: stringValue(request.method) ?? "GET", - resource_type: stringValue(params.type) ?? null, + method: cdpStringValue(request.method) ?? "GET", + resource_type: cdpStringValue(params.type) ?? null, status: null, mime_type: null, encoded_data_length: null, initiator: { - type: stringValue(initiator?.type) ?? "other", + type: cdpStringValue(initiator?.type) ?? "other", url: initiatorUrl?.url ?? null, line: integerOrNull(initiatorFrame?.lineNumber), column: integerOrNull(initiatorFrame?.columnNumber), @@ -169,7 +169,7 @@ export const handleResponseReceived = ( state: CdpCaptureEventsState, params: UnknownRecord, ): void => { - const requestId = stringValue(params.requestId); + const requestId = cdpStringValue(params.requestId); if (requestId === undefined) { state.completeness.exclude("network_requests", "invalid_protocol_value"); return; @@ -183,7 +183,7 @@ export const handleResponseReceived = ( "network_requests", response === undefined ? "invalid_protocol_value" - : exclusionReasonForUrl(stringValue(response.url)), + : exclusionReasonForUrl(cdpStringValue(response.url)), ); state.network.delete(requestId); return; @@ -191,7 +191,7 @@ export const handleResponseReceived = ( state.network.set(requestId, { ...current, status: numberValue(response.status) ?? null, - mime_type: stringValue(response.mimeType) ?? null, + mime_type: cdpStringValue(response.mimeType) ?? null, }); const metadata = safeResponseMetadata( requestId, @@ -207,7 +207,7 @@ export const handleLoadingFinished = ( state: CdpCaptureEventsState, params: UnknownRecord, ): void => { - const requestId = stringValue(params.requestId); + const requestId = cdpStringValue(params.requestId); if (requestId === undefined) return; const current = state.network.get(requestId); if (current === undefined) return; @@ -233,16 +233,16 @@ export const handleConsoleAPICalled = ( if (source === undefined) { state.completeness.exclude( "console_events", - exclusionReasonForUrl(stringValue(frame.url)), + exclusionReasonForUrl(cdpStringValue(frame.url)), ); return; } const arguments_ = recordsValue(params.args); state.console.push({ - type: stringValue(params.type) ?? "unknown", + type: cdpStringValue(params.type) ?? "unknown", timestamp: numberValue(params.timestamp) ?? 0, argument_types: arguments_.map( - (argument) => stringValue(argument.type) ?? "unknown", + (argument) => cdpStringValue(argument.type) ?? "unknown", ), url: source.url, line: integerOrNull(frame.lineNumber), @@ -287,14 +287,14 @@ export const handleWebSocketFrame = ( params: UnknownRecord, direction: "sent" | "received", ): void => { - const requestId = stringValue(params.requestId); + const requestId = cdpStringValue(params.requestId); if ( requestId === undefined || (!state.network.has(requestId) && !state.allowedWebSockets.has(requestId)) ) return; const response = recordValue(params.response); - const payload = stringValue(response?.payloadData) ?? ""; + const payload = cdpStringValue(response?.payloadData) ?? ""; const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0)); const decoded = opcode === 1 ? undefined : decodeBase64(payload); if (opcode !== 1 && decoded === undefined) @@ -327,8 +327,8 @@ export const handleWebSocketCreated = ( state: CdpCaptureEventsState, params: UnknownRecord, ): void => { - const requestId = stringValue(params.requestId); - const rawUrl = stringValue(params.url); + const requestId = cdpStringValue(params.requestId); + const rawUrl = cdpStringValue(params.url); if (requestId === undefined) { state.completeness.exclude( "websocket_connections", @@ -378,8 +378,8 @@ export const handleFrameNavigated = ( ) ) return; - const rawUrl = stringValue(frame?.url); - const loaderId = stringValue(frame?.loaderId); + const rawUrl = cdpStringValue(frame?.url); + const loaderId = cdpStringValue(frame?.loaderId); // `frameNavigated` re-fires for a document already reported (same URL and // loader). Treating that as a navigation aborted a valid capture with // `target_changed`, which is a false claim that the target moved. diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts index 5ff9999864..9b5358e8a8 100644 --- a/src/browser/CdpCaptureEventHelpers.ts +++ b/src/browser/CdpCaptureEventHelpers.ts @@ -3,7 +3,7 @@ import { numberValue, recordValue, recordsValue, - stringValue, + cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; import type { CdpEvent } from "./CdpConnection.js"; @@ -17,10 +17,10 @@ export const isMainFrameNavigation = ( if (params === undefined) return false; const frameId = event.method === "Page.frameNavigated" - ? stringValue(recordValue(params.frame)?.id) + ? cdpStringValue(recordValue(params.frame)?.id) : event.method === "Page.frameRequestedNavigation" || event.method === "Page.navigatedWithinDocument" - ? stringValue(params.frameId) + ? cdpStringValue(params.frameId) : undefined; return frameId === mainFrameId; }; @@ -38,7 +38,7 @@ export const initiatorLocation = ( if (frame !== undefined) return frame; stack = recordValue(stack.parent); } - return stringValue(initiator?.url) === undefined ? undefined : initiator; + return cdpStringValue(initiator?.url) === undefined ? undefined : initiator; }; export const exclusionReasonForUrl = ( @@ -69,7 +69,7 @@ export const isJsonContentType = ( for (const [name, value] of Object.entries(headers)) if ( name.toLowerCase() === "content-type" && - isJsonMediaType(stringValue(value)) + isJsonMediaType(cdpStringValue(value)) ) return true; return false; @@ -85,7 +85,7 @@ export const isJsonMediaType = (value: string | null | undefined): boolean => { export const consolePrimitive = ( value: UnknownRecord, ): { readonly type: string; readonly text: string } | undefined => { - const type = stringValue(value.type); + const type = cdpStringValue(value.type); switch (type) { case "string": return typeof value.value === "string" diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index 141082a661..db4922edf4 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -13,7 +13,7 @@ import { numberValue, recordValue, recordsValue, - stringValue, + cdpStringValue, } from "./CdpCaptureValues.js"; /** Capture redacted storage metadata for an authorized page and its origin. */ @@ -108,8 +108,8 @@ const capturedCaches = ( values: readonly Record[], ): CapturedCache[] => values.flatMap((cache) => { - const name = stringValue(cache.cacheName); - const id = stringValue(cache.cacheId); + const name = cdpStringValue(cache.cacheName); + const id = cdpStringValue(cache.cacheId); return name === undefined || id === undefined ? [] : [{ name, id }]; }); @@ -130,8 +130,8 @@ const storageItems = async ( return { items: [], complete: false }; const items = entries.flatMap((entry) => { if (!Array.isArray(entry)) return []; - const key = stringValue(entry[0]); - const value = stringValue(entry[1]); + const key = cdpStringValue(entry[0]); + const value = cdpStringValue(entry[1]); return key === undefined || value === undefined ? [] : [{ key, value }]; }); return { items, complete: items.length === entries.length }; @@ -140,7 +140,7 @@ const storageItems = async ( const stringArray = (value: unknown): readonly string[] => Array.isArray(value) ? value.flatMap((item) => { - const text = stringValue(item); + const text = cdpStringValue(item); return text === undefined ? [] : [text]; }) : []; diff --git a/src/browser/CdpCaptureStorageFingerprints.ts b/src/browser/CdpCaptureStorageFingerprints.ts index efec5ab740..4aacef6764 100644 --- a/src/browser/CdpCaptureStorageFingerprints.ts +++ b/src/browser/CdpCaptureStorageFingerprints.ts @@ -9,7 +9,11 @@ import type { import { compareCodePoints } from "../domain/canonicalOrdering.js"; import { CdpConnection } from "./CdpConnection.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; -import { recordValue, recordsValue, stringValue } from "./CdpCaptureValues.js"; +import { + recordValue, + recordsValue, + cdpStringValue, +} from "./CdpCaptureValues.js"; type StorageFingerprint = WebPageInspection["storage"]["content_fingerprints"][number]; @@ -132,10 +136,10 @@ const addCookies = async ( const cookies = recordsValue(cookieValues); if (cookies.length !== cookieValues.length) run.state.complete = false; for (const cookie of cookies) { - const name = stringValue(cookie.name); - const value = stringValue(cookie.value); - const domain = stringValue(cookie.domain); - const path = stringValue(cookie.path); + const name = cdpStringValue(cookie.name); + const value = cdpStringValue(cookie.value); + const domain = cdpStringValue(cookie.domain); + const path = cdpStringValue(cookie.path); if ( name === undefined || value === undefined || @@ -176,7 +180,7 @@ const addIndexedDb = async ( run.state.complete = false; continue; } - if (stringValue(database.name) !== databaseName) { + if (cdpStringValue(database.name) !== databaseName) { run.state.complete = false; continue; } @@ -194,7 +198,7 @@ const addIndexedDb = async ( complete: indexedDbSchemaComplete(stores), }); for (const store of stores) { - const objectStoreName = stringValue(store.name); + const objectStoreName = cdpStringValue(store.name); if (objectStoreName === undefined) { run.state.complete = false; continue; @@ -209,11 +213,11 @@ const addIndexedDb = async ( }; const indexedDbStoreSchema = (store: Readonly>) => ({ - name: stringValue(store.name) ?? null, + name: cdpStringValue(store.name) ?? null, key_path: stableValue(store.keyPath), auto_increment: store.autoIncrement === true, indexes: recordsValue(store.indexes).map((index) => ({ - name: stringValue(index.name) ?? null, + name: cdpStringValue(index.name) ?? null, key_path: stableValue(index.keyPath), unique: index.unique === true, multi_entry: index.multiEntry === true, @@ -225,14 +229,14 @@ const indexedDbSchemaComplete = ( ): boolean => stores.every( (store) => - stringValue(store.name) !== undefined && + cdpStringValue(store.name) !== undefined && stableValueComplete(store.keyPath) && typeof store.autoIncrement === "boolean" && Array.isArray(store.indexes) && recordsValue(store.indexes).length === store.indexes.length && recordsValue(store.indexes).every( (index) => - stringValue(index.name) !== undefined && + cdpStringValue(index.name) !== undefined && stableValueComplete(index.keyPath) && typeof index.unique === "boolean" && typeof index.multiEntry === "boolean", @@ -358,8 +362,8 @@ const addCacheEntry = async ( cache: CapturedCache, entry: Readonly>, ): Promise => { - const requestURL = stringValue(entry.requestURL); - const requestMethod = stringValue(entry.requestMethod); + const requestURL = cdpStringValue(entry.requestURL); + const requestMethod = cdpStringValue(entry.requestMethod); if (requestURL === undefined || requestMethod === undefined) { run.state.complete = false; return; @@ -370,8 +374,8 @@ const addCacheEntry = async ( } const headerRecords = recordsValue(entry.requestHeaders); const requestHeaders = headerRecords.flatMap((header) => { - const name = stringValue(header.name); - const value = stringValue(header.value); + const name = cdpStringValue(header.name); + const value = cdpStringValue(header.value); return name === undefined || value === undefined ? [] : [{ name, value }]; }); if ( @@ -387,7 +391,7 @@ const addCacheEntry = async ( { cacheId: cache.id, requestURL, requestHeaders }, run.limitations, ); - const body = stringValue(recordValue(recordValue(raw)?.response)?.body); + const body = cdpStringValue(recordValue(recordValue(raw)?.response)?.body); const decoded = body === undefined ? null : decodeBase64(body); const complete = decoded !== null; if (!complete) run.state.complete = false; diff --git a/src/browser/CdpCaptureValues.ts b/src/browser/CdpCaptureValues.ts index 8eff2034b6..a69cdfefe9 100644 --- a/src/browser/CdpCaptureValues.ts +++ b/src/browser/CdpCaptureValues.ts @@ -20,7 +20,7 @@ export const recordsValue = (value: unknown): readonly UnknownRecord[] => }) : []; -export const stringValue = (value: unknown): string | undefined => +export const cdpStringValue = (value: unknown): string | undefined => typeof value === "string" ? value : undefined; export const numberValue = (value: unknown): number | undefined => @@ -49,7 +49,7 @@ export const allowedSanitizedUrl = ( value: unknown, allowedOrigins: ReadonlySet, ): ReturnType | undefined => { - const text = stringValue(value); + const text = cdpStringValue(value); if (text === undefined) return undefined; const sanitized = sanitizeBrowserUrl(text); return sanitized.origin !== null && allowedOrigins.has(sanitized.origin) diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index 9851909e38..8fb8e7a3a3 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -14,7 +14,7 @@ import { recordValue, recordsValue, requiredRecord, - stringValue, + cdpStringValue, } from "./CdpCaptureValues.js"; import { walkFrameTrees } from "./CdpCaptureDocuments.js"; import { @@ -220,9 +220,9 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => { const params = recordValue(event.params); if (params === undefined) return undefined; if (event.method === "Page.frameNavigated") - return stringValue(recordValue(params.frame)?.id); + return cdpStringValue(recordValue(params.frame)?.id); if (event.method === "Page.navigatedWithinDocument") - return stringValue(params.frameId); + return cdpStringValue(params.frameId); return undefined; }; @@ -238,15 +238,15 @@ const captureFrames = async ( completeness.exclude("frames", "invalid_protocol_value"), )) { const frame = recordValue(tree.frame); - const frameId = stringValue(frame?.id); - const path = await authorizedElectronFile(stringValue(frame?.url) ?? ""); + const frameId = cdpStringValue(frame?.id); + const path = await authorizedElectronFile(cdpStringValue(frame?.url) ?? ""); if (frameId === undefined || path === undefined) { completeness.exclude("frames", "out_of_target_scope"); continue; } frames.push({ frame_id: frameId, - parent_frame_id: stringValue(frame?.parentId) ?? null, + parent_frame_id: cdpStringValue(frame?.parentId) ?? null, file_path: path, }); } @@ -274,7 +274,7 @@ const captureResources = async ( if (tree === undefined) break; for (const resource of recordsValue(tree.resources)) { const path = await authorizedElectronFile( - stringValue(resource.url) ?? "", + cdpStringValue(resource.url) ?? "", ); if (path === undefined) { completeness.exclude("resources", "out_of_target_scope"); @@ -282,8 +282,8 @@ const captureResources = async ( } const item = { file_path: path, - type: stringValue(resource.type) ?? "Other", - mime_type: stringValue(resource.mimeType) ?? "", + type: cdpStringValue(resource.type) ?? "Other", + mime_type: cdpStringValue(resource.mimeType) ?? "", content_size: numberValue(resource.contentSize) === undefined ? null @@ -358,7 +358,7 @@ const captureDom = async ( const mainFilePath = async (result: unknown): Promise => { const frameTree = recordValue(requiredRecord(result).frameTree); const frame = recordValue(frameTree?.frame); - return await authorizedElectronFile(stringValue(frame?.url) ?? ""); + return await authorizedElectronFile(cdpStringValue(frame?.url) ?? ""); }; const arrayValue = (value: unknown): readonly unknown[] => diff --git a/src/browser/CdpElectronScriptEvents.ts b/src/browser/CdpElectronScriptEvents.ts index b81b6b9dbd..7376e03e0e 100644 --- a/src/browser/CdpElectronScriptEvents.ts +++ b/src/browser/CdpElectronScriptEvents.ts @@ -1,6 +1,10 @@ import type { CdpEvent } from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; -import { numberValue, recordValue, stringValue } from "./CdpCaptureValues.js"; +import { + numberValue, + recordValue, + cdpStringValue, +} from "./CdpCaptureValues.js"; export interface ElectronScriptDraft { readonly scriptId: string; @@ -35,8 +39,8 @@ export const ingestElectronScriptEvent = (input: { } if (input.event.method !== "Debugger.scriptParsed") return; const value = recordValue(input.event.params); - const scriptId = stringValue(value?.scriptId); - const rawUrl = stringValue(value?.url); + const scriptId = cdpStringValue(value?.scriptId); + const rawUrl = cdpStringValue(value?.url); if (scriptId === undefined) { input.completeness.exclude("scripts", "invalid_protocol_value"); return; @@ -48,10 +52,10 @@ export const ingestElectronScriptEvent = (input: { input.scripts.push({ scriptId, rawUrl, - hash: stringValue(value?.hash) ?? "", + hash: cdpStringValue(value?.hash) ?? "", length: nonnegativeInteger(value?.length), isModule: value?.isModule === true, - language: stringValue(value?.scriptLanguage) ?? null, + language: cdpStringValue(value?.scriptLanguage) ?? null, executionContextKey: executionContextKey(value?.executionContextId), }); }; @@ -63,7 +67,7 @@ const retainExecutionContext = ( const parameters = recordValue(event.params); const runtimeContext = recordValue(parameters?.context); const contextKey = executionContextKey(runtimeContext?.id); - const frameId = stringValue(recordValue(runtimeContext?.auxData)?.frameId); + const frameId = cdpStringValue(recordValue(runtimeContext?.auxData)?.frameId); if (contextKey === null || frameId === undefined) return; frames.set(contextKey, frameId); }; diff --git a/src/browser/CdpElectronScripts.ts b/src/browser/CdpElectronScripts.ts index e394334ffb..3d51cef9ea 100644 --- a/src/browser/CdpElectronScripts.ts +++ b/src/browser/CdpElectronScripts.ts @@ -6,7 +6,7 @@ import { canonicalDigest } from "../domain/comparisonSemantics.js"; import { createWebTextArtifact } from "../domain/webContentArtifact.js"; import type { CdpConnection } from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; -import { requiredRecord, stringValue } from "./CdpCaptureValues.js"; +import { requiredRecord, cdpStringValue } from "./CdpCaptureValues.js"; import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; @@ -94,7 +94,7 @@ const captureScriptSource = async ( input.signal, ), ); - const text = stringValue(result.scriptSource) ?? ""; + const text = cdpStringValue(result.scriptSource) ?? ""; return { source: { included: true, diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts index 5847bd47fb..63a6224907 100644 --- a/src/browser/CdpElectronWorkers.ts +++ b/src/browser/CdpElectronWorkers.ts @@ -4,7 +4,11 @@ import type { } from "../domain/electronObservation.js"; import type { CdpConnection } from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; -import { recordValue, recordsValue, stringValue } from "./CdpCaptureValues.js"; +import { + recordValue, + recordsValue, + cdpStringValue, +} from "./CdpCaptureValues.js"; import type { CdpEndpointTarget } from "./CdpEndpoint.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; @@ -35,7 +39,7 @@ export const captureElectronWorkers = async (input: { } const workers: ElectronPageInspection["workers"] = []; for (const target of recordsValue(recordValue(result)?.targetInfos)) { - const type = stringValue(target.type) ?? ""; + const type = cdpStringValue(target.type) ?? ""; if (!type.includes("worker")) continue; const openerTargetId = boundedTargetField(target.openerId); const parentFrameId = boundedTargetField(target.parentFrameId); @@ -46,7 +50,7 @@ export const captureElectronWorkers = async (input: { input.completeness.exclude("workers", "out_of_target_scope"); continue; } - const path = await authorizedElectronFile(stringValue(target.url) ?? ""); + const path = await authorizedElectronFile(cdpStringValue(target.url) ?? ""); if (path === undefined) { input.completeness.exclude("workers", "out_of_target_scope"); continue; @@ -71,6 +75,6 @@ export const captureElectronWorkers = async (input: { }; const boundedTargetField = (value: unknown): string | null => { - const field = stringValue(value); + const field = cdpStringValue(value); return field === undefined || field === "" ? null : field; }; diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 0ba2389122..a5618b71f1 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -14,7 +14,7 @@ import { isHttpUrl, numberValue, recordValue, - stringValue, + cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; import { mainFrameUrl } from "./CdpCaptureDocuments.js"; @@ -218,7 +218,7 @@ class TimelineCapture { this.#add({ type: "navigation_requested", params, - rawUrl: stringValue(params.url) ?? null, + rawUrl: cdpStringValue(params.url) ?? null, requestId: null, detail: reason, }); @@ -227,7 +227,7 @@ class TimelineCapture { #navigationCommitted(event: CdpEvent, params: UnknownRecord): void { const frame = recordValue(params.frame); if (!isMainFrameNavigation(event, this.mainFrameId)) return; - const rawUrl = stringValue(frame?.url); + const rawUrl = cdpStringValue(frame?.url); const destination = scopedUrl(rawUrl, this.allowedOrigins); this.#add({ type: this.#pendingReload ? "same_origin_reload" : "navigation_committed", @@ -247,7 +247,7 @@ class TimelineCapture { #sameDocument(event: CdpEvent, params: UnknownRecord): void { if (!isMainFrameNavigation(event, this.mainFrameId)) return; - const rawUrl = stringValue(params.url); + const rawUrl = cdpStringValue(params.url); const destination = scopedUrl(rawUrl, this.allowedOrigins); this.#add({ type: "same_document_navigation", @@ -265,21 +265,21 @@ class TimelineCapture { } #redirect(params: UnknownRecord): void { - const resourceType = stringValue(params.type); + const resourceType = cdpStringValue(params.type); if (resourceType !== undefined && resourceType !== "Document") return; if ( - stringValue(params.frameId) !== this.mainFrameId || + cdpStringValue(params.frameId) !== this.mainFrameId || recordValue(params.redirectResponse) === undefined ) return; const request = recordValue(params.request); - const rawUrl = stringValue(request?.url); + const rawUrl = cdpStringValue(request?.url); const destination = scopedUrl(rawUrl, this.allowedOrigins); this.#add({ type: "redirect", params, rawUrl: rawUrl ?? null, - requestId: stringValue(params.requestId) ?? null, + requestId: cdpStringValue(params.requestId) ?? null, detail: null, }); if (destination.scope !== "approved") { @@ -289,19 +289,19 @@ class TimelineCapture { } #loadingFailed(params: UnknownRecord): void { - const frame = stringValue(params.frameId); + const frame = cdpStringValue(params.frameId); if (frame !== undefined && frame !== this.mainFrameId) return; this.#add({ type: "load_failed", params, rawUrl: null, - requestId: stringValue(params.requestId) ?? null, + requestId: cdpStringValue(params.requestId) ?? null, detail: safeNetworkError(params.errorText), }); } #lifecycle(params: UnknownRecord): void { - if (stringValue(params.frameId) !== this.mainFrameId) return; + if (cdpStringValue(params.frameId) !== this.mainFrameId) return; this.#add({ type: "lifecycle", params, @@ -344,7 +344,7 @@ const observationEventMatches = ( return event.sessionId === sessionId; return ( sessionId !== undefined && - stringValue(recordValue(event.params)?.sessionId) === sessionId + cdpStringValue(recordValue(event.params)?.sessionId) === sessionId ); }; @@ -405,7 +405,7 @@ const captureFinalUrl = async ( }; const frameId = (result: unknown): string | undefined => - stringValue( + cdpStringValue( recordValue(recordValue(recordValue(result)?.frameTree)?.frame)?.id, ); @@ -423,17 +423,18 @@ const scopedUrl = ( : { url: null, scope: "unsupported" }; }; -const boundedId = (value: unknown): string | null => stringValue(value) ?? null; +const boundedId = (value: unknown): string | null => + cdpStringValue(value) ?? null; const safeDetail = (value: unknown): string | null => { - const detail = stringValue(value)?.toLowerCase(); + const detail = cdpStringValue(value)?.toLowerCase(); return detail !== undefined && /^[a-z0-9_.:-]{1,100}$/u.test(detail) ? detail : null; }; const safeNetworkError = (value: unknown): string | null => { - const error = stringValue(value); + const error = cdpStringValue(value); return error !== undefined && /^net::ERR_[A-Z0-9_]{1,100}$/u.test(error) ? error : null; diff --git a/src/browser/CdpPageCaptureScripts.ts b/src/browser/CdpPageCaptureScripts.ts index b0b43454b8..03c4962b5d 100644 --- a/src/browser/CdpPageCaptureScripts.ts +++ b/src/browser/CdpPageCaptureScripts.ts @@ -10,7 +10,7 @@ import { requiredRecord, sourceExcluded, sourceResult, - stringValue, + cdpStringValue, } from "./CdpCaptureValues.js"; import type { CaptureContext } from "./CdpPageCapture.js"; import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js"; @@ -115,7 +115,7 @@ const captureScriptSource = async ( context.signal, ), ); - const content = stringValue(result.scriptSource) ?? ""; + const content = cdpStringValue(result.scriptSource) ?? ""; return sourceResult(content); }; diff --git a/src/browser/CdpPageCaptureWorkers.ts b/src/browser/CdpPageCaptureWorkers.ts index 6270f17fcd..0d36d1683e 100644 --- a/src/browser/CdpPageCaptureWorkers.ts +++ b/src/browser/CdpPageCaptureWorkers.ts @@ -5,7 +5,7 @@ import { isHttpUrl, recordValue, recordsValue, - stringValue, + cdpStringValue, } from "./CdpCaptureValues.js"; import type { CaptureContext } from "./CdpPageCapture.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; @@ -32,19 +32,19 @@ export const captureWorkers = async ( const items: WebPageInspection["workers"] = []; if (result === undefined) completeness.unavailable("workers"); for (const target of recordsValue(recordValue(result)?.targetInfos)) { - const type = stringValue(target.type) ?? ""; + const type = cdpStringValue(target.type) ?? ""; const url = allowedSanitizedUrl(target.url, allowedOrigins); const relatedToPage = - stringValue(target.openerId) === context.target.id || - (stringValue(target.parentFrameId) !== undefined && - frameIds.has(stringValue(target.parentFrameId) ?? "")); + cdpStringValue(target.openerId) === context.target.id || + (cdpStringValue(target.parentFrameId) !== undefined && + frameIds.has(cdpStringValue(target.parentFrameId) ?? "")); if (!type.includes("worker")) continue; if (!relatedToPage) { completeness.exclude("workers", "out_of_target_scope"); continue; } if (url === undefined) { - const rawUrl = stringValue(target.url); + const rawUrl = cdpStringValue(target.url); completeness.exclude( "workers", rawUrl === undefined || rawUrl === "" @@ -56,13 +56,13 @@ export const captureWorkers = async ( continue; } items.push({ - target_id: stringValue(target.targetId) ?? "", + target_id: cdpStringValue(target.targetId) ?? "", type, url: url.url, origin: url.origin, attached: target.attached === true, - opener_target_id: stringValue(target.openerId) ?? null, - parent_frame_id: stringValue(target.parentFrameId) ?? null, + opener_target_id: cdpStringValue(target.openerId) ?? null, + parent_frame_id: cdpStringValue(target.parentFrameId) ?? null, }); } return items; diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index 8a2a2f54f3..c3aee0f94e 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -2,7 +2,7 @@ import type { WebPageInspection } from "../domain/browserObservation.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { numberValue, - stringValue, + cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; @@ -42,7 +42,7 @@ export const safeResponseMetadata = ( response: { request_id: requestId, url, - mime_type: boundedHeader(stringValue(response.mimeType)), + mime_type: boundedHeader(cdpStringValue(response.mimeType)), content_length: nonnegativeInteger(headers.get("content-length")), content_encoding: boundedHeader(headers.get("content-encoding")), csp, diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index 19b5ad91e7..2e41936446 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -11,7 +11,7 @@ import type { CdpConnection, CdpEvent } from "./CdpConnection.js"; import { allowedSanitizedUrl, recordValue, - stringValue, + cdpStringValue, } from "./CdpCaptureValues.js"; import { mainFrameUrl } from "./CdpCaptureDocuments.js"; import { captureFrames } from "./CdpCaptureDocuments.js"; @@ -63,7 +63,7 @@ export const captureCdpScreenshot = async ( changed: false, leftScope: false, url: beforeUrl, - loaderId: stringValue(initialFrame?.loaderId), + loaderId: cdpStringValue(initialFrame?.loaderId), }; const removeListener = context.connection.onEvent((event) => { if (event.sessionId !== context.sessionId) return; @@ -89,7 +89,7 @@ export const captureCdpScreenshot = async ( } finally { removeListener(); } - const encoded = stringValue(result?.data); + const encoded = cdpStringValue(result?.data); const bytes = encoded === undefined ? undefined : decodeCanonicalBase64(encoded); if (bytes === undefined) @@ -149,8 +149,8 @@ const observeScreenshotNavigation = ( event.method === "Page.frameNavigated" && state.loaderId !== undefined && state.loaderId !== "" && - stringValue(frame?.loaderId) === state.loaderId && - stringValue(frame?.url) === state.url + cdpStringValue(frame?.loaderId) === state.loaderId && + cdpStringValue(frame?.url) === state.url ) return; diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index 9da2cdf514..ae805b19c0 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -16,7 +16,7 @@ import { numberValue, recordValue, recordsValue, - stringValue, + cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; import { @@ -173,10 +173,13 @@ const initialFrameScope = ( completeness.exclude("webmcp_tools", "invalid_protocol_value"), )) { const frame = recordValue(tree.frame); - const frameId = stringValue(frame?.id); + const frameId = cdpStringValue(frame?.id); const prior = frameId === undefined ? undefined : scoped.get(frameId); if (frameId !== undefined && prior !== undefined) - scoped.set(frameId, { ...prior, loaderId: stringValue(frame?.loaderId) }); + scoped.set(frameId, { + ...prior, + loaderId: cdpStringValue(frame?.loaderId), + }); } return scoped; }; @@ -203,7 +206,7 @@ const handleFrameDetached = ( params: UnknownRecord, state: FrameScopeState, ): boolean => { - const frameId = stringValue(params.frameId); + const frameId = cdpStringValue(params.frameId); if (frameId === undefined) { state.completeness.exclude("webmcp_tools", "invalid_protocol_value"); return true; @@ -223,12 +226,12 @@ const handleFrameUpdate = ( state.completeness.exclude("webmcp_tools", "invalid_protocol_value"); return true; } - const frameId = stringValue(frame?.id) ?? stringValue(frame?.frameId); + const frameId = cdpStringValue(frame?.id) ?? cdpStringValue(frame?.frameId); if (frameId === undefined) { state.completeness.exclude("webmcp_tools", "invalid_protocol_value"); return true; } - const rawUrl = stringValue(frame?.url); + const rawUrl = cdpStringValue(frame?.url); // Chromium can briefly commit an inherited, empty document while replacing // a subframe. That intermediate URL says nothing about the frame's eventual // scope or its registered tools; wait for the next committed URL (or the @@ -259,7 +262,7 @@ const updateFrameScope = ( return; } const previous = state.frames.get(frameId); - const loaderId = stringValue(frame?.loaderId); + const loaderId = cdpStringValue(frame?.loaderId); if (hasNewDocument(documentCommitted, previous, url.url, loaderId)) removeFrameTools(frameId, state.tools); if (documentCommitted && state.transientFrames.has(frameId)) @@ -269,7 +272,7 @@ const updateFrameScope = ( origin: url.origin, ...(loaderId === undefined ? {} : { loaderId }), parentFrameId: - (stringValue(frame?.parentId) ?? + (cdpStringValue(frame?.parentId) ?? state.frames.get(frameId)?.parentFrameId ?? "") || null, @@ -364,8 +367,8 @@ const ingestWebMcpEvent = (options: WebMcpIngestOptions): void => { if (params === undefined) return; if (event.method === "WebMCP.toolsRemoved") { for (const removed of recordsValue(params.tools)) { - const name = stringValue(removed.name); - const frameId = stringValue(removed.frameId); + const name = cdpStringValue(removed.name); + const frameId = cdpStringValue(removed.frameId); if (name === undefined || frameId === undefined) continue; for (const [key, tool] of tools) if (tool.name === name && tool.frame_id === frameId) tools.delete(key); @@ -374,7 +377,7 @@ const ingestWebMcpEvent = (options: WebMcpIngestOptions): void => { } if (event.method !== "WebMCP.toolsAdded") return; for (const declared of recordsValue(params.tools)) { - const frameId = stringValue(declared.frameId); + const frameId = cdpStringValue(declared.frameId); if (frameId !== undefined && transientFrames.has(frameId)) { if (!resolvedTransientFrames.has(frameId)) continue; transientFrames.delete(frameId); @@ -395,8 +398,8 @@ const normalizeTool = ( >, completeness: CdpCaptureCompleteness, ): WebMcpDiscovery["tools"]["items"][number] | undefined => { - const frameId = stringValue(value.frameId); - const name = stringValue(value.name); + const frameId = cdpStringValue(value.frameId); + const name = cdpStringValue(value.name); const frame = frameId === undefined ? undefined : frames.get(frameId); if ( frameId === undefined || @@ -407,7 +410,7 @@ const normalizeTool = ( completeness.exclude("webmcp_tools", "out_of_target_scope"); return undefined; } - const description = stringValue(value.description) ?? ""; + const description = cdpStringValue(value.description) ?? ""; const annotations = recordValue(value.annotations); return { tool_key: toolKey(frame.url, frameId, name), diff --git a/src/browser/V8InspectorProvider.ts b/src/browser/V8InspectorProvider.ts index a6f957edfe..39faa4bf3f 100644 --- a/src/browser/V8InspectorProvider.ts +++ b/src/browser/V8InspectorProvider.ts @@ -19,7 +19,7 @@ import { err, ok, type Result } from "../domain/result.js"; import { numberValue, recordValue, - stringValue, + cdpStringValue, delayWithCancellation, } from "./CdpCaptureValues.js"; import { CdpConnection, type CdpEvent } from "./CdpConnection.js"; @@ -331,7 +331,7 @@ const ingestScript = ( ): void => { state.scriptsObserved += 1; const value = recordValue(event.params); - const rawUrl = stringValue(value?.url); + const rawUrl = cdpStringValue(value?.url); if (rawUrl === undefined || rawUrl === "") { state.invalidScripts += 1; retainEvent(state, 0); @@ -340,7 +340,7 @@ const ingestScript = ( const draft: ScriptDraft = { rawUrl, executionContextKey: contextKey(value?.executionContextId), - cdpHash: stringValue(value?.hash) ?? null, + cdpHash: cdpStringValue(value?.hash) ?? null, length: nonnegativeInteger(value?.length), isModule: value?.isModule === true, }; @@ -375,7 +375,7 @@ const ingestContext = ( } const origin = event.method === "Runtime.executionContextCreated" - ? (stringValue(runtimeContext?.origin) ?? null) + ? (cdpStringValue(runtimeContext?.origin) ?? null) : null; const draft: ContextDraft = { contextKey: key, diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 72b44439a3..e8a84f7615 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -14,6 +14,10 @@ import type { import { webSourceMapsSchema } from "../domain/webBundleAnalysis.js"; import { createWebTextArtifact } from "../domain/webContentArtifact.js"; import { safeParseJson } from "../domain/safeJson.js"; +import { + flattenSourceMapLeaves, + isVersion3Map, +} from "../domain/sourceMapEnvelope.js"; export interface WebSourceMapRequest { readonly scriptKey: string; @@ -278,32 +282,13 @@ const validSourceMapEnvelope = (text: string): boolean => { const parsedResult = safeParseJson(text); if (!parsedResult.ok) return false; const parsed: unknown = parsedResult.value; - if (!isRecord(parsed) || parsed.version !== 3) return false; - if (typeof parsed.mappings === "string") return validSourceMapLeaf(parsed); - if (!Array.isArray(parsed.sections)) return false; - const pending: unknown[] = [...parsed.sections]; - while (pending.length > 0) { - const section = pending.pop(); - if ( - !isRecord(section) || - !isRecord(section.offset) || - !validSourceMapOffset(section.offset.line) || - !validSourceMapOffset(section.offset.column) || - !("map" in section) - ) - return false; - const map = section.map; - if (!isRecord(map) || map.version !== 3) return false; - if (Array.isArray(map.sections)) - for (const child of map.sections) pending.push(child); - else if (!validSourceMapLeaf(map)) return false; - } - return true; + if (isVersion3Map(parsed) && typeof parsed.mappings === "string") + return validSourceMapLeaf(parsed); + const leaves = flattenSourceMapLeaves(parsed, { validateOffsets: true }); + if (leaves === undefined) return false; + return leaves.every(validSourceMapLeaf); }; -const validSourceMapOffset = (value: unknown): boolean => - typeof value === "number" && Number.isSafeInteger(value) && value >= 0; - const validSourceMapLeaf = (map: Readonly>): boolean => typeof map.mappings === "string" && Array.isArray(map.sources) && @@ -380,9 +365,6 @@ const sourceMediaType = (source: string | null): string => ? "text/typescript" : "text/javascript"; -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - const originalDependency = ( node: t.Node, ): { diff --git a/src/domain/javascriptApplicationVersionKeys.ts b/src/domain/javascriptApplicationVersionKeys.ts index 898fde60af..ef17d3d96d 100644 --- a/src/domain/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascriptApplicationVersionKeys.ts @@ -239,9 +239,9 @@ const semanticValue = ( if (node.kind === "ipc-channel") return properties.resolution === "dynamic" ? null - : (stringValue(properties.channel) ?? firstLabel(node)); + : (nonEmptyString(properties.channel) ?? firstLabel(node)); if (node.kind === "context-bridge-api") - return firstLabel(node) ?? stringValue(properties.api_name); + return firstLabel(node) ?? nonEmptyString(properties.api_name); if (node.kind === "native-export") return ( joined( @@ -258,8 +258,8 @@ const semanticValue = ( ].includes(node.kind) ) return joined(properties.declared_path, properties.resolved_path); - if (node.kind === "package") return stringValue(properties.name); - return canonicalPath(node) ?? stringValue(properties.path); + if (node.kind === "package") return nonEmptyString(properties.name); + return canonicalPath(node) ?? nonEmptyString(properties.path); }; const canonicalPath = (node: ApplicationNode): string | null => @@ -286,11 +286,11 @@ const uniqueStringProperty = ( }; const joined = (...values: readonly unknown[]): string | null => { - const strings = values.map(stringValue); + const strings = values.map(nonEmptyString); return strings.some((value) => value === null) ? null : strings.join("\0"); }; -const stringValue = (value: unknown): string | null => +const nonEmptyString = (value: unknown): string | null => typeof value === "string" && value !== "" ? value : null; const stringList = (value: unknown): string[] => diff --git a/src/domain/javascriptAstFingerprint.ts b/src/domain/javascriptAstFingerprint.ts index 334f2490b4..deda5e3306 100644 --- a/src/domain/javascriptAstFingerprint.ts +++ b/src/domain/javascriptAstFingerprint.ts @@ -4,6 +4,7 @@ import * as t from "@babel/types"; import { compareCodePoints } from "./canonicalOrdering.js"; import { propertyName } from "./javascriptAstValues.js"; +import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; /** Static CommonJS export names. */ export interface StaticExports { @@ -123,6 +124,3 @@ const memberPath = (node: t.Node | null | undefined): string => { } return ""; }; - -const stringValue = (node: t.Node | null | undefined): string | undefined => - t.isStringLiteral(node) ? node.value : undefined; diff --git a/src/domain/sourceMapEnvelope.test.ts b/src/domain/sourceMapEnvelope.test.ts new file mode 100644 index 0000000000..27dfbbaa43 --- /dev/null +++ b/src/domain/sourceMapEnvelope.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { flattenSourceMapLeaves, isVersion3Map } from "./sourceMapEnvelope.js"; + +const leaf = { + version: 3, + mappings: "AAAA", + sources: ["app.ts"], + names: [], +}; + +describe("source-map envelope walk", () => { + it("accepts version-3 maps and rejects other shapes", () => { + expect(isVersion3Map(leaf)).toBe(true); + expect(isVersion3Map({ version: 2, mappings: "AAAA" })).toBe(false); + expect(isVersion3Map(null)).toBe(false); + expect(isVersion3Map([{ version: 3 }])).toBe(false); + }); + + it("collects indexed section leaves without validating offsets by default", () => { + const root = { + version: 3, + sections: [ + { offset: { line: 0, column: 0 }, map: leaf }, + { offset: { line: 10, column: 0 }, map: { ...leaf, sources: [] } }, + ], + }; + expect(flattenSourceMapLeaves(root)).toHaveLength(2); + }); + + it("rejects non-map sections and enforces offsets when requested", () => { + const missing = { + version: 3, + sections: [{ offset: { line: 0, column: 0 } }], + }; + expect(flattenSourceMapLeaves(missing)).toBeUndefined(); + const badOffset = { + version: 3, + sections: [{ offset: { line: -1, column: 0 }, map: leaf }], + }; + expect( + flattenSourceMapLeaves(badOffset, { validateOffsets: true }), + ).toBeUndefined(); + expect(flattenSourceMapLeaves(badOffset)).toHaveLength(1); + }); + + it("rejects non-version-3 roots and nested maps", () => { + expect(flattenSourceMapLeaves({ version: 3 })).toHaveLength(1); + expect(flattenSourceMapLeaves({ mappings: "AAAA" })).toBeUndefined(); + expect( + flattenSourceMapLeaves({ + version: 3, + sections: [{ offset: { line: 0, column: 0 }, map: { version: 2 } }], + }), + ).toBeUndefined(); + }); +}); diff --git a/src/domain/sourceMapEnvelope.ts b/src/domain/sourceMapEnvelope.ts new file mode 100644 index 0000000000..b5c78f1d4d --- /dev/null +++ b/src/domain/sourceMapEnvelope.ts @@ -0,0 +1,58 @@ +/** + * Structural walk over indexed source-map envelopes. Both the browser + * source-map fetcher and the artifact source-map reader accept version-3 + * maps with recursively indexed `sections`; this module owns that walk so + * the two validators cannot drift. Leaf content checks (mappings, sources, + * names, contents) stay with the callers, which enforce different + * strictness. + */ +type SourceMapRecord = Readonly>; + +const isRecord = (value: unknown): value is SourceMapRecord => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Whether a parsed value has the version-3 map shape. */ +export const isVersion3Map = (value: unknown): value is SourceMapRecord => + isRecord(value) && value.version === 3; + +const isValidOffset = (value: unknown): boolean => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; + +export interface FlattenSourceMapOptions { + /** Require each section offset to carry safe-integer line/column. */ + readonly validateOffsets?: boolean; +} + +/** + * Collect leaf maps from an indexed source-map envelope, or undefined when + * the envelope is not a version-3 map. A map carrying neither `mappings` + * nor `sections` yields itself as the single leaf; callers decide whether + * that leaf is valid content. + */ +export const flattenSourceMapLeaves = ( + root: unknown, + options?: FlattenSourceMapOptions, +): readonly SourceMapRecord[] | undefined => { + if (!isVersion3Map(root)) return undefined; + const leaves: SourceMapRecord[] = []; + const pending: unknown[] = [root]; + while (pending.length > 0) { + const map = pending.pop(); + if (!isVersion3Map(map)) return undefined; + if (Array.isArray(map.sections)) { + for (const section of map.sections) { + if (!isRecord(section) || !isRecord(section.map)) return undefined; + if (options?.validateOffsets === true) { + if (!isRecord(section.offset)) return undefined; + if ( + !isValidOffset(section.offset.line) || + !isValidOffset(section.offset.column) + ) + return undefined; + } + pending.push(section.map); + } + } else leaves.push(map); + } + return leaves; +};