diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index c1313d0f75..5302adc06a 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -36,18 +36,29 @@ const MAXIMUM_NAME_BYTES = 4096; /** Text retained across all rows; names can repeat one long string table entry. */ const MAXIMUM_RETAINED_TEXT_BYTES = 16 * 1024 * 1024; +const retainedTextBytes = (...strings: readonly (string | null)[]): number => + strings.reduce( + (bytes, value) => + bytes + (value === null ? 0 : Buffer.byteLength(value, "utf8")), + 0, + ); + +const checkRetainedText = (bytes: number): void => { + if (bytes > MAXIMUM_RETAINED_TEXT_BYTES) + throw new BinaryResourceFailure( + "structure", + MAXIMUM_RETAINED_TEXT_BYTES, + `Export names exceed the ${String(MAXIMUM_RETAINED_TEXT_BYTES)}-byte retained-text allowance`, + ); +}; + /** Charge retained text and rows at their owning storage boundary. */ const createRetention = () => { let rows = 0; let text = 0; const retainText = (...strings: readonly (string | null)[]): void => { - for (const value of strings) text += value?.length ?? 0; - if (text > MAXIMUM_RETAINED_TEXT_BYTES) - throw new BinaryResourceFailure( - "structure", - MAXIMUM_RETAINED_TEXT_BYTES, - `Export names exceed the ${String(MAXIMUM_RETAINED_TEXT_BYTES)}-byte retained-text allowance`, - ); + text += retainedTextBytes(...strings); + checkRetainedText(text); }; return { text: retainText, @@ -316,8 +327,25 @@ const readVersionNames = ( issues: NativeExportIssue[], ): Map => { const namesByIndex = new Map(); + // This intermediate map owns its storage independently of repeated text in + // output rows. Replacing an index releases its previous decoded name. + let textBytes = 0; const addName = (index: number, name: Buffer): void => { - namesByIndex.set(index, name.length === 0 ? null : decodeElfString(name)); + const decoded = name.length === 0 ? null : decodeElfString(name); + const previous = namesByIndex.get(index); + const nextTextBytes = + textBytes - + retainedTextBytes( + previous?.name ?? null, + previous?.name_bytes_base64 ?? null, + ) + + retainedTextBytes( + decoded?.name ?? null, + decoded?.name_bytes_base64 ?? null, + ); + checkRetainedText(nextTextBytes); + namesByIndex.set(index, decoded); + textBytes = nextTextBytes; }; const verneed = sections.find(({ type }) => type === SHT_GNU_VERNEED); if (verneed !== undefined) { diff --git a/tests/boundary/filesystem/nativeExports.test.ts b/tests/boundary/filesystem/nativeExports.test.ts index 7afeba3a24..a4e98b597f 100644 --- a/tests/boundary/filesystem/nativeExports.test.ts +++ b/tests/boundary/filesystem/nativeExports.test.ts @@ -87,6 +87,104 @@ describe("native export stable filesystem boundary", () => { }); }); +describe("native export text capacity", () => { + it.each(["symbol", "version"] as const)( + "bounds UTF-8 bytes of retained ELF %s text and its exact-byte spelling", + async (kind) => { + const root = await createTestTempDirectory("rea-native-export-text-"); + const service = createNativeExportsService(); + const text = "€".repeat(20_000); + const rawName = Buffer.from(text, "utf8").toString("latin1"); + try { + for (const count of [119, 160]) { + const path = join(root, `${kind}-${String(count)}.so`); + const bytes = elfExportImage({ + symbols: Array.from({ length: count }, () => + kind === "symbol" ? { name: rawName } : { name: "a", version: 2 }, + ), + ...(kind === "version" + ? { versionDefinitions: ["lib.so", rawName] } + : {}), + }); + expect(bytes.length).toBeLessThan(70_000); + await writeFile(path, bytes); + const result = await service.inspect({ path }); + if (count === 160) { + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError" }, + }); + continue; + } + if (!result.ok) throw new Error(JSON.stringify(result.error)); + const report = nativeExportsSchema.parse( + result.value.normalized_result, + ); + if (report.format !== "elf") throw new Error("Expected ELF report"); + expect(report.coverage).toMatchObject({ + status: "complete", + exports: count, + }); + const row = report.exports[0]!; + const name = kind === "symbol" ? row : row.version; + expect(name).toMatchObject({ + name: text, + name_bytes_base64: Buffer.from(text, "utf8").toString("base64"), + name_utf8_decodable: true, + }); + } + } finally { + await service.close(); + } + }, + ); + + it("bounds intermediate ELF version names even when no symbols use them", async () => { + const root = await createTestTempDirectory("rea-native-export-versions-"); + const service = createNativeExportsService(); + try { + for (const count of [279, 280]) { + const path = join(root, `versions-${String(count)}.so`); + const bytes = elfExportImage({ + symbols: [], + versionDefinitions: Array(count).fill("v".repeat(60_000)), + // Definitions replace requirements at the same indices; released + // names must not remain charged against the live map's allowance. + ...(count === 279 + ? { + versionRequirements: Array.from( + { length: count }, + (_, index) => ({ + index: index + 1, + name: "w".repeat(60_000), + }), + ), + } + : {}), + }); + expect(bytes.length).toBeLessThan(140_000); + await writeFile(path, bytes); + const result = await service.inspect({ path }); + if (count === 280) + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError" }, + }); + else { + if (!result.ok) throw new Error(JSON.stringify(result.error)); + expect(result.value.normalized_result).toMatchObject({ + exports: [], + issues: [], + coverage: { status: "complete", exports: 0 }, + }); + } + } + } finally { + await service.close(); + } + }); +}); + describe("native export encoding and failures", () => { it("keeps ELF symbol bytes while exposing valid UTF-8 text", async () => { const root = await createTestTempDirectory("rea-native-export-unicode-");