fix(native): account export and version text in UTF-8 bytes

Count retained UTF-8 text rather than UTF-16 code units, including exact-byte base64 spellings. Bound the decoded version-name map independently from output expansion, releasing replaced entries from its live storage charge.

Add real service/provider boundary controls for accepted and over-budget symbol names, version names and unused version metadata. Existing per-string bounds remain the allocation owner.

Validation: native parser, filesystem and CLI/MCP views passed; build, typecheck, lint and formatting passed. No serialized-report size cap, OOM reproduction or performance improvement is claimed.
This commit is contained in:
morluto committed 2026-10-27 01:01:10 +08:00
1 parent 3d74c480e0
commit 74d5cab372
2 files changed
+134 -8

No files matched your search

+36 -8
View File
@@ -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<number, DecodedElfString | null> => {
const namesByIndex = new Map<number, DecodedElfString | null>();
// 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) {
@@ -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<string>(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-");