fix(native): parse renamed dyld_info re-exports (#2364)

* fix(native): parse renamed dyld_info re-exports

dyld_info prints a re-export whose name differs from the source library's
symbol as `[re-export] _foo_renamed (_foo from libfoo)`. The export parser
only accepted `(from libfoo)`, so inspect_macho reported a nonexistent
export named `_foo_renamed (_foo from libfoo)` with no source, and the nm
row for `_foo_renamed` survived the name-based merge as a second export
with reexport null.

Accept the optional source symbol, trying the plain `(from lib)` form
first so a library name containing ` from ` keeps its text. The row now
reports `_foo_renamed` re-exported from libfoo and replaces the nm
duplicate. The export schema has no field for the source symbol name, so
exports coverage records a limitation when a renamed re-export is present.
The golden fixture is a dyld_info capture of a synthetic dylib linked with
-Wl,-alias,_foo,_foo_renamed.

* fix(native): split dyld re-export suffix without nested quantifiers

Replace the (?:(.+?)\s+)??from lazy-optional group with a linear string
scan so malformed lines cannot backtrack and a library path containing
" from " still keeps its whole text via the plain-form-first check.

* test(native): cover nested re-export library names

* fix(native): retain reexports missing from nm inventory

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: morluto <17176810+morluto@users.noreply.github.com>
This commit is contained in:
Chaoqi 2026-10-13 13:06:55 -04:00 committed by GitHub
parent f652709cac
commit 1d5f95dc4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 196 additions and 4 deletions

View File

@ -266,6 +266,11 @@ const normalizeMacho = (
]),
exports: covered(exports, false, [
"Merged nm/dyld_info results may be incomplete for stripped binaries.",
...(dyldExports.some(({ sourceSymbol }) => sourceSymbol !== undefined)
? [
"Renamed re-exports are listed under their exported name; the source library's symbol name is not reported.",
]
: []),
]),
segments: covered(
load.segments.map((segment) => ({

View File

@ -4,6 +4,8 @@ interface ParsedDyldSymbol {
readonly weak: boolean | null;
readonly reexport: boolean | null;
readonly source: string | null;
/** The source library's name for a symbol it re-exports under another name. */
readonly sourceSymbol?: string;
}
/** Decode Apple dyld inventory rows, projecting export offsets to VM addresses. */
@ -33,15 +35,23 @@ export const parseDyldSymbols = (
];
return [];
}
const reexport = /^\[re-export\]\s+(.+?)(?:\s+\(from (.+)\))?$/u.exec(line);
if (reexport?.[1] !== undefined)
// dyld_info prints `_sym (from lib)`, or `_new (_old from lib)` when the
// source library names the symbol differently. Split the trailing
// parenthesized suffix with string scans so a library path containing
// " from ", or a source symbol containing parentheses such as
// `operator()`, cannot pull the match into nested lazy quantifiers.
const reexport = parseReexportRow(line, knownExportNames);
if (reexport !== null)
return [
{
name: reexport[1],
name: reexport.name,
address: null,
weak: null,
reexport: true,
source: reexport[2] ?? null,
source: reexport.source,
...(reexport.sourceSymbol === undefined
? {}
: { sourceSymbol: reexport.sourceSymbol }),
},
];
const exported = /^(0x[\da-f]+)\s+(.+?)(?:\s+\[([^\]]+)\])?$/iu.exec(line);
@ -78,3 +88,70 @@ export const parseDyldSymbols = (
const canonicalHex = (value: string | undefined): string | null =>
value === undefined ? null : `0x${BigInt(value).toString(16)}`;
interface ReexportRow {
readonly name: string;
readonly source: string | null;
readonly sourceSymbol?: string;
}
/**
* Split a `[re-export]` row without nested lazy quantifiers.
*
* Returns null when the line is not a re-export row so callers fall through
* to the plain export parser. Known names disambiguate nested parentheses;
* when that inventory is incomplete, retain the suffix-based fallback.
*/
const parseReexportRow = (
line: string,
knownNames: ReadonlySet<string>,
): ReexportRow | null => {
const prefix = /^\[re-export\]\s+/u.exec(line);
if (prefix === null) return null;
const rest = line.slice(prefix[0].length);
if (rest.length === 0) return null;
if (!rest.endsWith(")")) return { name: rest, source: null };
const candidates: ReexportRow[] = [];
for (const match of rest.matchAll(/\s+\(/gu)) {
const name = rest.slice(0, match.index);
if (!knownNames.has(name)) continue;
const parsed = reexportSuffix(
name,
rest.slice(match.index + match[0].length, -1),
);
if (parsed !== null) candidates.push(parsed);
}
// Exact nm identities establish a boundary even when names contain parentheses.
// Competing known interpretations remain unknown rather than creating a new name.
if (candidates.length > 0)
return candidates.length === 1 ? candidates[0]! : null;
if (knownNames.has(rest)) return { name: rest, source: null };
let depth = 0;
for (let index = rest.length - 1; index >= 0; index--) {
if (rest[index] === ")") depth++;
else if (rest[index] === "(" && --depth === 0) {
if (index === 0 || !/\s/u.test(rest[index - 1]!)) break;
const name = rest.slice(0, index).trimEnd();
return (
reexportSuffix(name, rest.slice(index + 1, -1)) ?? {
name: rest,
source: null,
}
);
}
}
return { name: rest, source: null };
};
const reexportSuffix = (name: string, inner: string): ReexportRow | null => {
if (name.length === 0) return null;
if (inner.startsWith("from ") && inner.length > 5)
return { name, source: inner.slice(5) };
const separator = /\s+from /u.exec(inner);
if (separator === null) return null;
const sourceSymbol = inner.slice(0, separator.index);
const source = inner.slice(separator.index + separator[0].length);
return sourceSymbol.length > 0 && source.length > 0
? { name, source, sourceSymbol }
: null;
};

View File

@ -225,6 +225,79 @@ it("retains native inventory facts from captured Apple tool output", async () =>
});
});
it("reports a renamed re-export once under its exported name", async () => {
const client = new NativeMacOSProvider(
{},
new FixtureRunner({
"dyld_info:-exports": await fixture(
"dyld-inventory/renamed-reexports.txt",
),
nm: "_foo_renamed\n_own\n",
}),
"darwin",
).createClient(machoTarget("/private/fixture"));
const execution = await client.execute("inspect_macho", {});
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({
exports: {
items: [
{
name: "_foo_renamed",
address: null,
weak: null,
reexport: true,
source: "libfoo",
},
{ name: "_own", reexport: false, source: null },
],
limitations: [
"Merged nm/dyld_info results may be incomplete for stripped binaries.",
"Renamed re-exports are listed under their exported name; the source library's symbol name is not reported.",
],
},
});
});
it("splits re-export rows at the source library", () => {
// dyld_info -exports rows for synthetic dylibs: a source symbol named
// `operator()`, and a library whose install name contains ` from `.
expect(
parseDyldSymbols(
[
"[re-export] _call_renamed (operator() from libfoo)",
"[re-export] _g (from lib from foo)",
"[re-export] _nested (from lib (from foo))",
].join("\n"),
"exports",
null,
new Set(["_call_renamed", "_g", "_nested"]),
),
).toEqual([
{
name: "_call_renamed",
address: null,
weak: null,
reexport: true,
source: "libfoo",
sourceSymbol: "operator()",
},
{
name: "_g",
address: null,
weak: null,
reexport: true,
source: "lib from foo",
},
{
name: "_nested",
address: null,
weak: null,
reexport: true,
source: "lib (from foo)",
},
]);
});
it("retains symbol names containing spaces in dyld inventory rows", () => {
expect(
parseDyldSymbols(" 0x0000 _entry with space (from fixture)", "imports"),
@ -449,6 +522,25 @@ it("leaves an ambiguous short and literal name to the caller's nm inventory", ()
).toEqual([]);
});
it("retains unmatched re-export rows when the known export inventory is incomplete", () => {
expect(
parseDyldSymbols(
"[re-export] _g (from libfoo)",
"exports",
null,
new Set(["_own"]),
),
).toEqual([
{
name: "_g",
address: null,
weak: null,
reexport: true,
source: "libfoo",
},
]);
});
it("retains annotation parsing without nm name evidence", () => {
expect(parseDyldSymbols("0x120 _weak [weak-def]", "exports")).toEqual([
{

View File

@ -37,6 +37,19 @@ import rows from `xcrun dyld_info -imports /usr/lib/libobjc.A.dylib`.
`xcrun dyld_info -exports /usr/lib/libc++.1.dylib`. These are separate library
captures used to check their respective row formats.
`renamed-reexports.txt` is `xcrun dyld_info -exports` for a dylib that
re-exports `_foo` from `libfoo` under the name `_foo_renamed`:
```sh
printf 'int foo(void){return 1;}\n' > /tmp/foo.c
printf 'int own(void){return 2;}\n' > /tmp/re.c
xcrun clang -dynamiclib /tmp/foo.c -o /tmp/libfoo.dylib -install_name @rpath/libfoo.dylib
xcrun clang -dynamiclib /tmp/re.c -o /tmp/libre.dylib -install_name @rpath/libre.dylib \
/tmp/libfoo.dylib -Wl,-alias,_foo,_foo_renamed
xcrun dyld_info -exports /tmp/libre.dylib
xcrun nm -gjU /tmp/libre.dylib
```
`codesign-missing.txt` is stderr from `codesign -d --verbose=4` on a nonexistent
path (exit 1). `codesign-unsigned.txt` is stderr from the same command on a copy
of the fixture after `codesign --remove-signature` (exit 1). Only the latter is

View File

@ -0,0 +1,5 @@
fixture [arm64]:
-exports:
offset symbol
[re-export] _foo_renamed (_foo from libfoo)
0x00000308 _own