From f983c718c33789ee40aec43f84315f6b53e95bde Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:09:17 +0800 Subject: [PATCH] fix(browser): bound aggregate source-map decoding before expansion --- docs/web-source-location.md | 9 + src/browser/WebSourceMapFetcher.ts | 142 ++++++++++---- src/domain/webSourceLocation.ts | 1 + src/javascript/sourceMaps/SourceMapFormat.ts | 180 ++++++++++++++---- .../browser/webSourceMapFetcher.test.ts | 141 ++++++++++++++ 5 files changed, 394 insertions(+), 79 deletions(-) diff --git a/docs/web-source-location.md b/docs/web-source-location.md index dae31c3ad..6e677b08f 100644 --- a/docs/web-source-location.md +++ b/docs/web-source-location.md @@ -91,6 +91,15 @@ preceding browser mappings when a nested section starts with a gap or has no child leaves. Raw map text, original source identities and the dedicated decoder profile are preserved. +Browser collection accepts up to 64 MiB of map responses per inspection and +preflights indexed sections, sources, names, generated rows and mapping segments +against a 262144-record decode budget before upstream flattening. Indexed maps +are limited to 64 nested levels. These structural limits bound decoded object +growth while preserving large embedded `sourcesContent` strings within the +response-byte budget; exceeding a decoder limit returns a `fetch_failed` item. +Malformed UTF-8 map responses are reported as invalid rather than repaired with +replacement characters. + Focused tests cover duplicate mappings, indexed offsets/overlap/local indexes, 32-bit VLQ overflow, nullable content, UTF-16/CRLF positions, immutable artifact identity, cancellation, late output closure, cleanup failures, SDK schemas and diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 6adb381f3..3cbed28f0 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -12,7 +12,6 @@ import { isUrlLikeModuleSpecifier } from "../domain/webBundleAnalyzerAst.js"; import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascriptSemanticAnalysis.js"; import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js"; import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js"; -import { hasValidSourceMapContents } from "../domain/sourceMapContents.js"; import type { AnalyzeWebBundleInput, WebSourceMapItem, @@ -22,9 +21,9 @@ 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"; + SourceMapFormatFailure, + validateSourceMapStructure, +} from "../javascript/sourceMaps/SourceMapFormat.js"; export interface WebSourceMapRequest { readonly scriptKey: string; @@ -53,6 +52,11 @@ type ParsedSourceMapItem = Extract< SourceMapItem, { status: "included" | "partial" } >; +interface SourceMapDecodeContext { + readonly signal: AbortSignal | undefined; + readonly deadlineAt: number; + readonly budget: { records: number }; +} /** Fetch and validate approved source maps without browser credentials. */ export const fetchWebSourceMaps = async ( @@ -70,11 +74,17 @@ export const fetchWebSourceMaps = async ( const abortFromCaller = (): void => operationController.abort(signal?.reason); signal?.addEventListener("abort", abortFromCaller, { once: true }); const operationSignal = operationController.signal; - const budget = { retainedBytes: 0 }; + const deadlineAt = + Date.now() + (host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS); + const budget = { + retainedBytes: 0, + deadlineAt, + decodedRecords: { records: 0 }, + }; try { for (const request of requests) { if (signal?.aborted === true) throw signal.reason; - if (operationSignal.aborted) { + if (operationSignal.aborted || Date.now() >= deadlineAt) { items.push( emptySourceMapItem( request, @@ -90,25 +100,28 @@ export const fetchWebSourceMaps = async ( clearTimeout(timeout); signal?.removeEventListener("abort", abortFromCaller); } - const retained = items.filter( - ({ status }) => status === "included" || status === "partial", - ).length; + if (signal?.aborted === true) throw signal.reason; return webSourceMapsSchema.parse({ - status: - items.length === 0 - ? "unavailable" - : retained === items.length && - !items.some(({ status }) => status === "partial") - ? "included" - : retained > 0 - ? "partial" - : "unavailable", + status: sourceMapsStatus(items), requested: requests.length, processed: items.length, items, }); }; +const sourceMapsStatus = ( + items: readonly SourceMapItem[], +): SourceMaps["status"] => { + const retained = items.filter( + ({ status }) => status === "included" || status === "partial", + ).length; + if (items.length === 0 || retained === 0) return "unavailable"; + return retained === items.length && + !items.some(({ status }) => status === "partial") + ? "included" + : "partial"; +}; + class SourceMapDeadlineError extends Error { constructor() { super("Source-map fetching exceeded its operation deadline."); @@ -123,12 +136,23 @@ class SourceMapSizeLimitError extends Error { } } +class SourceMapEncodingError extends Error { + constructor() { + super("Source-map response is not valid UTF-8."); + this.name = "SourceMapEncodingError"; + } +} + const fetchOne = async ( request: WebSourceMapRequest, input: AnalyzeWebBundleInput, signal: AbortSignal | undefined, host: SourceMapFetchHost, - budget: { retainedBytes: number }, + budget: { + retainedBytes: number; + deadlineAt: number; + decodedRecords: { records: number }; + }, ): Promise => { if (!approvedUrl(request.fetchUrl, input.allowed_origins)) return emptySourceMapItem( @@ -158,6 +182,7 @@ const fetchOne = async ( `Source-map server returned HTTP ${String(response.status)}.`, ); } + checkOperation(undefined, signal, budget.deadlineAt); return normalizeSourceMap( request, await readBoundedText( @@ -167,6 +192,11 @@ const fetchOne = async ( signal, ), fetchedUrl, + { + signal, + deadlineAt: budget.deadlineAt, + budget: budget.decodedRecords, + }, ); } catch (cause: unknown) { if ( @@ -174,6 +204,8 @@ const fetchOne = async ( !(signal.reason instanceof SourceMapDeadlineError) ) throw cause; + if (cause instanceof SourceMapEncodingError) + return emptySourceMapItem(request, "invalid", cause.message); return emptySourceMapItem( request, "fetch_failed", @@ -260,7 +292,11 @@ const readBoundedText = async ( joined.set(chunk, offset); offset += chunk.byteLength; } - return new TextDecoder().decode(joined); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(joined); + } catch { + throw new SourceMapEncodingError(); + } }; const readWithAbort = ( @@ -346,13 +382,15 @@ type EncodedBrowserSourceMap = EncodedSourceMap | SectionedSourceMap; /** Compose indexed offsets before upstream flattening, retaining parent clipping. */ const sourceMapWithAbsoluteSections = ( - text: string, + value: unknown, + check: () => void, ): EncodedBrowserSourceMap => { - const root = JSON.parse(text) as EncodedBrowserSourceMap; + const root = value as EncodedBrowserSourceMap; if (!("sections" in root)) return root; const sections: Section[] = []; const pending: Section[] = [{ map: root, offset: { line: 0, column: 0 } }]; while (pending.length > 0) { + check(); const node = pending.pop(); if (node === undefined) break; if (!("sections" in node.map)) { @@ -387,17 +425,31 @@ const normalizeSourceMap = ( request: WebSourceMapRequest, text: string, fetchedUrl: string, + context: SourceMapDecodeContext, ): SourceMapItem => { - if (!validSourceMapEnvelope(text)) + const parsedJson = safeParseJson(text); + if (!parsedJson.ok) return emptySourceMapItem( request, "invalid", "Source-map JSON is not a version 3 map.", ); try { - const map = new AnyMap(sourceMapWithAbsoluteSections(text), fetchedUrl); + const check = (): void => + checkOperation(undefined, context.signal, context.deadlineAt); + validateSourceMapStructure(parsedJson.value, { + profile: "browser-collection", + budget: context.budget, + check, + }); + check(); + const map = new AnyMap( + sourceMapWithAbsoluteSections(parsedJson.value, check), + fetchedUrl, + ); const resolvedBySource = new Map(); const originalSources = map.sources.map((source, index) => { + if ((index & 255) === 0) check(); const content = map.sourcesContent?.[index]; const resolved = map.resolvedSources[index] ?? source ?? "[unknown-source]"; @@ -412,6 +464,7 @@ const normalizeSourceMap = ( }); const mappings: ParsedSourceMapItem["mappings"] = []; eachMapping(map, (mapping) => { + if ((mappings.length & 0x3fff) === 0) check(); if ( mapping.source === null || mapping.originalLine === null || @@ -429,7 +482,8 @@ const normalizeSourceMap = ( name: mapping.name ?? null, }); }); - const modules = originalModuleEdges(originalSources); + const modules = originalModuleEdges(originalSources, check); + check(); const parsed = { ...sourceMapContext(request), artifact: createWebTextArtifact(text, "application/source-map+json"), @@ -437,6 +491,7 @@ const normalizeSourceMap = ( original_module_edges: modules.edges, mappings, }; + check(); return modules.incomplete.length === 0 ? { ...parsed, status: "included", limitation: null } : { @@ -445,11 +500,17 @@ const normalizeSourceMap = ( limitation: `Module edges are incomplete: ${modules.incomplete.length} of ${originalSources.filter(({ artifact }) => artifact !== null).length} original sources could not be parsed in full (${modules.incomplete.join(", ")}).`, }; } catch (cause: unknown) { - void cause; + if (cause instanceof SourceMapFormatFailure && cause.reason === "limit") + return emptySourceMapItem(request, "fetch_failed", cause.message); + if ( + context.signal?.aborted === true || + cause instanceof SourceMapDeadlineError + ) + throw cause; return emptySourceMapItem( request, "invalid", - "Source-map mappings could not be decoded safely.", + "Source-map JSON mappings could not be decoded safely.", ); } }; @@ -462,13 +523,16 @@ interface OriginalModuleEdges { const originalModuleEdges = ( sources: ParsedSourceMapItem["original_sources"], + check: () => void, ): OriginalModuleEdges => { const edges: ParsedSourceMapItem["original_module_edges"] = []; const seen = new Set(); const incomplete: string[] = []; for (const source of sources) { + check(); if (source.artifact === null) continue; const parsed = parseJavaScriptSource(source.artifact.text); + check(); if (parsed === null) { incomplete.push(source.source); continue; @@ -479,6 +543,7 @@ const originalModuleEdges = ( let unboundRequires: ReadonlySet | undefined; traverseJavaScriptAst(parsed, { enter: (node) => { + check(); const dependency = originalDependency(node); if (dependency === null) return; const { kind, specifier } = dependency; @@ -518,23 +583,16 @@ const originalModuleEdges = ( return { edges, incomplete }; }; -const validSourceMapEnvelope = (text: string): boolean => { - const parsedResult = safeParseJson(text); - if (!parsedResult.ok) return false; - const parsed: unknown = parsedResult.value; - 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 checkOperation = ( + callerSignal: AbortSignal | undefined, + operationSignal: AbortSignal | undefined, + deadlineAt: number, +): void => { + if (callerSignal?.aborted === true) throw callerSignal.reason; + if (operationSignal?.aborted === true) throw operationSignal.reason; + if (Date.now() >= deadlineAt) throw new SourceMapDeadlineError(); }; -const validSourceMapLeaf = (map: Readonly>): boolean => - typeof map.mappings === "string" && - Array.isArray(map.sources) && - Array.isArray(map.names) && - hasValidSourceMapContents(map.sources.length, map.sourcesContent); - const approvedUrl = ( value: string, allowedOrigins: readonly string[], diff --git a/src/domain/webSourceLocation.ts b/src/domain/webSourceLocation.ts index c4e0ee070..b93eae6e3 100644 --- a/src/domain/webSourceLocation.ts +++ b/src/domain/webSourceLocation.ts @@ -104,6 +104,7 @@ export const WEB_SOURCE_MAP_LIMITS = { outputBytes: 32 * 1024 * 1024, decodedRows: 262144, decodedSegments: 262144, + decodedRecords: 262144, sectionDepth: 64, decodeTimeoutMs: 20000, } as const; diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index 9c3e1db27..f49078628 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js"; +import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js"; const leafSchema = z.object({ version: z.literal(3), @@ -38,6 +39,17 @@ interface PendingMap { readonly stop: Offset | null; } +/** Shared structural constraints configurable only for stricter consumers. */ +export interface SourceMapStructureBudget { + records: number; +} + +export interface SourceMapStructureOptions { + readonly profile?: "browser-collection"; + readonly budget?: SourceMapStructureBudget; + readonly check?: () => void; +} + /** A decoder-profile failure with an actionable producer constraint. */ export class SourceMapFormatFailure extends Error { constructor( @@ -84,9 +96,32 @@ const addOffset = (base: Offset, offset: Offset): Offset => { return result; }; -const addSections = (node: PendingMap, pending: PendingMap[]): void => { +const consumeRecords = (budget: { records: number }, count: number): void => { + if (count > WEB_SOURCE_MAP_LIMITS.decodedRecords - budget.records) + throw new SourceMapFormatFailure( + "limit", + `Source-map structure exceeds the ${WEB_SOURCE_MAP_LIMITS.decodedRecords}-record decoder budget.`, + ); + budget.records += count; +}; + +const addSections = ( + node: PendingMap, + pending: PendingMap[], + budget: { records: number }, + options: SourceMapStructureOptions, +): void => { + options.check?.(); + const header = mapHeaderSchema.parse(node.value); + if (!Array.isArray(header.sections)) + throw new SourceMapFormatFailure( + "format", + "Indexed source-map sections must be an array.", + ); + consumeRecords(budget, header.sections.length); const sections = indexedSchema.parse(node.value).sections; for (const [index, section] of sections.entries()) { + if ((index & 255) === 0) options.check?.(); const previous = sections[index - 1]; if (previous !== undefined && compare(section.offset, previous.offset) <= 0) throw new SourceMapFormatFailure( @@ -133,6 +168,7 @@ const addSections = (node: PendingMap, pending: PendingMap[]): void => { const inspectMappingLayout = ( mappings: string, + check?: () => void, ): { lines: number; segments: number } => { let lines = 1; let segments = 0; @@ -152,7 +188,9 @@ const inspectMappingLayout = ( "Source-map segment requires one, four or five VLQ components.", ); }; + let index = 0; for (const character of mappings) { + if ((index++ & 0x3fff) === 0) check?.(); if (character === "," || character === ";") { finish(); inSegment = false; @@ -189,6 +227,58 @@ const inspectMappingLayout = ( return { lines, segments }; }; +const inspectLeaf = ( + node: PendingMap, + budget: { records: number }, + options: SourceMapStructureOptions, +): z.output => { + options.check?.(); + consumeRecords(budget, 1); + if ( + typeof node.value !== "object" || + node.value === null || + Array.isArray(node.value) + ) + return leafSchema.parse(node.value); + const rawMap = node.value as Readonly>; + const rawSources = rawMap.sources; + const rawNames = rawMap.names; + const rawContents = rawMap.sourcesContent; + consumeRecords(budget, Array.isArray(rawSources) ? rawSources.length : 0); + consumeRecords(budget, Array.isArray(rawNames) ? rawNames.length : 0); + consumeRecords(budget, Array.isArray(rawContents) ? rawContents.length : 0); + options.check?.(); + if (options.profile === "browser-collection" && !Array.isArray(rawNames)) + throw new SourceMapFormatFailure( + "format", + "Source-map leaf requires a names array.", + ); + if ( + options.profile === "browser-collection" && + !hasValidSourceMapContents( + Array.isArray(rawSources) ? rawSources.length : 0, + rawContents, + ) + ) + throw new SourceMapFormatFailure( + "format", + "Source-map sourcesContent must align with its source declarations.", + ); + options.check?.(); + const leaf = leafSchema.parse(node.value); + const layout = inspectMappingLayout(leaf.mappings, options.check); + consumeRecords(budget, layout.segments + layout.lines); + if ( + layout.segments > WEB_SOURCE_MAP_LIMITS.decodedSegments || + node.offset.line + layout.lines > WEB_SOURCE_MAP_LIMITS.decodedRows + ) + throw new SourceMapFormatFailure( + "limit", + "Source-map layout exceeds the 262144 segment/generated-row decoder budget.", + ); + return leaf; +}; + /** Validate structure before allocation, retaining raw declarations and section boundaries. */ export const inspectSourceMap = ( text: string, @@ -204,14 +294,51 @@ export const inspectSourceMap = ( ? withoutBom.slice(withoutBom.indexOf("\n") + 1) : withoutBom; const value: unknown = JSON.parse(jsonText); + const inspected = inspectSourceMapValue(value); + const normalized = + inspected.format === "regular" + ? inspected.leaves[0]?.map + : { + version: 3, + sections: inspected.leaves.map((leaf) => ({ + offset: leaf.offset, + map: leaf.map, + })), + }; + return { + jsonText: JSON.stringify(normalized), + ...inspected, + }; +}; + +/** Validate shared source-map structure before a consumer decodes mappings. */ +export const validateSourceMapStructure = ( + value: unknown, + options: SourceMapStructureOptions = {}, +): void => { + inspectSourceMapValue(value, options, false); +}; + +const inspectSourceMapValue = ( + value: unknown, + options: SourceMapStructureOptions = {}, + collect = true, +): { + readonly format: "regular" | "indexed"; + readonly reportedFile: string | null; + readonly declarations: readonly SourceMapSourceDeclaration[]; + readonly leaves: readonly SourceMapLeaf[]; +} => { + options.check?.(); const root = mapHeaderSchema.parse(value); const declarations: SourceMapSourceDeclaration[] = []; const leaves: SourceMapLeaf[] = []; const pending: PendingMap[] = [ { value, path: [], offset: { line: 0, column: 0 }, stop: null }, ]; - let segmentCount = 0; + const budget = options.budget ?? { records: 0 }; while (pending.length > 0) { + options.check?.(); const node = pending.pop(); if (node === undefined) break; if (node.path.length > WEB_SOURCE_MAP_LIMITS.sectionDepth) @@ -220,47 +347,26 @@ export const inspectSourceMap = ( "Indexed source-map nesting exceeds the 64-level decoder stack budget.", ); if (mapHeaderSchema.parse(node.value).sections !== undefined) { - addSections(node, pending); + addSections(node, pending, budget, options); continue; } - const leaf = leafSchema.parse(node.value); - const layout = inspectMappingLayout(leaf.mappings); - segmentCount += layout.segments; - if ( - segmentCount > WEB_SOURCE_MAP_LIMITS.decodedSegments || - node.offset.line + layout.lines > WEB_SOURCE_MAP_LIMITS.decodedRows - ) - throw new SourceMapFormatFailure( - "limit", - "Source-map layout exceeds the 262144 segment/generated-row decoder budget.", - ); - leaves.push({ map: leaf, offset: node.offset, stop: node.stop }); + const leaf = inspectLeaf(node, budget, options); + if (collect) + leaves.push({ map: leaf, offset: node.offset, stop: node.stop }); for (const [sourceIndex, source] of leaf.sources.entries()) - declarations.push({ - sectionPath: node.path, - sourceIndex, - source, - root: leaf.sourceRoot ?? null, - content: leaf.sourcesContent?.[sourceIndex] ?? null, - ignored: (leaf.ignoreList ?? leaf.x_google_ignoreList ?? []).includes( + if (collect) + declarations.push({ + sectionPath: node.path, sourceIndex, - ), - }); + source, + root: leaf.sourceRoot ?? null, + content: leaf.sourcesContent?.[sourceIndex] ?? null, + ignored: (leaf.ignoreList ?? leaf.x_google_ignoreList ?? []).includes( + sourceIndex, + ), + }); } - // Absolute leaves adapt nested first-line column offsets before upstream AnyMap. - // Raw map bytes and the original section/source identities are retained separately. - const normalized = - root.sections === undefined - ? leaves[0]?.map - : { - version: 3, - sections: leaves.map((leaf) => ({ - offset: leaf.offset, - map: leaf.map, - })), - }; return { - jsonText: JSON.stringify(normalized), format: root.sections === undefined ? "regular" : "indexed", reportedFile: root.file ?? null, declarations, diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index 0a56be9af..2a5d1e963 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -374,6 +374,52 @@ describe("web source-map fetching and validation: enforcing fetch limits and val }); }); +describe("source-map response encoding", () => { + it("rejects malformed UTF-8 source-map response bytes", async () => { + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: () => + Promise.resolve( + new Response(Uint8Array.of(0x7b, 0x22, 0xc3, 0x28, 0x22, 0x7d), { + status: 200, + }), + ), + }); + + expect(result.status).toBe("unavailable"); + expect(result.items[0]).toMatchObject({ + status: "invalid", + artifact: null, + limitation: "Source-map response is not valid UTF-8.", + }); + }); +}); + +describe("source-map operation deadline observations", () => { + it("keeps completed maps when a later request times out", async () => { + let calls = 0; + const requests = [ + request, + { ...request, scriptKey: `scr_${"2".repeat(64)}` }, + ]; + const result = await fetchWebSourceMaps(requests, input(), undefined, { + timeoutMs: 100, + fetch: () => { + calls += 1; + return calls === 1 + ? Promise.resolve(validMapResponse()) + : new Promise(() => undefined); + }, + }); + + expect(result.status).toBe("partial"); + expect(result.items.map(({ status }) => status)).toEqual([ + "included", + "fetch_failed", + ]); + expect(result.items[0]?.original_sources).toHaveLength(1); + }); +}); + describe("indexed source-map offsets", () => { it.each([ { line: 0, column: -1 }, @@ -621,6 +667,76 @@ describe("source-map dependency coverage of hard-to-parse sources", () => { }); describe("web source-map collection", () => { + it("rejects mapping expansion before the trace-mapping decoder allocates it", async () => { + const response = new Response( + JSON.stringify({ + version: 3, + names: [], + sources: [], + mappings: Array.from({ length: 262_145 }, () => "A").join(","), + }), + { status: 200 }, + ); + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: () => Promise.resolve(response), + }); + + expect(result.status).toBe("unavailable"); + expect(result.items[0]).toMatchObject({ + status: "fetch_failed", + artifact: null, + }); + expect(result.items[0]?.limitation).toContain("262144"); + }); + + it("accounts for decoded records across the complete fetch operation", async () => { + const mapText = JSON.stringify({ + version: 3, + names: [], + sources: [], + mappings: Array.from({ length: 150_000 }, () => "A").join(","), + }); + const requests = [ + request, + { ...request, scriptKey: `scr_${"2".repeat(64)}` }, + ]; + const result = await fetchWebSourceMaps(requests, input(), undefined, { + fetch: () => Promise.resolve(new Response(mapText, { status: 200 })), + }); + + expect(result.status).toBe("partial"); + expect(result.items.map(({ status }) => status)).toEqual([ + "included", + "fetch_failed", + ]); + expect(result.items[1]?.limitation).toContain("262144-record"); + }); + + it("rejects deeply nested indexed maps before recursive flattening", async () => { + let map: unknown = { + version: 3, + names: [], + sources: [], + mappings: "", + }; + for (let index = 0; index < 65; index += 1) + map = { + version: 3, + sections: [{ offset: { line: 0, column: 0 }, map }], + }; + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: () => + Promise.resolve(new Response(JSON.stringify(map), { status: 200 })), + }); + + expect(result.status).toBe("unavailable"); + expect(result.items[0]).toMatchObject({ + status: "fetch_failed", + artifact: null, + }); + expect(result.items[0]?.limitation).toContain("64-level"); + }); + it("retains every mapping from a sectioned source map", async () => { const segmentCount = 10_001; const regular = { @@ -693,6 +809,31 @@ describe("web source-map collection", () => { }); }); +describe("raw source-map structure preflight", () => { + it("budgets source inventory before schema parsing its entries", async () => { + const response = new Response( + JSON.stringify({ + version: 3, + names: [], + sources: Array.from({ length: 262_145 }, (_, index) => + index === 0 ? 42 : "source.js", + ), + mappings: "", + }), + { status: 200 }, + ); + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: () => Promise.resolve(response), + }); + + expect(result.items[0]).toMatchObject({ + status: "fetch_failed", + artifact: null, + }); + expect(result.items[0]?.limitation).toContain("262144-record"); + }); +}); + const validMapResponse = () => new Response( JSON.stringify({