From 9fd78a74f84a8e924bd58e7deaf5934bfb24f53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E7=AB=8B=20Guo=20Li?= Date: Sat, 24 Oct 2026 03:14:34 +0800 Subject: [PATCH 01/17] fix(native): preserve unknown ELF export coverage without a dynsym section Closes #6792. ELF images with `PT_DYNAMIC` but no `SHT_DYNSYM` section incorrectly reported a complete, empty export inventory. The shared ELF directory reader now owns the dynamic-segment observation; CLI/MCP report `unsupported` with a reason. Existing symbol-table results remain intact. Read-only format correction; no new execution authority or public schema. Validation on the current main integration: - `npm run check`, test discovery, and 166 selected Linux tests pass, including actual CLI/stdio MCP cases and PE/IL2CPP/GPU/Rust reader consumers. Original author validation: 113 tests on macOS, including regressions that failed before the fix; test discovery passed. - Real Debian libc (1,926,232 bytes) and libLLVM 14 (109,967,296 bytes), with only section-header-count/name-index bytes changed, reproduce the old false absence in both public adapters. The reviewed implementation returns unsupported in both adapters. Independent Python maps unchanged `PT_DYNAMIC` declarations and selected symbol bytes; original `readelf` agrees. The reduced files are not loaded or executed; `readelf -D` itself cannot enumerate these reduced layouts, and that failed oracle attempt is retained. - Unmodified libc still returns all 3,025 exports, byte-identical complete Evidence and the same Evidence ID as the baseline. Resource-guarded public probes completed without stops or payload leftovers. No speed, memory or model-token improvement is claimed for this coverage fix. Codex review completed with no findings. CI failed: all 61 observed failures (including 47 omitted from the aggregate report by canceled shards) reproduce with identical complete failure diagnostics on the exact CI base and merge trees. Baseline-only bypass is explicitly authorized for this maintenance session; canceled/skipped coverage remains unverified. See the final CI attribution comment. The original author's readiness declaration below is preserved. ## Author readiness Human owner: @leeguooooo - [x] I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work. - [x] I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps. - [x] I declare this PR ready for initial maintainer review. --- src/native/exports/NativeExportParser.ts | 11 ++- src/native/metadata/NativeSectionDirectory.ts | 21 ++++-- .../nativeExportsDynamicSegment.test.ts | 69 +++++++++++++++++++ .../binaryDiagnostics/nativeExports.ts | 26 ++++++- 4 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 tests/acceptance/applications/nativeExportsDynamicSegment.test.ts diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index e44981778a..673c54b818 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -511,21 +511,26 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => { byte_order: directory.little ? ("little" as const) : ("big" as const), }; const { sections } = directory; - if (sections.length === 0) + const dynsym = sections.find(({ type }) => type === SHT_DYNSYM); + if ( + sections.length === 0 || + (dynsym === undefined && directory.has_dynamic_segment) + ) return { ...result, exports: [], coverage: { status: "unsupported", reason: - "The ELF has no section headers; enumerating dynamic symbols from the dynamic segment is not implemented", + sections.length === 0 + ? "The ELF has no section headers; enumerating dynamic symbols from the dynamic segment is not implemented" + : "The ELF declares a dynamic segment but has no dynamic symbol section; enumerating dynamic symbols from the dynamic segment is not implemented", examined_entries: 0, exports: 0, }, issues: [], limitations: ELF_LIMITATIONS, }; - const dynsym = sections.find(({ type }) => type === SHT_DYNSYM); if (dynsym === undefined) return { ...result, diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts index 538689e32b..323b79f444 100644 --- a/src/native/metadata/NativeSectionDirectory.ts +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -38,6 +38,7 @@ export interface NativeSectionDirectory { readonly program_offset: bigint; readonly program_count: number; readonly program_width: number; + readonly has_dynamic_segment: boolean; readonly sections: readonly NativeSection[]; } const STRUCTURE_BYTES = 16 * 1024 * 1024; @@ -130,14 +131,24 @@ export function* readElfSectionDirectory( reject("ELF extended program count is below its reserved range"); } } + let has_dynamic_segment = false; if (program_count !== 0) { if (program_width < (wide ? 56 : 32)) reject("ELF program header width is invalid"); - request( - fileSize, - program_offset, - BigInt(program_count) * BigInt(program_width), + const programs = new BinaryReader( + yield request( + fileSize, + program_offset, + BigInt(program_count) * BigInt(program_width), + ), + little, ); + for (let index = 0; index < program_count; index++) { + if (programs.u32(index * program_width) === 2) { + has_dynamic_segment = true; + break; + } + } } const sections: NativeSection[] = []; let section_names_limitation: string | undefined; @@ -259,6 +270,7 @@ export function* readElfSectionDirectory( program_offset, program_count, program_width, + has_dynamic_segment, sections, }; } @@ -346,6 +358,7 @@ export function* readPeSectionDirectory( program_offset: 0n, program_count: 0, program_width: 0, + has_dynamic_segment: false, sections, }; } diff --git a/tests/acceptance/applications/nativeExportsDynamicSegment.test.ts b/tests/acceptance/applications/nativeExportsDynamicSegment.test.ts new file mode 100644 index 0000000000..a3bfe3aaac --- /dev/null +++ b/tests/acceptance/applications/nativeExportsDynamicSegment.test.ts @@ -0,0 +1,69 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { nativeExportsSchema } from "../../../src/domain/native/nativeExports.js"; +import { elfExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps unenumerated ELF dynamic exports unsupported through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-elf-dynamic-exports-"); + const mcp = + surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined; + for (const encoding of [ + { bits: 64, little: true }, + { bits: 32, little: false }, + ] as const) { + for (const sectionHeaders of ["null-only", false, true] as const) { + const path = join( + root, + `exports-${encoding.bits}-${sectionHeaders}.so`, + ); + const bytes = elfExportImage({ + ...encoding, + symbols: [{ name: "exported_answer" }], + dynamicSegment: true, + sectionHeaders, + }); + await writeFile(path, bytes); + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["inspect-native-exports", path, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const response = await mcp!.call("inspect_native_exports", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const evidence = toolContract( + "inspect_native_exports", + ).outputSchema.parse(document); + const result = nativeExportsSchema.parse(evidence.normalized_result); + expect(result).toMatchObject({ + format: "elf", + coverage: { + status: sectionHeaders === true ? "complete" : "unsupported", + exports: sectionHeaders === true ? 1 : 0, + }, + issues: [], + }); + if (sectionHeaders === true) { + expect(result.exports).toMatchObject([{ name: "exported_answer" }]); + } else { + expect(result.exports).toEqual([]); + expect(result.coverage.reason).toContain("dynamic segment"); + expect(result.limitations.join(" ")).not.toContain( + "so it exports no dynamic symbols", + ); + } + } + } + }, +); diff --git a/tests/fixtures/binaryDiagnostics/nativeExports.ts b/tests/fixtures/binaryDiagnostics/nativeExports.ts index 123a9e4501..16667a958e 100644 --- a/tests/fixtures/binaryDiagnostics/nativeExports.ts +++ b/tests/fixtures/binaryDiagnostics/nativeExports.ts @@ -248,7 +248,8 @@ export const elfExportImage = (options: { readonly versionsOutsideFile?: boolean; /** Declare a zero-byte `.dynsym`, without its null entry. */ readonly emptyDynamicSymbols?: boolean; - readonly sectionHeaders?: boolean; + readonly sectionHeaders?: boolean | "null-only"; + readonly dynamicSegment?: boolean; readonly dynamicSymbols?: boolean; }): Buffer => { const bits = options.bits ?? 64; @@ -317,6 +318,8 @@ export const elfExportImage = (options: { u16(wide ? 52 : 40, wide ? 64 : 52); u16(wide ? 54 : 42, wide ? 56 : 32); u16(wide ? 58 : 46, sectionWidth); + if (options.dynamicSegment === true) + writeElfDynamicSegment(bytes, bits, little); if (options.sectionHeaders === false) return bytes; symbols.forEach((symbol, index) => { @@ -381,8 +384,9 @@ export const elfExportImage = (options: { word(at + (wide ? 56 : 36), BigInt(entsize)); }); word(wide ? 40 : 32, BigInt(sectionAt)); - u16(wide ? 60 : 48, sections.length); - u16(wide ? 62 : 50, sections.length - 1); + const nullOnly = options.sectionHeaders === "null-only"; + u16(wide ? 60 : 48, nullOnly ? 1 : sections.length); + u16(wide ? 62 : 50, nullOnly ? 0 : sections.length - 1); return bytes; }; @@ -463,3 +467,19 @@ const elfSectionRows = ( ] satisfies ElfSectionRow[])), [60, 3, layout.shstrtabAt, layout.shstrtab, 0, 0, 0], ]; + +/** Two program headers: an empty load segment followed by a dynamic segment. */ +const writeElfDynamicSegment = ( + bytes: Buffer, + bits: 32 | 64, + little: boolean, +): void => { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const wide = bits === 64; + const programAt = wide ? 64 : 52; + if (wide) view.setBigUint64(32, BigInt(programAt), little); + else view.setUint32(28, programAt, little); + view.setUint16(wide ? 56 : 44, 2, little); + view.setUint32(programAt, 1, little); + view.setUint32(programAt + (wide ? 56 : 32), 2, little); +}; From f713087132021d235682f908a1c0f3bc6ab8762d Mon Sep 17 00:00:00 2001 From: Abel Yagubyan Date: Fri, 23 Oct 2026 15:57:14 -0400 Subject: [PATCH 02/17] fix(adb): pass int intent extras to am without leading zeros (#6843) Fixes #6807 Co-authored-by: Abelo9996 Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- src/adb/AdbProvider.test.ts | 40 +++++++++++++++++++++++++++++++++++++ src/adb/AdbProvider.ts | 6 +++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 2bba049f61..6a3eed7354 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -2043,6 +2043,46 @@ ${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat ]); }); +it.each([ + { value: "10", operand: "10" }, + { value: "010", operand: "10" }, + { value: "-010", operand: "-10" }, + { value: "08", operand: "8" }, + { value: "000", operand: "0" }, +])( + "passes int extra $value to am as decimal $operand", + async ({ value, operand }) => { + const { binary, root } = await writeAdbStub( + (root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")} +${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW }\n" })(root)}`, + ); + await evidenceResult(providerOver(binary), { + operation: "start_adb_activity", + input: { + serial: "emulator-5554", + action: "android.intent.action.VIEW", + extras: [ + { key: "count", type: "int", value }, + // `--el` is read with Long.valueOf, which is always decimal. + { key: "total", type: "long", value }, + ], + }, + }); + const logged = (await readFile(join(root, "argv.log"), "utf8")) + .trim() + .split("\n"); + // am reads `--ei` with Integer.decode, where a leading zero means octal. + expect(logged.slice(-6)).toEqual([ + "--ei", + "count", + operand, + "--el", + "total", + value, + ]); + }, +); + it("collects a device bugreport archive with its digest", async () => { const { binary, root } = await writeAdbStub( () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 7db8179767..7685f26d83 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -2823,7 +2823,11 @@ export class AdbProvider { ...input.extras.flatMap((extra) => [ extraFlags[extra.type], extra.key, - extra.value, + // am reads `--ei` with Integer.decode, where a leading zero selects + // octal; drop redundant zeros so the decimal value is what arrives. + extra.type === "int" + ? extra.value.replace(/^(-?)0+(?=\d)/u, "$1") + : extra.value, ]), ]; return this.runAmStart( From af3b039d31d56017169608bb85ab65fde6a0aa0d Mon Sep 17 00:00:00 2001 From: Abel Yagubyan Date: Fri, 23 Oct 2026 16:13:45 -0400 Subject: [PATCH 03/17] fix(native): preserve whitespace in Mach-O segment and section names (#6842) Fixes #6818 Co-authored-by: Abelo9996 Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- src/native/parsers/otool.ts | 9 ++++ .../native/nativeLoadKinds.golden.test.ts | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index cf685ac41d..37ade5ac33 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -507,10 +507,19 @@ const TEXT_FIELDS = /^(?:segname|sectname|string #\d+)$/u; */ const OFFSET_STRING_FIELD = /^(name|path) (.* \(offset \d+\))$/u; +/** + * otool prints segment and section names as ` %.16s`: one separator + * space, then the fixed-width name, whose own leading and trailing whitespace + * belongs to the Mach-O value. + */ +const MACHO_NAME_FIELD = /^(segname|sectname)(?: (.*))?$/u; + const parseField = (rawLine: string): readonly [string, string] | undefined => { const offsetString = OFFSET_STRING_FIELD.exec(rawLine.trimStart()); if (offsetString?.[1] !== undefined && offsetString[2] !== undefined) return [offsetString[1], offsetString[2]]; + const name = MACHO_NAME_FIELD.exec(rawLine.trimStart()); + if (name?.[1] !== undefined) return [name[1], name[2] ?? ""]; // otool prints each linker option as `string #n