diff --git a/docs/release-exposure.md b/docs/release-exposure.md index 450b598c0d..bf408eb8b6 100644 --- a/docs/release-exposure.md +++ b/docs/release-exposure.md @@ -54,7 +54,7 @@ A member of a package that inventory did not expand is reported as the host file A package member skipped by a read allowance still counts as readable for `packaging.extractable_payload`, because the format stores it unencrypted. An encrypted member or one that failed to read counts as unreadable. -A `.map` too large to parse is a linker map when its header cannot begin a JSON object. A larger `.map` that begins as JSON, or that has a JavaScript name such as `app.js.map`, is counted as skipped. `native.debug_companions` identifies a companion from its name or from a `.map` body that is not a source map, so its `exposed` result has `inferred` confidence. +A map read counts against the source-map allowance when it starts, so an oversized or unreadable `.map` uses a read as a parsed one does. A `.map` too large to parse is a linker map when its header cannot begin a JSON object. A larger `.map` that begins as JSON, or that has a JavaScript name such as `app.js.map`, is counted as skipped. `native.debug_companions` identifies a companion from its name or from a `.map` body that is not a source map, so its `exposed` result has `inferred` confidence. ## Status diff --git a/src/artifacts/ReleaseExposureReader.test.ts b/src/artifacts/ReleaseExposureReader.test.ts index 16bfe2ec8f..7ac8da72f8 100644 --- a/src/artifacts/ReleaseExposureReader.test.ts +++ b/src/artifacts/ReleaseExposureReader.test.ts @@ -33,6 +33,7 @@ import { MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES, MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES, MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES, + MAX_RELEASE_EXPOSURE_SOURCE_MAPS, MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES, PACKAGED_NATIVE_IMAGES, type ReleaseExposureScan, @@ -1255,6 +1256,97 @@ describe("ReleaseExposureReader member allowances", () => { }); }); +describe("ReleaseExposureReader source-map count", () => { + it.each([ + [ + "dotnet-bundle", + (bytes: Buffer, paths: readonly string[]): Buffer => + dotnetBundleFixture( + paths.map((path) => ({ path, bytes, compressed: true })), + ).bytes, + ], + [ + "pyinstaller-carchive", + (bytes: Buffer, paths: readonly string[]): Buffer => + cArchiveExecutable({ + carrier: "pe", + layout: "2.1", + pythonVersion: 310, + members: paths.map((name) => ({ + name, + typeCode: "x", + compress: true, + payload: bytes, + })), + }), + ], + ])( + "charges an oversized %s map to the map count before opening it", + async (_format, archive) => { + const root = await createTestTempDirectory("rea-release-exposure-over-"); + const count = MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 8; + const oversized = Buffer.alloc( + MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, + 0x20, + ); + const paths = Array.from( + { length: count }, + (_, index) => `m${String(index)}.js.map`, + ); + const target = join(root, "app.dll"); + await writeFile(target, archive(oversized, paths)); + let opened = 0; + createdReaders.wrap = (reader, path) => { + if (path !== target) return reader; + const open = reader.open.bind(reader); + reader.open = (entry, signal) => { + if (entry.path.endsWith(".js.map")) opened += 1; + return open(entry, signal); + }; + return reader; + }; + try { + const scanned = await inspect(root); + expect(opened).toBe(MAX_RELEASE_EXPOSURE_SOURCE_MAPS); + expect(scanned.source_maps).toMatchObject({ + embedded: 0, + skipped: count, + }); + expect(scanned.text_members.skipped).toBe(count); + } finally { + createdReaders.wrap = undefined; + } + }, + ); + + it("keeps a linker map's outcome past the count once oversized maps use it", async () => { + const root = await createTestTempDirectory("rea-release-exposure-spent-"); + const oversized = Buffer.alloc( + MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, + 0x20, + ); + oversized.write("Archive member included to satisfy reference\n"); + await writeFile( + join(root, "app.dll"), + dotnetBundleFixture( + Array.from( + { length: MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 1 }, + (_, index) => ({ + path: `m${String(index)}.map`, + bytes: oversized, + compressed: true, + }), + ), + ).bytes, + ); + const scanned = await inspect(root); + expect(scanned.debug_companions.paths).toHaveLength( + MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 1, + ); + expect(scanned.source_maps.skipped).toBe(0); + }); +}); + const bundled = async ( files: readonly { path: string; bytes: Buffer }[], ): Promise => { diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts index 37bf99c4cb..548c808ba4 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -925,6 +925,9 @@ const examineMap = async ( state.skippedMaps += 1; return null; } + // The read is what the count bounds, so it is charged before the member is + // opened. An oversized or unreadable map spends its slot like a parsed one. + if (hasSlot) state.mapsRead += 1; const loaded = await map.read( hasSlot ? MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES @@ -1008,7 +1011,6 @@ const readSourceMap = ( map: Pick, bytes: Buffer, ): void => { - state.mapsRead += 1; try { const inspected = inspectSourceMap(bytes.toString("utf8")); const embedded = inspected.declarations.some( @@ -1173,6 +1175,7 @@ const examineInlineMaps = ( state.skippedMaps += 1; continue; } + state.mapsRead += 1; // An inline declaration is a JavaScript map even if its JSON is invalid. readSourceMap( state,