From 43f06eb1e80d2ef62b7e407cc8bd9a3bce6a795b Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 9 Nov 2026 05:42:53 -0800 Subject: [PATCH] feat(native): recover ELF function ranges from unwind tables (#9585) * feat(native): recover ELF function ranges from unwind tables inspect_elf_unwind_ranges and inspect-elf-unwind-ranges read .eh_frame and .eh_frame_hdr from one local ELF32 or ELF64 image and list the function ranges its FDEs name, with CIE facts, LSDA and personality, the containing section and an exact symbol where one starts at the address. Nothing is executed and no disassembler is needed. Pointer encodings follow the DW_EH_PE rules with raw zero as null, discarded FDEs are counted and skipped, the .eh_frame_hdr search table is compared with the decoded FDEs, and record, row and symbol budgets are bounded. verify:elf:unwind-ranges compares the reported starts with llvm-dwarfdump or readelf on a compiled C++ program. * fix(elf): keep unresolved unwind facts out of reported ranges Skip FDEs whose initial location is indirect and do not locate .eh_frame through an indirect eh_frame_ptr, since pointer slots are not functions. Stop an unbounded header-located scan at a CIE with an unrecognized augmentation, report ignored duplicate PT_GNU_EH_FRAME headers as incomplete coverage, and accept a null personality stored under an indirect encoding. Keep only symbols at decoded FDE starts, cache name observations per string-table offset, and decode names only for the returned page under a fixed 16 MiB text allowance, so repeated or long names no longer grow memory with the symbol count. * fix(scripts): run the ELF unwind verifier under owned process trees Launch probes, compiler drivers, the stripper, the frame dump and the inspection through createVerifierCommands, as verify-binary-hardening does, so a timeout or interrupt stops the whole tree. The temporary directory is removed only after the owned commands have closed. * docs(elf): move ELF unwind ranges guide under docs/guides/native The documentation guide assigns analyst workflows to docs/guides//. Move the guide there, place its navigation and directory entries in the Native binaries group, and update links from the docs index, CLI guide, spec and authored skill. The public /elf-unwind-ranges route is unchanged. * fix(elf): give unwind text limits their own recovery and skip indirect tables Throw a dedicated failure when a page's symbol names exceed the 16 MiB text allowance, and map it to a resource error that tells the caller to request a smaller limit rather than raise max_ranges. Read a CIE augmentation with one bounded NUL search and keep it as a byte view, decoded only for returned CIEs, so a long augmentation is never built up character by character. Leave an .eh_frame_hdr search table with an indirect encoding unread: the header is incomplete, the comparison is unavailable, and the table no longer bounds a header-located .eh_frame scan. * fix(elf): resolve header count and table forms and name IFUNC resolvers Leave a non-zero .eh_frame_hdr FDE count under an indirect encoding unresolved, since it is the address of a slot that is not followed, and mark the header incomplete without reading the table. Treat a DW_EH_PE_omit table encoding as an omitted search table before testing the indirect bit, so a header that declares a count without a table stays present with complete coverage, and accept that form in output validation. Match STT_GNU_IFUNC symbols at exact FDE starts after function symbols, with a limitation that the name is the resolver, not the implementation it selects at run time. * fix(elf): require allocated unwind pointer bases * fix(elf): map unwind header relative base * fix(elf): validate unwind load mappings * test(elf): cover mapped nonallocated frame agreement --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../skills/reverse-engineer-anything/SKILL.md | 11 +- .../references/native-and-artifacts.md | 16 + docs/.vitepress/documentation.ts | 5 + docs/README.md | 1 + docs/getting-started/cli.md | 14 + docs/guides/native/elf-unwind-ranges.md | 177 ++ docs/index.md | 2 +- docs/specs/README.md | 2 + docs/specs/native/elf-unwind-ranges/plan.md | 35 + docs/specs/native/elf-unwind-ranges/spec.md | 92 + docs/specs/native/elf-unwind-ranges/tasks.md | 9 + package.json | 1 + scripts/verify-elf-unwind-ranges.mjs | 246 ++ src/application/CapabilityInventory.ts | 1 + .../binaryDiagnostics/ElfUnwindRangesPort.ts | 20 + .../ElfUnwindRangesService.test.ts | 424 ++++ .../ElfUnwindRangesService.ts | 399 ++++ src/cli/binaryDiagnosticsCommands.ts | 46 + src/cliCommandNames.ts | 1 + src/composition/binaryDiagnostics.ts | 6 + src/composition/capabilityDeclarations.ts | 8 + src/composition/capabilityServices.ts | 2 + .../native/binaryDiagnosticsToolContracts.ts | 19 + src/contracts/toolEffects.ts | 1 + src/domain/artifactOperationError.ts | 1 + src/domain/investigationProviders.ts | 7 + src/domain/native/elfUnwindRanges.test.ts | 75 + src/domain/native/elfUnwindRanges.ts | 239 ++ src/generatedPackageMetadata.ts | 2 +- src/native/elf/ElfIntervalIndex.test.ts | 43 + src/native/elf/ElfIntervalIndex.ts | 80 + src/native/elf/ElfUnwindRangesProvider.ts | 258 +++ src/native/elf/ElfUnwindTables.fixture.ts | 1230 ++++++++++ src/native/elf/ElfUnwindTables.test.ts | 1097 +++++++++ src/native/elf/ElfUnwindTables.ts | 2001 +++++++++++++++++ .../metadata/NativeSectionDirectory.test.ts | 18 + src/native/metadata/NativeSectionDirectory.ts | 6 +- src/server/createServer.ts | 9 + src/server/registerElfUnwindRangesTool.ts | 31 + tests/acceptance/elfUnwindRangesCli.test.ts | 144 ++ .../acceptance/localPathAdmissionCli.test.ts | 1 + .../tools/localToolContracts.test.ts | 1 + .../filesystem/elfUnwindRanges.test.ts | 144 ++ .../mcp/absoluteLocalPathTools.test.ts | 2 + tests/boundary/mcp/elfUnwindRangesMcp.test.ts | 155 ++ .../stableArtifactConsumers.test.ts | 9 + 46 files changed, 7087 insertions(+), 4 deletions(-) create mode 100644 docs/guides/native/elf-unwind-ranges.md create mode 100644 docs/specs/native/elf-unwind-ranges/plan.md create mode 100644 docs/specs/native/elf-unwind-ranges/spec.md create mode 100644 docs/specs/native/elf-unwind-ranges/tasks.md create mode 100644 scripts/verify-elf-unwind-ranges.mjs create mode 100644 src/application/binaryDiagnostics/ElfUnwindRangesPort.ts create mode 100644 src/application/binaryDiagnostics/ElfUnwindRangesService.test.ts create mode 100644 src/application/binaryDiagnostics/ElfUnwindRangesService.ts create mode 100644 src/domain/native/elfUnwindRanges.test.ts create mode 100644 src/domain/native/elfUnwindRanges.ts create mode 100644 src/native/elf/ElfIntervalIndex.test.ts create mode 100644 src/native/elf/ElfIntervalIndex.ts create mode 100644 src/native/elf/ElfUnwindRangesProvider.ts create mode 100644 src/native/elf/ElfUnwindTables.fixture.ts create mode 100644 src/native/elf/ElfUnwindTables.test.ts create mode 100644 src/native/elf/ElfUnwindTables.ts create mode 100644 src/server/registerElfUnwindRangesTool.ts create mode 100644 tests/acceptance/elfUnwindRangesCli.test.ts create mode 100644 tests/boundary/filesystem/elfUnwindRanges.test.ts create mode 100644 tests/boundary/mcp/elfUnwindRangesMcp.test.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 663ac1110f..f1f8ac0bbd 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -2,7 +2,7 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture, and of Linux desktop apps with a task from a request. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "61" + version: "62" --- # REA @@ -307,6 +307,15 @@ explicit path or endpoint and do not need it. build-id or ABI-tag fields. A missing table is observed absence of those declarations, not proof the image never carried notes. See the [ELF notes guide](https://github.com/morluto/rea/blob/main/docs/elf-notes.md). +- ELF unwind function ranges: `inspect_elf_unwind_ranges` with the explicit + local ELF32/ELF64 file. This built-in target-free inspection requires no + disassembler. Preserve FDE start, end, size, containing section, exact + symbol name, CIE index, LSDA, and personality. Page with `offset` and + `limit`. A missing `.eh_frame` is observed absence of that declaration, + not proof the image has no functions. `.debug_frame` is reported present + or absent and is not decoded. CFA instructions are counted and not + evaluated. See the + [ELF unwind ranges guide](https://github.com/morluto/rea/blob/main/docs/guides/native/elf-unwind-ranges.md). - Apple `.ips` crash reports: `inspect_apple_ips` with the explicit local file. This built-in target-free inspection requires no Xcode, atos, or pwntools. Preserve first-line metadata, `bug_type` as a string, process and diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index f95b378e2f..c29d5e8acc 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -199,6 +199,22 @@ absence of those declarations, not proof the image never carried notes in another container. See [ELF note tables](https://github.com/morluto/rea/blob/main/docs/elf-notes.md). +## ELF unwind function ranges + +Use `inspect_elf_unwind_ranges` for an explicit local ELF32/ELF64 image when +the question concerns function starts and lengths that survive `strip`, which +functions have an LSDA, or which ranges have no exact symbol. No +`open_binary` or target execution is required. The CLI equivalent is +`inspect-elf-unwind-ranges PATH`. Preserve artifact SHA-256, FDE start and +end, containing section, exact symbol name, CIE index, LSDA, and personality. +Page later ranges with `offset` and `limit`. A missing `.eh_frame` is +observed absence of that declaration and of `PT_GNU_EH_FRAME`, not proof the +image has no functions. Header disagreement is a limitation and does not drop +decoded ranges. `.debug_frame` is reported present or absent and is not +decoded. CFA instructions are counted and not evaluated. Indirect EH pointers +are not dereferenced. See +[ELF unwind function ranges](https://github.com/morluto/rea/blob/main/docs/guides/native/elf-unwind-ranges.md). + ## Apple IPS crash reports Use `inspect_apple_ips` for an explicit local Apple `.ips` file when the diff --git a/docs/.vitepress/documentation.ts b/docs/.vitepress/documentation.ts index e883a8b7ba..bd79190c78 100644 --- a/docs/.vitepress/documentation.ts +++ b/docs/.vitepress/documentation.ts @@ -43,6 +43,11 @@ export const documentationSections = [ source: "guides/native/binary-diagnostics.md", route: "/binary-diagnostics", }, + { + text: "ELF unwind function ranges", + source: "guides/native/elf-unwind-ranges.md", + route: "/elf-unwind-ranges", + }, { text: "Linux kernel build config", source: "guides/native/kernel-build-config.md", diff --git a/docs/README.md b/docs/README.md index 7bfe41dbf5..890c7436ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ product pages. The VitePress documentation source is this `docs/` tree. - [Native binary investigation](guides/native/native-investigation.md) - [Go binary build metadata](guides/native/go-binaries.md) - [Offline binary diagnostics](guides/native/binary-diagnostics.md) +- [ELF unwind function ranges](guides/native/elf-unwind-ranges.md) - [Linux kernel build config](guides/native/kernel-build-config.md) - [Recorded crash evidence](guides/native/recorded-crashes.md) - [Windows Shell Link shortcuts](guides/native/windows-shortcut.md) diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index 3ae3a0b0dd..e29eb61d2e 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -462,6 +462,20 @@ The workflow reports note owners, types, descriptor hashes, and GNU build-id or ABI-tag fields when those descriptors match. A missing note table is observed absence of those declarations. See [ELF note tables](../elf-notes.md). +Inspect ELF unwind function ranges without executing the image: + +```bash +rea inspect-elf-unwind-ranges /absolute/path/to/program --json +``` + +The workflow reports FDE start, end, and size, the containing section, an exact +symbol name when one exists, CIE identity, LSDA, and personality. Coverage +counts ranges that match a symbol, ranges without a symbol, and whether the +`.eh_frame_hdr` search table agrees with the decoded FDEs. `.debug_frame` is +reported present, absent, or incomplete and is not decoded. A missing unwind table is +observed absence of those declarations. See +[ELF unwind function ranges](../guides/native/elf-unwind-ranges.md). + Inspect an Apple `.ips` crash report without executing a target: ```bash diff --git a/docs/guides/native/elf-unwind-ranges.md b/docs/guides/native/elf-unwind-ranges.md new file mode 100644 index 0000000000..94c9105254 --- /dev/null +++ b/docs/guides/native/elf-unwind-ranges.md @@ -0,0 +1,177 @@ +# ELF unwind function ranges + +`inspect_elf_unwind_ranges` / `inspect-elf-unwind-ranges` reads `.eh_frame` +and `.eh_frame_hdr` from one selected local ELF32/ELF64 image and lists the +function ranges those tables name. It reads the file without executing it and +does not require a disassembler. + +```bash +rea inspect-elf-unwind-ranges /absolute/path/to/program --json +``` + +The CLI resolves relative paths against the caller's working directory. MCP +requires an absolute filesystem path and preserves that exact selection: + +```json +{ + "name": "inspect_elf_unwind_ranges", + "arguments": { "path": "/artifacts/program" } +} +``` + +Pass `--offset` and `--limit` (MCP `offset` and `limit`) to page the ranges. +The default page starts at 0 and returns at most 256 ranges. + +## Observations + +The inline Evidence result contains the artifact path, SHA-256, and byte +length, plus ELF class, byte order, machine, and object type. + +`eh_frame.status` is `present`, `absent`, `incomplete`, or `unknown`. A +present table names its source: `section` when section headers name +`.eh_frame`, or `program` when section headers are missing and +`PT_GNU_EH_FRAME` supplies the bytes. `location` is the file range that was +examined. A `.eh_frame` section that is `SHT_NOBITS` or lies outside the file +is `incomplete` with a null `location`. One that runs past the end of the +file is `incomplete`, `location` is clamped to its in-file bytes, and the +records in those bytes are still decoded. Only the first `.eh_frame` and +`.eh_frame_hdr` section is decoded, and only the first `PT_GNU_EH_FRAME` +program header. A duplicate section, or a duplicate `PT_GNU_EH_FRAME` when no +`.eh_frame_hdr` section supplies the header, keeps `coverage.status` at +`incomplete`. + +`eh_frame_hdr` reports version, the three pointer encodings, the `.eh_frame` +pointer, the declared FDE count, and how many binary-search table entries +were decoded. A header section that is `SHT_NOBITS` or extends past the end of +the file is `incomplete` with a null `location` and is not decoded. +`coverage.eh_frame_hdr_agreement` is `agree` when the table has exactly one +row per decoded FDE, the sorted starts match including repeats, and each row +points at a distinct FDE with that start. It is `disagree` otherwise, and +`unavailable` when the header is absent, unknown, or incomplete. Disagreement +is a limitation. The decoded FDEs stay in the result. The search table is +read for at most `max_ranges` rows, and for no more rows than the header bytes +can hold. When the declared FDE count is larger than either bound, the header +is `incomplete`, `table_entries` counts only the rows read, the comparison is +`unavailable`, and a limitation names the cap. A declared count that does not fit +in 32 bits is not decoded: `fde_count` is null, the header is `incomplete`, +and the decoded FDEs stay in the result. A header whose FDE count encoding is +`DW_EH_PE_omit` (`0xff`), as GNU ld writes when it cannot build the search +table, is `present` with a null `fde_count`, no table rows, and comparison +`unavailable`; it does not make coverage incomplete. A header that declares +an FDE count but writes `DW_EH_PE_omit` as its table encoding is `present` +with that count, no table rows, and comparison `unavailable`, and also keeps +coverage complete. A non-zero FDE count under an indirect encoding is the +address of a slot that is not followed, so `fde_count` is null, the header is +`incomplete`, and the table is not read. + +When section headers name both `.eh_frame_hdr` and `PT_GNU_EH_FRAME`, the +section is decoded. A limitation names the segment as ignored only when it +declares different bytes than the section. When no `.eh_frame_hdr` section is +named, the header is read from `PT_GNU_EH_FRAME` even if a `.eh_frame` +section is present. + +A record whose length word is `0xffffffff` uses the 64-bit extended length +that follows it. In `.eh_frame` the CIE ID and CIE pointer after that length +stay 4 bytes wide. + +`DW_EH_PE_datarel` fields in `.eh_frame_hdr` are relative to the header +address. In `.eh_frame` they are relative to the GOT: `DT_PLTGOT` when the +dynamic table names it, otherwise the `.got.plt` or `.got` section address. +A limitation names the base that was used. When no GOT base is declared, +those values are unresolved: the affected record is not reported and +`coverage.status` is `incomplete`. + +`debug_frame.status` is `present`, `absent`, `incomplete`, or `unknown`. It +is `unknown` when the image has no section headers or unreadable section +names. It is `incomplete`, with a null `location` and `coverage.status` at +`incomplete`, when the section is `SHT_NOBITS` or its bytes are not all in +the file. The `.eh_frame` results stay in the report. This command does not +decode `.debug_frame`. + +`cies` lists the CIEs that the returned page's ranges name, in index order, +so its length never exceeds the page size. Each `cie_index` in `ranges` +matches the `index` of one entry in `cies`. `coverage.total_cies` counts every +decoded CIE, including CIEs no FDE on this page uses. Each CIE reports +version 1 or 3, augmentation, code and data alignment, +return-address register, pointer encoding, LSDA encoding, personality address +when the augmentation carries one, signal-frame and B-key flags, and the byte +length of the CFA program. CFA instructions are counted and not evaluated. +A CIE whose augmentation has a letter other than `z`, `L`, `P`, `R`, `S`, or +`B` is not decoded: the encodings of later letters cannot be trusted, so its +FDEs are not decoded either. A limitation counts those FDEs and coverage is +`incomplete`. + +`ranges` is one page of FDEs. Each range has `start`, `end`, decimal `size`, +the containing `SHF_ALLOC` section, an exact symbol name when a symbol starts +at that address, the CIE index, LSDA, and personality. A function symbol is +preferred, then an `STT_GNU_IFUNC` symbol, then an untyped one. An IFUNC name +is the resolver that starts at that address, not the implementation it +selects at run time, and a limitation says so. Symbol names, section names, +and augmentation strings are target-derived and untrusted. Indirect EH +pointers are not dereferenced. An FDE whose initial location is indirect names +a pointer slot, not its function, so it is not reported as a range; a +limitation counts those FDEs and coverage is `incomplete`. An indirect +`eh_frame_ptr` does not locate `.eh_frame`, and the header is `incomplete`. +A search table with an indirect encoding is not read: the header is +`incomplete`, the comparison is `unavailable`, and the table does not bound a +header-located `.eh_frame`. + +An encoded pointer whose stored value is zero is a null pointer, as libgcc +reads it: no PC-relative, text, data, or function base is added and an +indirect pointer is not followed. A resolved address, and an FDE `end`, +wraps at the ELF address width (32 or 64 bits), as libgcc's pointer +arithmetic does. A zero LSDA pointer is reported as a null +`lsda`, even when the CIE declares an LSDA encoding. An FDE whose initial +location is zero marks a function the linker discarded. It is not reported +as a range, `examined_records` still counts it, and a limitation counts how +many were skipped. In a relocatable object an unrelocated initial location +is also zero, so those FDEs are skipped too. + +`page` reports `offset`, `limit`, `returned`, `total`, and `next_offset`. +`next_offset` is null when the page reaches the end of the decoded list. +`coverage` counts every decoded range, every decoded CIE, how many ranges +match a symbol, and how many do not. `examined_records` counts CIE and FDE records visited, including +records that could not be decoded. + +A missing `.eh_frame` section and a missing `PT_GNU_EH_FRAME` segment are +observed absence of those declarations. That absence is not proof the image +has no functions. Program headers come from the shared ELF section +directory, which rejects an image whose program-header table cannot be read, +so a missing `PT_GNU_EH_FRAME` is always observed absence. When section +headers are missing too, section names are unknown, so a missing header is +`unknown` rather than `absent`. + +A `.eh_frame` located only through the `.eh_frame_hdr` `eh_frame_ptr` has no +section size, so it is read up to the end of its `PT_LOAD` segment. Other +data, such as `.gcc_except_table`, can follow it there. When the search table +was decoded, the scan stops after the last FDE the table names, as libgcc's +`PT_GNU_EH_FRAME` lookup only binary-searches that table. Later bytes are not +decoded and a limitation says so. Without a usable table, the scan stops at +the first record that fails to decode, as libgcc's linear FDE search does, +and `coverage.status` is `incomplete`. A CIE with an unrecognized +augmentation letter, or an FDE that uses one, also stops that scan. Reaching the end of the segment with +no terminator also keeps coverage `incomplete`, because the table's extent is +then unknown. + +Addresses map to file offsets through `SHF_ALLOC` sections, then `PT_LOAD` +segments. A section without `SHF_ALLOC`, such as `.comment` or `.debug_*`, +is not loaded, so its `sh_addr` is never used for translation and it is never +reported as the section containing a range. A record that ends past the table, or non-zero bytes after +the terminator, keeps `coverage.status` at `incomplete`. Relocatable objects +stay incomplete because relocations are not applied. + +## Limits and failures + +The selected file is limited to 64 MiB by default (512 MiB maximum). +`max_ranges` defaults to 8192 and caps at 65536. That ceiling applies +separately to CIE records and to FDEs. Exceeding either budget fails without +partial success. One page returns at most 4096 ranges. Symbol lookup stops +after 65536 entries across `.symtab` and `.dynsym` combined. Reaching that +cap keeps `coverage.status` at `incomplete`. Only symbols at decoded FDE +starts are kept, and a name is decoded only for a returned range. The names +on one page share a fixed 16 MiB text allowance; a page that exceeds it fails +with a resource error whose recovery is a smaller `limit`, because +`max_ranges` and `max_file_bytes` do not raise it. + +Malformed ELF headers fail without partial success. This workflow does not +execute the image, evaluate CFA programs, or follow indirect pointers. diff --git a/docs/index.md b/docs/index.md index 16020670f4..2b5786f17e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,7 +52,7 @@ The [documentation directory](README.md) lists every guide by domain. | Task | Guides | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | -| Inspect native functions, references, or types | [Native binaries](guides/native/native-investigation.md), [Go metadata](guides/native/go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Linux kernel build config](guides/native/kernel-build-config.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](guides/native/binary-diagnostics.md), [recorded crashes](guides/native/recorded-crashes.md) | +| Inspect native functions, references, or types | [Native binaries](guides/native/native-investigation.md), [Go metadata](guides/native/go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [ELF unwind ranges](guides/native/elf-unwind-ranges.md), [Linux kernel modules](kernel-modules.md), [Linux kernel build config](guides/native/kernel-build-config.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](guides/native/binary-diagnostics.md), [recorded crashes](guides/native/recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](providers/ghidra-provider.md), [Windows](providers/windows-ghidra-p0.md), [DOS](providers/ghidra-dos.md), [raw bytes](providers/raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](providers/ghidra-mips.md), [RISC-V](providers/ghidra-riscv.md), [PowerPC](ghidra-ppc.md), [PSP](providers/ghidra-psp.md), [NativeAOT](providers/ghidra-nativeaot.md), [IDA](providers/ida-provider.md), [Kuna](kuna-provider.md), [JEB](providers/jeb-analysis.md) | | Analyze JavaScript or Electron artifacts | [Static analysis](guides/javascript/javascript-artifact-reconstruction.md), [tracing and comparisons](guides/javascript/javascript-application-workflows.md), [application graph](guides/javascript/javascript-application-graph.md), [source recovery](guides/javascript/javascript-recovery.md) | | Compare static JavaScript with runtime observations | [Runtime attribution](guides/javascript/javascript-runtime-observation.md), [reconciliation](guides/javascript/javascript-runtime-reconciliation.md), [Electron observation](guides/javascript/electron-observation.md), [Electron fuses](electron-fuses.md) | diff --git a/docs/specs/README.md b/docs/specs/README.md index fbff89e315..3f959feb33 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -15,5 +15,7 @@ for the specification lifecycle, roadmap granularity and producer-owned contract - [Go static string-header literal recovery](native/go-string-literals/spec.md) ([plan](native/go-string-literals/plan.md), [tasks](native/go-string-literals/tasks.md)) +- [ELF unwind function ranges](native/elf-unwind-ranges/spec.md) + ([plan](native/elf-unwind-ranges/plan.md), [tasks](native/elf-unwind-ranges/tasks.md)) - [Linux kernel build config](native/kernel-build-config/spec.md) ([plan](native/kernel-build-config/plan.md), [tasks](native/kernel-build-config/tasks.md)) diff --git a/docs/specs/native/elf-unwind-ranges/plan.md b/docs/specs/native/elf-unwind-ranges/plan.md new file mode 100644 index 0000000000..255029271f --- /dev/null +++ b/docs/specs/native/elf-unwind-ranges/plan.md @@ -0,0 +1,35 @@ +# Implementation plan + +## Boundary and contract + +`src/domain/native/elfUnwindRanges.ts` owns the input and result schemas, +limits and the shared limitation strings. `src/native/elf/ElfUnwindTables.ts` +decodes `.eh_frame` and `.eh_frame_hdr` over the existing ELF image reader. +Dynamic entries use the shared `readElfDynamicEntry`. +`src/native/elf/ElfIntervalIndex.ts` builds the section address and file-offset +indexes once per image. + +`src/application/binaryDiagnostics/ElfUnwindRangesService.ts` depends on the +domain and its port and validates the report shape. It is composed and +registered beside the sibling binary diagnostics tools in +`src/composition/` and `src/server/createServer.ts`. + +## Incremental work and validation + +1. Define the schemas, limits and limitations, with domain tests. +2. Decode CIEs and FDEs over synthetic fixtures for both widths and + endiannesses, including pointer encodings and extended lengths. +3. Decode `.eh_frame_hdr`, compare it with the FDEs, and bound the search + table. +4. Resolve symbols and containing sections through indexes, with a combined + symbol budget. +5. Register the CLI command and MCP tool, then add acceptance, filesystem and + MCP boundary tests. +6. Add the `verify:elf:unwind-ranges` script against a compiled C++ program. +7. Write the guide and the CLI reference entry, update the skill reference, + and run focused tests, lint, typecheck and docs checks. + +## Effects and compatibility + +Additive: one new read-only tool, one CLI command and one verifier script. +Existing tools and Evidence shapes do not change. diff --git a/docs/specs/native/elf-unwind-ranges/spec.md b/docs/specs/native/elf-unwind-ranges/spec.md new file mode 100644 index 0000000000..9955fa381c --- /dev/null +++ b/docs/specs/native/elf-unwind-ranges/spec.md @@ -0,0 +1,92 @@ +# ELF unwind function ranges + +## Problem and outcome + +A stripped ELF often has no symbol table, but its `.eh_frame` still names the +start and end of nearly every function so exceptions can unwind. REA can list +symbols and disassemble with a provider, but it cannot recover function +boundaries from unwind tables, so an agent without a disassembler has no +function list for a stripped binary. + +This feature adds `inspect_elf_unwind_ranges` and +`inspect-elf-unwind-ranges`. They read `.eh_frame` and `.eh_frame_hdr` from one +selected local ELF32 or ELF64 image and list the function ranges those tables +name, without executing the image. + +[ELF unwind function ranges](../../../guides/native/elf-unwind-ranges.md) describes the +current behavior and limitations. This directory is the feature +specification, implementation plan and task list. The guide does not replace +these files. + +## Requirements + +1. `.eh_frame` comes from section headers, or from `PT_GNU_EH_FRAME` when + section headers are missing. The source and examined file range are + reported. `SHT_NOBITS`, out-of-file and truncated tables are `incomplete`. + Program headers come from the shared section directory, which rejects an + unreadable program-header table, so a missing `PT_GNU_EH_FRAME` is observed + absence. An `.eh_frame` located only through the header's `eh_frame_ptr` + is read no further than the last FDE a decoded search table names. Without + a usable table it stops at the first undecodable record, including a CIE + with an unrecognized augmentation and its FDEs, or at the end of + its `PT_LOAD` without a terminator, and coverage is `incomplete`. +2. CIEs report version, augmentation, alignment factors, return-address + register, pointer and LSDA encodings, personality, and signal-frame and + B-key flags. A page returns only the CIEs its ranges name, so CIE output is + bounded by `limit`, and `coverage.total_cies` counts every decoded CIE. + CFA programs are counted, not evaluated. A CIE with an + unrecognized augmentation letter is not decoded, its FDEs are skipped + with a counted limitation, and coverage is `incomplete`. +3. FDEs become ranges with start, end, size, containing `SHF_ALLOC` section, an exact + symbol name when one starts at that address (a function, then an + `STT_GNU_IFUNC` resolver, then an untyped symbol), CIE index, LSDA and + personality, paged by `offset` and `limit`. +4. Encoded pointers follow the `DW_EH_PE` rules. A raw zero is a null pointer. + Resolved addresses and FDE ends wrap at the ELF32 or ELF64 address width. + `datarel` in `.eh_frame` resolves against `DT_PLTGOT`, then `.got.plt` or + `.got`, and an unresolved base leaves the record out with coverage + `incomplete`. Indirect pointers are not dereferenced: an FDE with an + indirect initial location is skipped with a counted limitation, and an + indirect `eh_frame_ptr` does not locate `.eh_frame`, and a search table + with an indirect encoding is neither compared nor used as a scan bound. + Each leaves coverage `incomplete`. +5. A 64-bit extended length keeps the `.eh_frame` CIE ID and pointer at 4 + bytes. +6. An FDE with a zero initial location is a discarded function. It is counted + as examined, not reported, and a limitation gives the count. +7. `.eh_frame_hdr` is decoded and its search table compared with the decoded + FDEs as `agree`, `disagree` or `unavailable`. The table is read for at most + `max_ranges` rows and no more rows than its bytes hold. A larger declared + count makes the header `incomplete` with a limitation. A declared count + with an omitted table is a `present` header with comparison + `unavailable`. A non-zero indirect count is not followed, so the count is + unknown and the header `incomplete`. +8. `.debug_frame` presence is reported but not decoded. +9. Budgets bound the file size, CIE and FDE counts, page size and the symbol + lookup across `.symtab` and `.dynsym`. Only symbols at decoded FDE starts + are kept, names are decoded only for the returned page, and that text has a + fixed 16 MiB allowance whose recovery is a smaller `limit`. Only the first `PT_GNU_EH_FRAME` is read; a + duplicate one that supplies the header makes coverage `incomplete`. Section and symbol lookups use + indexes built once per image, so cost does not grow as sections times + entries. Address translation uses `SHF_ALLOC` sections, then + `PT_LOAD`; a non-ALLOC section's `sh_addr` is never used. +10. Symbol names, section names and augmentation strings are marked + target-derived and untrusted. MCP requires an absolute path. + +## Acceptance + +- Unit tests cover CIE and FDE decoding for both widths and endiannesses, + pointer encodings, extended lengths, zero pointers, discarded FDEs, header + agreement and caps, and the GOT base. +- Service tests validate the report shape, including incomplete headers and + null LSDAs. +- CLI acceptance, filesystem boundary and MCP boundary tests exercise the + public contract. +- `npm run verify:elf:unwind-ranges` compares the reported FDE starts with + `readelf` or `llvm-dwarfdump` on a compiled C++ program. + +## Non-goals + +- Decoding `.debug_frame` or evaluating CFA programs. +- Applying relocations in relocatable objects. +- Following indirect EH pointers. diff --git a/docs/specs/native/elf-unwind-ranges/tasks.md b/docs/specs/native/elf-unwind-ranges/tasks.md new file mode 100644 index 0000000000..db7cc27214 --- /dev/null +++ b/docs/specs/native/elf-unwind-ranges/tasks.md @@ -0,0 +1,9 @@ +# Tasks + +- [x] Define the single-feature scope, contract and implementation plan. +- [x] Decode CIEs and FDEs with pointer encodings and extended lengths. +- [x] Decode and bound `.eh_frame_hdr`, and compare it with the FDEs. +- [x] Resolve symbols and sections through per-image indexes. +- [x] Register the CLI command and MCP tool with acceptance and boundary tests. +- [x] Verify FDE starts against `llvm-dwarfdump` on a C++ program built with zig. +- [x] Document the guide, CLI reference and skill reference. diff --git a/package.json b/package.json index 3e32b9fabd..9f46adc921 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ "verify:pcap": "npm run build:cached && node scripts/verify-pcap.mjs", "verify:pe:load-config": "npm run build:cached && node scripts/verify-pe-load-config.mjs", "verify:binary-hardening": "node scripts/verify-binary-hardening.mjs", + "verify:elf:unwind-ranges": "npm run build:cached && node scripts/verify-elf-unwind-ranges.mjs", "verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs", "verify:adb": "npm run build:cached && node scripts/verify-adb.mjs", "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", diff --git a/scripts/verify-elf-unwind-ranges.mjs b/scripts/verify-elf-unwind-ranges.mjs new file mode 100644 index 0000000000..121bf31155 --- /dev/null +++ b/scripts/verify-elf-unwind-ranges.mjs @@ -0,0 +1,246 @@ +/** + * Build a stripped C++ ELF and compare .eh_frame FDE starts with readelf. + * Not part of the default check. Exits non-zero when the host has no ELF + * C++ compiler or no readelf. + */ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; +import { createVerifierCommands } from "./lib/verify-commands.mjs"; + +/** + * Every probe, compiler driver and inspection runs as an owned process tree, + * so a timeout or close also stops the children a driver starts. Close it + * before removing the compiled outputs. + */ +const commands = await createVerifierCommands(); + +/** + * Ctrl-C or SIGTERM aborts the running command instead of ending Node, so the + * owned process trees and the compiled outputs are still cleaned up below. + */ +const interruption = new AbortController(); +const interrupt = (signal) => + interruption.abort( + new Error(`ELF unwind ranges verifier interrupted by ${signal}`), + ); +process.on("SIGINT", interrupt); +process.on("SIGTERM", interrupt); + +const run = (command, args, timeout) => + commands.exec(command, args, { + maxBuffer: 64 * 1024 * 1024, + timeout, + signal: interruption.signal, + }); + +const available = async (command) => { + // zig has no --version flag; it reports through the `version` subcommand. + const probe = command === "zig" ? ["version"] : ["--version"]; + try { + await run(command, probe, 30000); + return true; + } catch (failure) { + // An absent tool or a failing probe means the candidate is unavailable. + if (failure?.code === "ENOENT" || typeof failure?.code === "number") + return false; + throw failure; + } +}; + +const firstAvailable = async (candidates) => { + for (const command of candidates) + if (await available(command)) return command; + return null; +}; + +const sourceText = ` +struct Guard { ~Guard(); }; +Guard::~Guard() {} +int may_throw(int value) { + Guard guard; + if (value < 0) throw value; + return value + 1; +} +int main() { + try { return may_throw(1); } + catch (int) { return 2; } +} +`; + +const compileArguments = (compiler, source, output) => { + if (compiler === "zig") + return [ + "c++", + "-target", + "x86_64-linux-musl", + "-O0", + "-g", + source, + "-o", + output, + ]; + if (compiler === "clang++") + return [ + "--target=x86_64-linux-gnu", + "-O0", + "-g", + "-fexceptions", + source, + "-o", + output, + ]; + return ["-O0", "-g", "-fexceptions", source, "-o", output]; +}; + +const compile = async (compiler, source, output) => { + await run(compiler, compileArguments(compiler, source, output), 120000); +}; + +const stripArguments = (stripper, input, output) => { + if (stripper === "llvm-strip") return ["--strip-all", "-o", output, input]; + if (stripper === "llvm-objcopy") return ["--strip-all", input, output]; + if (stripper === "zig") return ["objcopy", "--strip-all", input, output]; + return ["-o", output, input]; +}; + +const stripCopy = async (stripper, input, output) => { + await run(stripper, stripArguments(stripper, input, output), 30000); +}; + +// llvm-readelf has no --debug-dump=frames; llvm-dwarfdump --eh-frame prints +// the same `pc=..` FDE ranges under an `.eh_frame contents:` head. +const frameDump = async (readelf, path) => + ( + await run( + readelf, + readelf === "llvm-dwarfdump" + ? ["--eh-frame", path] + : ["--debug-dump=frames", path], + 30000, + ) + ).stdout; + +const ehFrameStarts = async (readelf, path) => { + const stdout = await frameDump(readelf, path); + const starts = new Set(); + let inEhFrame = false; + for (const line of stdout.split("\n")) { + if (/Contents of the \.eh_frame\b|^\.eh_frame contents:/u.test(line)) { + inEhFrame = true; + continue; + } + if (/Contents of the \.debug_frame\b|^\.\w+ contents:/u.test(line)) { + inEhFrame = false; + continue; + } + if (!inEhFrame) continue; + const match = /pc=(0x[0-9a-fA-F]+|[0-9a-fA-F]+)\.\./u.exec(line); + // readelf and llvm-dwarfdump print bare hex; BigInt needs the 0x prefix. + if (match?.[1] !== undefined) + starts.add( + BigInt(match[1].startsWith("0x") ? match[1] : `0x${match[1]}`), + ); + } + return starts; +}; + +const inspectedStarts = async (path) => { + const starts = new Set(); + let offset = 0; + for (;;) { + const { stdout: output } = await run( + process.execPath, + [ + resolve("scripts/rea.mjs"), + "inspect-elf-unwind-ranges", + path, + "--json", + "--offset", + String(offset), + "--limit", + "4096", + "--max-ranges", + "65536", + ], + 60000, + ); + const report = JSON.parse(output).normalized_result; + for (const range of report.ranges) starts.add(BigInt(range.start)); + if (report.page.next_offset === null) { + assert.equal(report.eh_frame.status, "present"); + assert.equal(starts.size, report.coverage.total_ranges); + return starts; + } + offset = report.page.next_offset; + } +}; + +// The root holds every compiled image. Remove it only after the owned +// commands have settled, and keep it when their cleanup failed. +let root; +let commandsClosed = false; +let unavailable = false; +await withVerifierCleanup(async () => { + const compiler = await firstAvailable(["zig", "clang++", "g++"]); + const readelf = await firstAvailable([ + "readelf", + "greadelf", + "llvm-dwarfdump", + ]); + const stripper = await firstAvailable([ + "llvm-strip", + "llvm-objcopy", + "zig", + "strip", + ]); + if (compiler === null || readelf === null || stripper === null) { + console.error( + "verify:elf:unwind-ranges needs an ELF C++ compiler (zig, clang++, or g++) and readelf (readelf, greadelf, or llvm-dwarfdump), plus a stripper.", + ); + unavailable = true; + return; + } + root = await mkdtemp(join(tmpdir(), "rea-elf-unwind-")); + const source = join(root, "ranges.cpp"); + const unstripped = join(root, "ranges.unstripped"); + const stripped = join(root, "ranges.stripped"); + await writeFile(source, sourceText); + await compile(compiler, source, unstripped); + await stripCopy(stripper, unstripped, stripped); + const expected = await ehFrameStarts(readelf, unstripped); + const observed = await inspectedStarts(stripped); + assert.ok(expected.size > 0, "readelf listed no .eh_frame FDEs"); + const missing = [...expected].filter((start) => !observed.has(start)); + const extra = [...observed].filter((start) => !expected.has(start)); + assert.deepEqual( + { missing: missing.map(String), extra: extra.map(String) }, + { missing: [], extra: [] }, + ); + console.log( + JSON.stringify({ + compiler, + readelf, + fde_starts: expected.size, + }), + ); +}, [ + async () => { + await commands.close(); + commandsClosed = true; + }, + async () => { + if (commandsClosed && root !== undefined) + await rm(root, { recursive: true, force: true }); + }, + async () => { + process.off("SIGINT", interrupt); + process.off("SIGTERM", interrupt); + }, +]); +// A signal that lands outside a command, such as during the final rm, is still +// a failed run. An earlier failure already left through the rejection above. +if (interruption.signal.aborted) throw interruption.signal.reason; +if (unavailable) process.exitCode = 1; diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index b81ee8446e..52eaeb6d69 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -630,6 +630,7 @@ const workflowAvailabilityFor = ( return { reason: "available", remediation: null }; if ( name === "inspect_elf_notes" || + name === "inspect_elf_unwind_ranges" || name === "inspect_devicetree" || name === "inspect_kernel_module" || name === "inspect_kernel_build_config" || diff --git a/src/application/binaryDiagnostics/ElfUnwindRangesPort.ts b/src/application/binaryDiagnostics/ElfUnwindRangesPort.ts new file mode 100644 index 0000000000..0893cc02ec --- /dev/null +++ b/src/application/binaryDiagnostics/ElfUnwindRangesPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + ElfUnwindRanges, + InspectElfUnwindRangesInput, +} from "../../domain/native/elfUnwindRanges.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and ELF unwind-table decoding, independent of a disassembler session. */ +export interface ElfUnwindRangesPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectElfUnwindRangesInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/ElfUnwindRangesService.test.ts b/src/application/binaryDiagnostics/ElfUnwindRangesService.test.ts new file mode 100644 index 0000000000..88ce45ca43 --- /dev/null +++ b/src/application/binaryDiagnostics/ElfUnwindRangesService.test.ts @@ -0,0 +1,424 @@ +import { expect, it } from "vitest"; +import { ElfUnwindRangesService } from "./ElfUnwindRangesService.js"; +import { ELF_UNWIND_RANGES_PROVIDER } from "../../domain/investigationProviders.js"; +import { + elfUnwindRangesSchema, + inspectElfUnwindRangesInputSchema, + type ElfUnwindRanges, +} from "../../domain/native/elfUnwindRanges.js"; +import { parseElfUnwindRanges } from "../../native/elf/ElfUnwindTables.js"; +import { + elfUnwindAbsentFixture, + elfUnwindFixture, + elfUnwindSectionlessFixture, +} from "../../native/elf/ElfUnwindTables.fixture.js"; +import { ok } from "../../domain/result.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; + +const identity = ELF_UNWIND_RANGES_PROVIDER; + +const undecodableHeaderPointer = (): Buffer => { + const bytes = elfUnwindSectionlessFixture(); + bytes[Number(bytes.readBigUInt64LE(64 + 56 + 8)) + 1] = 0x0f; + return bytes; +}; +const report: ElfUnwindRanges = { + artifact: { + path: "/artifacts/app", + sha256: "a".repeat(64), + bytes: 512, + }, + format: "elf64", + byte_order: "little", + machine: 62, + type: 3, + type_name: "shared", + eh_frame: { status: "absent", source: null, location: null }, + eh_frame_hdr: { + status: "absent", + location: null, + version: null, + eh_frame_pointer_encoding: null, + fde_count_encoding: null, + table_encoding: null, + eh_frame_pointer: null, + fde_count: null, + table_entries: 0, + }, + debug_frame: { status: "absent", location: null }, + cies: [], + ranges: [], + page: { + offset: 0, + limit: 256, + returned: 0, + total: 0, + next_offset: null, + }, + coverage: { + status: "complete", + total_ranges: 0, + total_cies: 0, + ranges_with_symbols: 0, + ranges_without_symbols: 0, + eh_frame_hdr_agreement: "unavailable", + examined_records: 0, + }, + limitations: [ + "No .eh_frame section or PT_GNU_EH_FRAME segment was declared.", + "No .eh_frame_hdr section or PT_GNU_EH_FRAME segment was declared.", + "CFA instructions are counted but not evaluated.", + ".debug_frame is absent; only .eh_frame was examined.", + "Symbol names, section names, and augmentation strings are target-derived and untrusted.", + ], +}; + +const parsed = ( + bytes: Buffer, + raw: Readonly> = {}, +): ElfUnwindRanges => { + const input = inspectElfUnwindRangesInputSchema.parse({ + path: "/artifacts/unwind.elf", + ...raw, + }); + return parseElfUnwindRanges( + bytes, + { + path: input.path, + sha256: "a".repeat(64), + bytes: bytes.length, + }, + input, + ); +}; + +const accept = async ( + value: ElfUnwindRanges, + raw: Readonly> = {}, +) => { + const input = inspectElfUnwindRangesInputSchema.parse({ + path: value.artifact.path, + ...raw, + }); + return new ElfUnwindRangesService({ + identity, + inspect: () => Promise.resolve(ok(value)), + }).inspect(input); +}; + +it("rejects a relative path and extra arguments before reading", async () => { + let calls = 0; + const service = new ElfUnwindRangesService({ + identity, + inspect: () => { + calls += 1; + return Promise.resolve(ok(report)); + }, + }); + const relative = await service.inspect({ path: "app" }); + const extra = await service.inspect({ + path: report.artifact.path, + extra: true, + }); + expect(relative.ok).toBe(false); + expect(extra.ok).toBe(false); + if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError); + if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError); + expect(calls).toBe(0); +}); + +it("records an absent unwind table for the selected ELF", async () => { + const result = await accept(report); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + confidence: "observed", + subject: { format: "elf", architecture: "x86_64" }, + normalized_result: report, + }); + expect(result.value.locations).toEqual([ + { kind: "artifact-path", path: report.artifact.path }, + ]); +}); + +it.each([ + ["elf64 mismatch", elfUnwindFixture()], + ["elf64 agreement", elfUnwindFixture({ header: "match" })], + [ + "elf32 big-endian", + elfUnwindFixture({ bits: 32, little: false, machine: 20, header: "match" }), + ], + ["sectionless", elfUnwindSectionlessFixture()], + ["undecodable header pointer", undecodableHeaderPointer()], + ["absent", elfUnwindAbsentFixture()], + ["truncated", elfUnwindFixture({ truncate: true })], + ["trailing", elfUnwindFixture({ trailingByte: true, header: "match" })], + ["no header", elfUnwindFixture({ header: "absent" })], + ["version 3", elfUnwindFixture({ version3: true, header: "match" })], + ["dynsym", elfUnwindFixture({ symbolSource: "dynsym", header: "match" })], + ["no symbols", elfUnwindFixture({ symbolSource: "none", header: "match" })], + [ + "NOBITS .debug_frame", + elfUnwindFixture({ + header: "match", + backing: { section: ".debug_frame", kind: "nobits" }, + }), + ], + [ + ".debug_frame past end of file", + elfUnwindFixture({ + backing: { section: ".debug_frame", kind: "past-eof" }, + }), + ], + [ + ".eh_frame past end of file", + elfUnwindFixture({ + header: "match", + backing: { section: ".eh_frame", kind: "past-eof" }, + }), + ], + [ + "NOBITS .eh_frame", + elfUnwindFixture({ backing: { section: ".eh_frame", kind: "nobits" } }), + ], + [ + "NOBITS .eh_frame_hdr", + elfUnwindFixture({ backing: { section: ".eh_frame_hdr", kind: "nobits" } }), + ], + [ + ".eh_frame_hdr past end of file", + elfUnwindFixture({ + backing: { section: ".eh_frame_hdr", kind: "past-eof" }, + }), + ], + [ + "64-bit .eh_frame lengths", + elfUnwindFixture({ header: "match", extendedLength: true }), + ], + [ + "DT_PLTGOT datarel", + elfUnwindFixture({ header: "match", datarel: "dynamic" }), + ], + [ + "overstated .eh_frame_hdr fde_count", + elfUnwindFixture({ header: "match", headerCount: 0x7fffffff }), + ], +] as const)("accepts parser output for %s", async (_label, bytes) => { + const value = parsed(bytes); + const result = await accept(value); + expect(result.ok, _label).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toEqual(value); +}); + +it("accepts a paged parser report without changing coverage", async () => { + const value = parsed(elfUnwindFixture(), { offset: 1, limit: 1 }); + const result = await accept(value, { offset: 1, limit: 1 }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + page: { offset: 1, limit: 1, returned: 1, total: 2, next_offset: null }, + coverage: { total_ranges: 2, ranges_with_symbols: 1 }, + }); +}); + +it("bounds CIE Evidence locations by the page, not by the CIE count", async () => { + const value = parsed(elfUnwindFixture({ extraCies: 500 }), { + offset: 1, + limit: 1, + }); + expect(value.coverage.total_cies).toBe(502); + const result = await accept(value, { offset: 1, limit: 1 }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + cies: [{ index: 1 }], + }); + // Artifact path, .eh_frame, .eh_frame_hdr, .debug_frame, one CIE, one FDE. + expect(result.value.locations).toHaveLength(6); +}); + +it("rejects a page that returns a CIE none of its ranges name", async () => { + const full = parsed(elfUnwindFixture({ extraCies: 2 })); + const page = parsed(elfUnwindFixture({ extraCies: 2 }), { + offset: 1, + limit: 1, + }); + const extra = full.cies[0]; + if (extra === undefined) throw new Error("fixture has no CIE"); + const result = await accept( + { ...page, cies: [extra, ...page.cies] }, + { offset: 1, limit: 1 }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("accepts an FDE with a zero LSDA under a CIE that declares an LSDA encoding", async () => { + const value = parsed(elfUnwindFixture({ zeroLsda: true })); + expect(value.cies[1]?.lsda_encoding?.raw).toBe(0x1b); + expect(value.ranges[1]?.lsda).toBeNull(); + const result = await accept(value); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + ranges: [{ lsda: null }, { lsda: null }], + }); +}); + +it("accepts a header that declares a count but omits its search table", async () => { + const bytes = elfUnwindFixture({ header: "match" }); + const shoff = Number(bytes.readBigUInt64LE(40)); + const sections = bytes.readUInt16LE(60); + const names = Number( + bytes.readBigUInt64LE(shoff + bytes.readUInt16LE(62) * 64 + 24), + ); + for (let index = 1; index < sections; index += 1) { + const at = shoff + index * 64; + const nameAt = names + bytes.readUInt32LE(at); + if ( + bytes.toString("latin1", nameAt, bytes.indexOf(0, nameAt)) === + ".eh_frame_hdr" + ) + bytes[Number(bytes.readBigUInt64LE(at + 24)) + 3] = 0xff; + } + const value = parsed(bytes); + expect(value.eh_frame_hdr).toMatchObject({ + status: "present", + fde_count: 2, + table_encoding: { raw: 0xff }, + }); + const result = await accept(value); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + coverage: { status: "complete", eh_frame_hdr_agreement: "unavailable" }, + }); +}); + +it("accepts a null personality stored under an indirect encoding", async () => { + const value = parsed(elfUnwindFixture({ nullPersonality: true })); + expect(value.cies[1]?.personality).toMatchObject({ + address: "0x0", + indirect: false, + encoding: { raw: 0x9b }, + }); + const result = await accept(value); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + ranges: [{ personality: null }, { personality: { address: "0x0" } }], + }); +}); + +it("rejects a direct personality reported as indirect", async () => { + const value = parsed(elfUnwindFixture({ nullPersonality: true })); + const cie = value.cies[1]; + if (cie?.personality == null) throw new Error("fixture has no personality"); + cie.personality.encoding = { raw: 0x1b, name: "pcrel|sdata4" }; + cie.personality.indirect = true; + const result = await accept(value); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it.each([ + [ + "symbol totals", + (value: ElfUnwindRanges) => { + value.coverage.ranges_with_symbols += 1; + }, + ], + [ + "range outside the file", + (value: ElfUnwindRanges) => { + value.ranges[0]!.location.offset = value.artifact.bytes; + }, + ], + [ + "short page", + (value: ElfUnwindRanges) => { + value.ranges.pop(); + value.page.returned = 1; + value.page.next_offset = 1; + }, + ], + [ + "agreement without the mismatch limitation", + (value: ElfUnwindRanges) => { + value.limitations = value.limitations.filter( + (text) => !text.includes("does not match"), + ); + }, + ], +] as const)("rejects parser output with %s", async (_label, mutate) => { + const value = structuredClone(parsed(elfUnwindFixture())); + mutate(value); + const result = await accept(value); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("reports a header capped by max_ranges as an incomplete Result", async () => { + const value = parsed(elfUnwindFixture({ header: "duplicate-row" }), { + max_ranges: 2, + }); + const result = await accept(value, { max_ranges: 2 }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + eh_frame_hdr: { status: "incomplete", fde_count: 3, table_entries: 2 }, + coverage: { status: "incomplete", eh_frame_hdr_agreement: "unavailable" }, + }); +}); + +it("rejects header table entries beyond the max_ranges budget", async () => { + const value = structuredClone( + parsed(elfUnwindFixture({ header: "duplicate-row" }), { max_ranges: 2 }), + ); + value.eh_frame_hdr.table_entries = 3; + const result = await accept(value, { max_ranges: 2 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("rejects a report whose artifact path is not the selected file", async () => { + const value = structuredClone(parsed(elfUnwindFixture())); + value.artifact.path = "/artifacts/other"; + const result = await new ElfUnwindRangesService({ + identity, + inspect: () => Promise.resolve(ok(value)), + }).inspect({ path: "/artifacts/unwind.elf" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("rejects a report that fails the output schema", async () => { + const value = structuredClone(report); + value.format = "elf128" as ElfUnwindRanges["format"]; + expect(elfUnwindRangesSchema.safeParse(value).success).toBe(false); + const result = await accept(value); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("discards an observation if the caller cancels while the provider completes", async () => { + const controller = new AbortController(); + const service = new ElfUnwindRangesService({ + identity, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(report)); + }, + }); + const result = await service.inspect( + { path: report.artifact.path }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); +}); diff --git a/src/application/binaryDiagnostics/ElfUnwindRangesService.ts b/src/application/binaryDiagnostics/ElfUnwindRangesService.ts new file mode 100644 index 0000000000..21d6eccbd6 --- /dev/null +++ b/src/application/binaryDiagnostics/ElfUnwindRangesService.ts @@ -0,0 +1,399 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { ElfUnwindRangesPort } from "./ElfUnwindRangesPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { elfObjectTypeName } from "../../domain/native/elfNotes.js"; +import { + ELF_UNWIND_LIMITATIONS, + elfEhPointerEncodingName, + inspectElfUnwindRangesInputSchema, + elfUnwindRangesSchema, + MAX_INSPECT_ELF_UNWIND_RANGES, + type ElfUnwindRanges, +} from "../../domain/native/elfUnwindRanges.js"; +import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js"; +import { elfArchitecture } from "../../domain/elfFixedHeader.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; + +const OPERATION = "inspect_elf_unwind_ranges"; +const { + cfa: CFA_LIMITATION, + untrusted: UNTRUSTED_LIMITATION, + indirect: INDIRECT_LIMITATION, + headerMismatch: HEADER_MISMATCH, + frameAbsent: FRAME_ABSENT, + headerAbsent: HEADER_ABSENT, + debugPresent: DEBUG_PRESENT, + debugAbsent: DEBUG_ABSENT, + debugUnknown: DEBUG_UNKNOWN, + debugOutside: DEBUG_OUTSIDE, +} = ELF_UNWIND_LIMITATIONS; + +type Range = { readonly offset: number; readonly bytes: number }; +type Encoding = { readonly raw: number; readonly name: string }; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class ElfUnwindRangesService { + constructor(readonly provider: ElfUnwindRangesPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + const input = parseAnalysisInput( + OPERATION, + rawInput, + inspectElfUnwindRangesInputSchema, + options?.signal, + ); + if (!input.ok) return input; + const result = await this.provider.inspect(input.value, options); + if (!result.ok) return result; + const report = elfUnwindRangesSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.value.path || + !validReport( + report.data, + input.value.max_ranges, + input.value.max_file_bytes, + input.value.offset, + input.value.limit, + ) + ) + return err( + new AnalysisOutputError( + OPERATION, + "ELF unwind provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + const architecture = BINARY_ARCHITECTURES.find( + (supported) => supported === elfArchitecture(value.machine), + ); + const locations = [ + { kind: "artifact-path" as const, path: value.artifact.path }, + ...rangesOf(value).map((range) => ({ + kind: "file-offset-range" as const, + start: range.offset, + end: range.offset + range.bytes, + })), + ]; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: "elf", + sha256: value.artifact.sha256, + ...(architecture === undefined ? {} : { architecture }), + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.value, + result: value, + confidence: "observed", + limitations: value.limitations, + locations, + }, + ), + options?.signal, + ); + } +} + +const inside = + (fileBytes: number) => + (range: Range): boolean => + range.bytes <= fileBytes && range.offset <= fileBytes - range.bytes; + +const rangesOf = (report: ElfUnwindRanges): Range[] => { + const found: Range[] = []; + const add = (range: Range | null): void => { + if (range !== null) found.push(range); + }; + add(report.eh_frame.location); + add(report.eh_frame_hdr.location); + add(report.debug_frame.location); + for (const cie of report.cies) add(cie.location); + for (const range of report.ranges) add(range.location); + return found; +}; + +const encodingOk = (encoding: Encoding | null): boolean => + encoding === null || encoding.name === elfEhPointerEncodingName(encoding.raw); + +const has = (report: ElfUnwindRanges, text: string): boolean => + report.limitations.includes(text); + +const frameOk = (report: ElfUnwindRanges): boolean => { + const frame = report.eh_frame; + if (frame.status === "absent" || frame.status === "unknown") { + if ( + frame.source !== null || + frame.location !== null || + report.cies.length !== 0 || + report.coverage.total_cies !== 0 || + report.coverage.total_ranges !== 0 + ) + return false; + if (frame.status === "unknown") + return report.coverage.status === "incomplete"; + return has(report, FRAME_ABSENT); + } + if (frame.status === "present") + return frame.source !== null && frame.location !== null; + return ( + report.coverage.status === "incomplete" && + (frame.location === null || frame.source !== null) + ); +}; + +const headerOk = (report: ElfUnwindRanges, maxRanges: number): boolean => { + const header = report.eh_frame_hdr; + const encodings = [ + header.eh_frame_pointer_encoding, + header.fde_count_encoding, + header.table_encoding, + ]; + // A declared fde_count may exceed the budget, but decoded rows never may. + if (!encodings.every(encodingOk) || header.table_entries > maxRanges) + return false; + if (header.status === "absent" || header.status === "unknown") { + if ( + header.location !== null || + header.version !== null || + header.eh_frame_pointer !== null || + header.fde_count !== null || + header.table_entries !== 0 || + encodings.some((encoding) => encoding !== null) || + report.coverage.eh_frame_hdr_agreement !== "unavailable" + ) + return false; + return ( + header.status === "unknown" || + report.eh_frame.status === "unknown" || + has(report, HEADER_ABSENT) + ); + } + if (header.status === "incomplete") + return ( + report.coverage.status === "incomplete" && + report.coverage.eh_frame_hdr_agreement === "unavailable" && + (header.fde_count === null || header.table_entries <= header.fde_count) + ); + if ( + header.location === null || + header.version !== 1 || + header.eh_frame_pointer === null || + encodings.some((encoding) => encoding === null) + ) + return false; + // A header whose count encoding is DW_EH_PE_omit carries no search table. + if (header.fde_count_encoding?.raw === 0xff) + return ( + header.fde_count === null && + header.table_entries === 0 && + report.coverage.eh_frame_hdr_agreement === "unavailable" + ); + // A header may declare a count but omit its search table. + if (header.table_encoding?.raw === 0xff) + return ( + header.fde_count !== null && + header.table_entries === 0 && + report.coverage.eh_frame_hdr_agreement === "unavailable" + ); + if ( + header.fde_count === null || + header.table_entries !== header.fde_count || + encodings.some((encoding) => encoding === null) + ) + return false; + if (report.coverage.eh_frame_hdr_agreement === "agree") + return !has(report, HEADER_MISMATCH); + if (report.coverage.eh_frame_hdr_agreement === "disagree") + return has(report, HEADER_MISMATCH); + return false; +}; + +const debugOk = (report: ElfUnwindRanges): boolean => { + const debug = report.debug_frame; + if (debug.status === "present") + return debug.location !== null && has(report, DEBUG_PRESENT); + if (debug.location !== null) return false; + if (debug.status === "incomplete") + return ( + report.coverage.status === "incomplete" && has(report, DEBUG_OUTSIDE) + ); + return has(report, debug.status === "absent" ? DEBUG_ABSENT : DEBUG_UNKNOWN); +}; + +// The page returns exactly the CIEs its ranges name, in ascending index order, +// so CIE output is bounded by the page size rather than by max_ranges. +const cieOk = (report: ElfUnwindRanges): boolean => { + const named = new Set(report.ranges.map((range) => range.cie_index)); + if (report.cies.length !== named.size) return false; + let previous = -1; + for (const cie of report.cies) { + if ( + !named.has(cie.index) || + cie.index <= previous || + cie.index >= report.coverage.total_cies || + !cieEncodingsOk(cie) + ) + return false; + previous = cie.index; + } + return true; +}; + +const cieEncodingsOk = (cie: ElfUnwindRanges["cies"][number]): boolean => + encodingOk(cie.pointer_encoding) && + encodingOk(cie.lsda_encoding) && + (cie.personality === null || + (encodingOk(cie.personality.encoding) && + personalityIndirectOk(cie.personality))); + +/** + * Only an indirect encoding yields an indirect personality, but a raw zero is + * a null pointer the decoder never dereferences, so it stays direct. + */ +const personalityIndirectOk = ( + personality: NonNullable, +): boolean => { + const indirectEncoding = (personality.encoding.raw & 0x80) !== 0; + return personality.indirect + ? indirectEncoding + : !indirectEncoding || personality.address === "0x0"; +}; + +/** `end` is `start + size` wrapped at the ELF address width, as libgcc computes it. */ +const addressSpan = ( + bits: 32 | 64, + start: string, + end: string, + size: string, +): boolean => { + const length = BigInt(size); + return ( + length < 1n << BigInt(bits) && + BigInt(end) === BigInt.asUintN(bits, BigInt(start) + length) + ); +}; + +const rangeOk = (report: ElfUnwindRanges): boolean => { + const byIndex = new Map(report.cies.map((cie) => [cie.index, cie])); + return report.ranges.every((range, position) => { + const cie = byIndex.get(range.cie_index); + if (cie === undefined || range.index !== report.page.offset + position) + return false; + if ( + !addressSpan( + report.format === "elf32" ? 32 : 64, + range.start, + range.end, + range.size, + ) + ) + return false; + if (cie.personality === null) + return range.personality === null && lsdaOk(cie, range); + return ( + range.personality !== null && + range.personality.address === cie.personality.address && + range.personality.indirect === cie.personality.indirect && + lsdaOk(cie, range) + ); + }); +}; + +const lsdaOk = ( + cie: ElfUnwindRanges["cies"][number], + range: ElfUnwindRanges["ranges"][number], +): boolean => { + // A CIE with an LSDA encoding still admits a zero (null) LSDA per FDE. + const encoding = cie.lsda_encoding; + return range.lsda === null || (encoding !== null && encoding.raw !== 0xff); +}; + +const pageOk = (report: ElfUnwindRanges): boolean => { + const { page, coverage, ranges } = report; + if ( + page.total !== coverage.total_ranges || + page.returned !== ranges.length || + page.returned > page.limit + ) + return false; + const available = page.offset >= page.total ? 0 : page.total - page.offset; + if (page.returned !== Math.min(page.limit, available)) return false; + const end = page.offset + page.returned; + if (page.returned === 0) + return page.offset >= page.total && page.next_offset === null; + if (end > page.total) return false; + return page.next_offset === (end >= page.total ? null : end); +}; + +const coverageOk = (report: ElfUnwindRanges, maxRanges: number): boolean => { + const { coverage, cies, ranges } = report; + const pageSymbols = ranges.filter((range) => range.symbol !== null).length; + if ( + coverage.ranges_with_symbols + coverage.ranges_without_symbols !== + coverage.total_ranges || + coverage.ranges_with_symbols > coverage.total_ranges || + cies.length > ranges.length || + coverage.total_cies > maxRanges || + coverage.total_ranges > maxRanges || + coverage.examined_records > maxRanges * 2 || + coverage.examined_records < coverage.total_cies + coverage.total_ranges || + pageSymbols > coverage.ranges_with_symbols || + ranges.length - pageSymbols > coverage.ranges_without_symbols + ) + return false; + if (report.page.offset === 0 && report.page.returned === report.page.total) + return pageSymbols === coverage.ranges_with_symbols; + return true; +}; + +const indirectVisible = (report: ElfUnwindRanges): boolean => + report.cies.some((cie) => cie.personality?.indirect === true) || + report.ranges.some( + (range) => + range.lsda?.indirect === true || range.personality?.indirect === true, + ); + +const validReport = ( + report: ElfUnwindRanges, + maxRanges: number, + maxBytes: number, + offset: number, + limit: number, +): boolean => { + if (maxRanges > MAX_INSPECT_ELF_UNWIND_RANGES) return false; + if ( + report.artifact.bytes > maxBytes || + report.type_name !== elfObjectTypeName(report.type) || + report.page.offset !== offset || + report.page.limit !== limit || + !rangesOf(report).every(inside(report.artifact.bytes)) || + !has(report, CFA_LIMITATION) || + !has(report, UNTRUSTED_LIMITATION) || + (indirectVisible(report) && !has(report, INDIRECT_LIMITATION)) + ) + return false; + return ( + frameOk(report) && + headerOk(report, maxRanges) && + debugOk(report) && + cieOk(report) && + rangeOk(report) && + pageOk(report) && + coverageOk(report, maxRanges) + ); +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index a7aafeef62..32d10c53e8 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -10,6 +10,7 @@ import { createRecordedCrashService, createBinaryLayoutService, createElfNotesService, + createElfUnwindRangesService, createAppleIpsService, createDevicetreeService, createKernelModuleService, @@ -35,6 +36,7 @@ import { inspectPeImportsInputSchema } from "../domain/native/peImports.js"; import { inspectWindowsShortcutInputSchema } from "../domain/native/windowsShortcut.js"; import { inspectNativeExportsInputSchema } from "../domain/native/nativeExports.js"; import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js"; +import { inspectElfUnwindRangesInputSchema } from "../domain/native/elfUnwindRanges.js"; import { inspectAppleIpsInputSchema } from "../domain/native/appleIps.js"; import { inspectKernelModuleInputSchema } from "../domain/native/kernelModule.js"; import { inspectKernelBuildConfigInputSchema } from "../domain/native/kernelBuildConfig.js"; @@ -92,6 +94,50 @@ export const registerBinaryDiagnosticsCommands = ( ); }), }); + cli.command(CLI_COMMANDS.inspectElfUnwindRanges, { + description: + "Inspect ELF unwind function ranges without executing the image", + args: z.object({ + path: localPathStringSchema.describe("Local ELF32/ELF64 image"), + }), + options: z.object({ + maxFileBytes: cliDecimalField( + inspectElfUnwindRangesInputSchema.shape.max_file_bytes, + ), + maxRanges: cliDecimalField( + inspectElfUnwindRangesInputSchema.shape.max_ranges, + ), + offset: cliDecimalField(inspectElfUnwindRangesInputSchema.shape.offset), + limit: cliDecimalField(inspectElfUnwindRangesInputSchema.shape.limit), + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const unwind = createElfUnwindRangesService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectElfUnwindRanges, + async () => + finishCliEvidenceCommand( + args.path, + () => + unwind.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_ranges: options.maxRanges, + offset: options.offset, + limit: options.limit, + }, + { signal }, + ), + unwind, + "rea-elf-unwind-ranges", + "inspect_elf_unwind_ranges", + signal, + ).then(projectCliAnalysisResult), + ); + }), + }); cli.command(CLI_COMMANDS.inspectAppleIps, { description: "Inspect an Apple .ips crash report without executing a target", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index bd0cd72b9f..847c33b327 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -83,6 +83,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectNativeExports: "inspect-native-exports", inspectDevicetree: "inspect-devicetree", inspectElfNotes: "inspect-elf-notes", + inspectElfUnwindRanges: "inspect-elf-unwind-ranges", inspectAppleIps: "inspect-apple-ips", inspectKernelModule: "inspect-kernel-module", inspectKernelBuildConfig: "inspect-kernel-build-config", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index c54c377638..9a33e7e3a0 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -18,6 +18,8 @@ import { WindowsShortcutService } from "../application/binaryDiagnostics/Windows import { WindowsShortcutProvider } from "../native/windows/WindowsShortcutProvider.js"; import { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; import { ElfNotesProvider } from "../native/elf/ElfNotesProvider.js"; +import { ElfUnwindRangesService } from "../application/binaryDiagnostics/ElfUnwindRangesService.js"; +import { ElfUnwindRangesProvider } from "../native/elf/ElfUnwindRangesProvider.js"; import { AppleIpsService } from "../application/binaryDiagnostics/AppleIpsService.js"; import { AppleIpsProvider } from "../native/apple/AppleIpsProvider.js"; import { KernelModuleService } from "../application/binaryDiagnostics/KernelModuleService.js"; @@ -58,6 +60,10 @@ export const createNativeExportsService = (): NativeExportsService => export const createElfNotesService = (): ElfNotesService => new ElfNotesService(new ElfNotesProvider()); +/** Portable ELF unwind-range inspection with no engine or disassembler. */ +export const createElfUnwindRangesService = (): ElfUnwindRangesService => + new ElfUnwindRangesService(new ElfUnwindRangesProvider()); + /** Portable Apple .ips inspection with no engine, Xcode or target acquisition. */ export const createAppleIpsService = (): AppleIpsService => new AppleIpsService(new AppleIpsProvider()); diff --git a/src/composition/capabilityDeclarations.ts b/src/composition/capabilityDeclarations.ts index b80b101013..8ab1705850 100644 --- a/src/composition/capabilityDeclarations.ts +++ b/src/composition/capabilityDeclarations.ts @@ -30,6 +30,7 @@ import { BYTE_ENTROPY_PROVIDER, DEVICETREE_PROVIDER, ELF_NOTES_PROVIDER, + ELF_UNWIND_RANGES_PROVIDER, HARMONY_APPLICATION_PROVIDER, JAVASCRIPT_APPLICATION_PROVIDER, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, @@ -259,6 +260,13 @@ export const CAPABILITY_DECLARATIONS = { elfNotes: { providers: [providerBinding(ELF_NOTES_PROVIDER, ["inspect_elf_notes"])], }, + elfUnwindRanges: { + providers: [ + providerBinding(ELF_UNWIND_RANGES_PROVIDER, [ + "inspect_elf_unwind_ranges", + ]), + ], + }, erlangBeam: { providers: [ providerBinding(ERLANG_BEAM_PROVIDER_IDENTITY, { diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index a31514a86c..d43618acb5 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -81,6 +81,7 @@ import { pwntoolsHostSupported, createDevicetreeService, createElfNotesService, + createElfUnwindRangesService, createAppleIpsService, createKernelModuleService, createKernelBuildConfigService, @@ -361,6 +362,7 @@ const modules = { nativeExports: service(() => createNativeExportsService()), devicetree: service(() => createDevicetreeService()), elfNotes: service(() => createElfNotesService()), + elfUnwindRanges: service(() => createElfUnwindRangesService()), appleIps: service(() => createAppleIpsService()), kernelModule: service(() => createKernelModuleService()), kernelBuildConfig: service(() => createKernelBuildConfigService()), diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index f1272ef9d1..45880c9d65 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -52,6 +52,10 @@ import { inspectElfNotesInputSchema, elfNotesSchema, } from "../../domain/native/elfNotes.js"; +import { + inspectElfUnwindRangesInputSchema, + elfUnwindRangesSchema, +} from "../../domain/native/elfUnwindRanges.js"; import { inspectAppleIpsInputSchema, appleIpsSchema, @@ -148,6 +152,21 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ }, ], }, + { + name: "inspect_elf_unwind_ranges", + ...toolContractMetadata("inspect_elf_unwind_ranges"), + kind: "native-provider", + description: + "Inspect one explicit local ELF32/ELF64 image's .eh_frame and .eh_frame_hdr without executing it or opening a disassembler. Returns a page of FDE function ranges with start, end, size, containing section, exact symbol name when one exists, CIE index, LSDA, and personality, plus version, augmentation, alignments, return-address register, and pointer encoding for only the CIEs those ranges name, with a total CIE count in coverage. Coverage counts ranges that match a symbol, ranges without a symbol, and whether the .eh_frame_hdr search table agrees with the decoded FDEs. .debug_frame is reported present, absent, or incomplete and is not decoded. A missing unwind table is observed absence of those declarations, not proof the image has no functions. Truncated records stay incomplete coverage. Reads a stable regular file; malformed ELF headers and exhausted CIE or FDE budgets return no partial success. CFA instructions are counted and not evaluated. Indirect EH pointers are not dereferenced. Portable on supported Node hosts.", + inputSchema: inspectElfUnwindRangesInputSchema, + outputSchema: evidenceResultOf(elfUnwindRangesSchema), + examples: [ + { + title: "List function ranges from ELF unwind tables", + input: { path: "/artifacts/program" }, + }, + ], + }, { name: "inspect_apple_ips", ...toolContractMetadata("inspect_apple_ips"), diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index dfd51bc2be..fd4fa12dea 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -293,6 +293,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { inspect_windows_shortcut: evidence, inspect_native_exports: evidence, inspect_elf_notes: evidence, + inspect_elf_unwind_ranges: evidence, inspect_apple_ips: evidence, inspect_devicetree: evidence, inspect_kernel_module: evidence, diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index f80e47affb..6e656d7ccd 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -33,6 +33,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_pe_resources" | "inspect_pe_certificates" | "inspect_elf_notes" + | "inspect_elf_unwind_ranges" | "inspect_devicetree" | "inspect_api_contract" | "inspect_playwright_trace" diff --git a/src/domain/investigationProviders.ts b/src/domain/investigationProviders.ts index d71013fc4e..98821f4d9f 100644 --- a/src/domain/investigationProviders.ts +++ b/src/domain/investigationProviders.ts @@ -60,6 +60,13 @@ export const ELF_NOTES_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for portable, execution-free ELF unwind-table decoding. */ +export const ELF_UNWIND_RANGES_PROVIDER = Object.freeze({ + id: "rea-elf-unwind-ranges", + name: "REA ELF unwind range parser", + version: "1", +} as const); + /** Provider identity for portable, execution-free Apple .ips crash-report decoding. */ export const APPLE_IPS_PROVIDER = Object.freeze({ id: "rea-apple-ips", diff --git a/src/domain/native/elfUnwindRanges.test.ts b/src/domain/native/elfUnwindRanges.test.ts new file mode 100644 index 0000000000..c560f220b9 --- /dev/null +++ b/src/domain/native/elfUnwindRanges.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from "vitest"; +import { + elfEhPointerEncodingName, + elfUnwindRangesSchema, + inspectElfUnwindRangesInputSchema, +} from "./elfUnwindRanges.js"; + +it("names DW_EH_PE encodings without dropping application or indirect bits", () => { + expect(elfEhPointerEncodingName(0x1b)).toBe("pcrel|sdata4"); + expect(elfEhPointerEncodingName(0x9b)).toBe("pcrel|sdata4|indirect"); + expect(elfEhPointerEncodingName(0x3b)).toBe("datarel|sdata4"); + expect(elfEhPointerEncodingName(0x03)).toBe("udata4"); + expect(elfEhPointerEncodingName(0)).toBe("absptr"); + expect(elfEhPointerEncodingName(0xff)).toBe("omit"); +}); + +it("rejects extra caller arguments and admits an empty unwind inventory", () => { + expect( + inspectElfUnwindRangesInputSchema.safeParse({ + path: "/artifacts/app", + extra: true, + }).success, + ).toBe(false); + const parsed = inspectElfUnwindRangesInputSchema.parse({ + path: "/artifacts/app", + }); + expect(parsed.max_ranges).toBe(8192); + expect(parsed.limit).toBe(256); + const report = elfUnwindRangesSchema.parse({ + artifact: { + path: "/artifacts/app", + sha256: "a".repeat(64), + bytes: 512, + }, + format: "elf64", + byte_order: "little", + machine: 62, + type: 3, + type_name: "shared", + eh_frame: { status: "absent", source: null, location: null }, + eh_frame_hdr: { + status: "absent", + location: null, + version: null, + eh_frame_pointer_encoding: null, + fde_count_encoding: null, + table_encoding: null, + eh_frame_pointer: null, + fde_count: null, + table_entries: 0, + }, + debug_frame: { status: "absent", location: null }, + cies: [], + ranges: [], + page: { + offset: 0, + limit: 256, + returned: 0, + total: 0, + next_offset: null, + }, + coverage: { + status: "complete", + total_ranges: 0, + total_cies: 0, + ranges_with_symbols: 0, + ranges_without_symbols: 0, + eh_frame_hdr_agreement: "unavailable", + examined_records: 0, + }, + limitations: ["CFA instructions are counted but not evaluated."], + }); + expect(report.ranges).toEqual([]); + expect(report.coverage.total_ranges).toBe(0); +}); diff --git a/src/domain/native/elfUnwindRanges.ts b/src/domain/native/elfUnwindRanges.ts new file mode 100644 index 0000000000..3855f04313 --- /dev/null +++ b/src/domain/native/elfUnwindRanges.ts @@ -0,0 +1,239 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; +import { ELF_OBJECT_TYPE_NAMES } from "./elfNotes.js"; + +const uint = z.number().int().min(0).max(0xffffffff); +const hexAddress = z.string().regex(/^0x[0-9a-f]+$/u); +const decimalSize = z.string().regex(/^(?:0|[1-9][0-9]*)$/u); +const range = z.strictObject({ offset: uint, bytes: uint }); + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_ELF_UNWIND_FILE_BYTES = 536870912; +/** Hard ceiling on decoded CIE records and, separately, on decoded FDEs. */ +export const MAX_INSPECT_ELF_UNWIND_RANGES = 65536; +/** Hard ceiling on one returned page of FDE ranges. */ +export const MAX_INSPECT_ELF_UNWIND_PAGE = 4096; +/** Hard ceiling on symbol-table entries scanned, across all tables, for exact-start names. */ +export const MAX_ELF_UNWIND_SYMBOLS = 65536; +/** Fixed allowance on symbol-name text decoded for one returned page. */ +export const MAX_ELF_UNWIND_SYMBOL_TEXT_BYTES = 16 * 1024 * 1024; + +/** + * Limitation texts the parser emits and the application layer checks for. + * One owner keeps the producer and the report validation in step. + */ +export const ELF_UNWIND_LIMITATIONS = { + cfa: "CFA instructions are counted but not evaluated.", + untrusted: + "Symbol names, section names, and augmentation strings are target-derived and untrusted.", + indirect: "Indirect EH pointers were not dereferenced.", + headerMismatch: + "The .eh_frame_hdr binary search table does not match the decoded FDEs.", + frameAbsent: "No .eh_frame section or PT_GNU_EH_FRAME segment was declared.", + headerAbsent: + "No .eh_frame_hdr section or PT_GNU_EH_FRAME segment was declared.", + debugPresent: ".debug_frame is present and was not decoded.", + debugAbsent: ".debug_frame is absent; only .eh_frame was examined.", + debugUnknown: ".debug_frame presence is unknown.", + debugOutside: + ".debug_frame is declared but its bytes are not in the file; it was not examined.", +} as const; + +const FORMAT_NAMES: Readonly> = { + 0: "absptr", + 1: "uleb128", + 2: "udata2", + 3: "udata4", + 4: "udata8", + 9: "sleb128", + 0x0a: "sdata2", + 0x0b: "sdata4", + 0x0c: "sdata8", +}; + +const APPLICATION_NAMES: Readonly> = { + 0x10: "pcrel", + 0x20: "textrel", + 0x30: "datarel", + 0x40: "funcrel", + 0x50: "aligned", +}; + +/** DW_EH_PE name such as `pcrel|sdata4` or `pcrel|sdata4|indirect`. */ +export const elfEhPointerEncodingName = (raw: number): string => { + const encoding = raw & 0xff; + if (encoding === 0xff) return "omit"; + const format = + FORMAT_NAMES[encoding & 0x0f] ?? `unknown${(encoding & 0x0f).toString(16)}`; + const application = encoding & 0x70; + const parts: string[] = []; + if (application !== 0) + parts.push( + APPLICATION_NAMES[application] ?? `unknown${application.toString(16)}`, + ); + parts.push(format); + if ((encoding & 0x80) !== 0) parts.push("indirect"); + return parts.join("|"); +}; + +const untrustedText = z.strictObject({ + text: z.string().min(1), + trust: z.literal("untrusted"), +}); + +const untrustedAugmentation = z.strictObject({ + text: z.string(), + trust: z.literal("untrusted"), +}); + +const encodingSchema = z.strictObject({ + raw: z.number().int().min(0).max(255), + name: z.string().min(1), +}); + +const tableStatus = z.enum(["present", "absent", "incomplete", "unknown"]); + +/** Caller-selected page of ELF unwind function ranges. */ +export const inspectElfUnwindRangesInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to an ELF image", + ), + max_file_bytes: z + .number() + .int() + .min(64) + .max(MAX_INSPECT_ELF_UNWIND_FILE_BYTES) + .default(67108864) + .describe("Maximum stable artifact bytes to read (default 64 MiB)"), + max_ranges: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_ELF_UNWIND_RANGES) + .default(8192) + .describe( + "Maximum CIE records and, separately, maximum FDEs to decode; exceeding either budget fails without partial success", + ), + offset: z + .number() + .int() + .min(0) + .max(0xffffffff) + .default(0) + .describe("Zero-based index of the first FDE range to return"), + limit: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_ELF_UNWIND_PAGE) + .default(256) + .describe("Maximum FDE ranges to return in this page"), +}); + +const cieSchema = z.strictObject({ + index: uint, + version: z.union([z.literal(1), z.literal(3)]), + augmentation: untrustedAugmentation, + code_alignment: z.number().int().nonnegative(), + data_alignment: z.number().int(), + return_address_register: z.number().int().nonnegative(), + pointer_encoding: encodingSchema, + lsda_encoding: encodingSchema.nullable(), + personality: z + .strictObject({ + address: hexAddress, + encoding: encodingSchema, + indirect: z.boolean(), + }) + .nullable(), + signal_frame: z.boolean(), + b_key: z.boolean(), + instruction_bytes: uint, + location: range, +}); + +const functionRangeSchema = z.strictObject({ + index: uint, + start: hexAddress, + end: hexAddress, + size: decimalSize, + section: untrustedText.nullable(), + symbol: untrustedText.nullable(), + cie_index: uint, + lsda: z + .strictObject({ + address: hexAddress, + indirect: z.boolean(), + }) + .nullable(), + personality: z + .strictObject({ + address: hexAddress, + indirect: z.boolean(), + }) + .nullable(), + location: range, +}); + +/** Observed .eh_frame function ranges. Truncation is incomplete, not absence. */ +export const elfUnwindRangesSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256: digestSchema, + bytes: uint, + }), + format: z.enum(["elf32", "elf64"]), + byte_order: z.enum(["little", "big"]), + machine: uint, + type: uint, + type_name: z.enum(ELF_OBJECT_TYPE_NAMES), + eh_frame: z.strictObject({ + status: tableStatus, + source: z.enum(["section", "program"]).nullable(), + location: range.nullable(), + }), + eh_frame_hdr: z.strictObject({ + status: tableStatus, + location: range.nullable(), + version: uint.nullable(), + eh_frame_pointer_encoding: encodingSchema.nullable(), + fde_count_encoding: encodingSchema.nullable(), + table_encoding: encodingSchema.nullable(), + eh_frame_pointer: hexAddress.nullable(), + fde_count: uint.nullable(), + table_entries: uint, + }), + debug_frame: z.strictObject({ + /** `incomplete`: declared, but NOBITS or outside the file. */ + status: z.enum(["present", "absent", "incomplete", "unknown"]), + location: range.nullable(), + }), + /** Decoded CIEs named by this page's ranges, in index order. */ + cies: z.array(cieSchema), + ranges: z.array(functionRangeSchema), + page: z.strictObject({ + offset: uint, + limit: uint, + returned: uint, + total: uint, + next_offset: uint.nullable(), + }), + coverage: z.strictObject({ + status: z.enum(["complete", "incomplete"]), + total_ranges: uint, + /** Every decoded CIE, including ones this page does not return. */ + total_cies: uint, + ranges_with_symbols: uint, + ranges_without_symbols: uint, + eh_frame_hdr_agreement: z.enum(["agree", "disagree", "unavailable"]), + examined_records: uint, + }), + limitations: z.array(z.string()), +}); + +export type ElfUnwindRanges = z.infer; +export type InspectElfUnwindRangesInput = z.infer< + typeof inspectElfUnwindRangesInputSchema +>; diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index 27795bfbd8..ccd1867bd1 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -6,5 +6,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "61", + skillVersion: "62", } as const; diff --git a/src/native/elf/ElfIntervalIndex.test.ts b/src/native/elf/ElfIntervalIndex.test.ts new file mode 100644 index 0000000000..eabb62edec --- /dev/null +++ b/src/native/elf/ElfIntervalIndex.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from "vitest"; + +import { buildIntervalIndex } from "./ElfIntervalIndex.js"; + +it("returns the preferred overlapping interval and nothing in gaps", () => { + const lookup = buildIntervalIndex( + [ + { low: 0n, high: 100n, value: "wide" }, + { low: 10n, high: 20n, value: "narrow" }, + { low: 200n, high: 210n, value: "far" }, + { low: 5n, high: 5n, value: "empty" }, + ], + (left, right) => + left === "narrow" + ? -1 + : right === "narrow" + ? 1 + : left.localeCompare(right), + ); + expect(lookup(0n)).toBe("wide"); + expect(lookup(9n)).toBe("wide"); + expect(lookup(10n)).toBe("narrow"); + expect(lookup(19n)).toBe("narrow"); + expect(lookup(20n)).toBe("wide"); + expect(lookup(99n)).toBe("wide"); + expect(lookup(100n)).toBeUndefined(); + expect(lookup(205n)).toBe("far"); + expect(lookup(210n)).toBeUndefined(); + expect(lookup(-1n)).toBeUndefined(); +}); + +it("answers many lookups over many overlapping intervals", () => { + const intervals = Array.from({ length: 65535 }, (_, index) => ({ + low: BigInt(index * 16), + high: BigInt(index * 16 + 32), + value: index, + })); + const lookup = buildIntervalIndex(intervals, (left, right) => left - right); + expect(lookup(0n)).toBe(0); + expect(lookup(16n)).toBe(0); + expect(lookup(32n)).toBe(1); + expect(lookup(BigInt(65534 * 16 + 31))).toBe(65534); +}); diff --git a/src/native/elf/ElfIntervalIndex.ts b/src/native/elf/ElfIntervalIndex.ts new file mode 100644 index 0000000000..6c0ff86c47 --- /dev/null +++ b/src/native/elf/ElfIntervalIndex.ts @@ -0,0 +1,80 @@ +/** One half-open `[low, high)` range owned by `value`. */ +export interface IndexedInterval { + readonly low: bigint; + readonly high: bigint; + readonly value: T; +} + +const compareBig = (left: bigint, right: bigint): number => + left < right ? -1 : left > right ? 1 : 0; + +/** First index whose boundary is greater than `key`. */ +const upperBound = (boundaries: readonly bigint[], key: bigint): number => { + let low = 0; + let high = boundaries.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (boundaries[middle]! <= key) low = middle + 1; + else high = middle; + } + return low; +}; + +/** First index whose boundary is at least `key`. */ +const lowerBound = (boundaries: readonly bigint[], key: bigint): number => { + let low = 0; + let high = boundaries.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (boundaries[middle]! < key) low = middle + 1; + else high = middle; + } + return low; +}; + +/** + * Builds a lookup that returns, for any key, the highest-priority interval + * containing it (`prefer(a, b) < 0` means `a` wins). Overlaps are resolved + * once at build time, so each lookup is a binary search instead of a scan of + * every interval. + */ +export const buildIntervalIndex = ( + intervals: readonly IndexedInterval[], + prefer: (left: T, right: T) => number, +): ((key: bigint) => T | undefined) => { + const usable = intervals.filter((interval) => interval.high > interval.low); + const boundaries = [ + ...new Set(usable.flatMap((interval) => [interval.low, interval.high])), + ].sort(compareBig); + const segments = Math.max(0, boundaries.length - 1); + const owners: Array = Array.from({ length: segments }); + // next[i] is the first unpainted segment at or after i (path-compressed). + const next = Int32Array.from({ length: segments + 1 }, (_, index) => index); + const find = (index: number): number => { + let root = index; + while (next[root] !== root) root = next[root]!; + let cursor = index; + while (next[cursor] !== root) { + const following = next[cursor]!; + next[cursor] = root; + cursor = following; + } + return root; + }; + const ordered = [...usable].sort((left, right) => + prefer(left.value, right.value), + ); + for (const interval of ordered) { + const last = lowerBound(boundaries, interval.high); + let segment = find(lowerBound(boundaries, interval.low)); + while (segment < last) { + owners[segment] = interval.value; + next[segment] = segment + 1; + segment = find(segment + 1); + } + } + return (key) => { + const segment = upperBound(boundaries, key) - 1; + return segment < 0 || segment >= segments ? undefined : owners[segment]; + }; +}; diff --git a/src/native/elf/ElfUnwindRangesProvider.ts b/src/native/elf/ElfUnwindRangesProvider.ts new file mode 100644 index 0000000000..5245830c92 --- /dev/null +++ b/src/native/elf/ElfUnwindRangesProvider.ts @@ -0,0 +1,258 @@ +import { + projectStableArtifactFailure, + stableArtifactInputRecovery, + projectArtifactCloseFailure, +} from "../../artifacts/StableArtifactFailure.js"; +import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import type { ElfUnwindRangesPort } from "../../application/binaryDiagnostics/ElfUnwindRangesPort.js"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; +import { + MAX_INSPECT_ELF_UNWIND_FILE_BYTES, + MAX_INSPECT_ELF_UNWIND_RANGES, + type InspectElfUnwindRangesInput, +} from "../../domain/native/elfUnwindRanges.js"; +import { err, ok } from "../../domain/result.js"; +import { ELF_UNWIND_RANGES_PROVIDER } from "../../domain/investigationProviders.js"; +import { + BinaryFormatFailure, + BinaryResourceFailure, +} from "../metadata/BinaryContainer.js"; +import { + ElfUnwindSymbolTextFailure, + parseElfUnwindRanges, +} from "./ElfUnwindTables.js"; + +const OPERATION = "inspect_elf_unwind_ranges"; + +/** Portable parser over one bounded, identity-checked regular file. */ +export class ElfUnwindRangesProvider implements ElfUnwindRangesPort { + readonly identity = ELF_UNWIND_RANGES_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + throw projectArtifactCloseFailure( + cause, + ELF_UNWIND_RANGES_PROVIDER.id, + OPERATION, + ); + } + } + + async inspect( + input: InspectElfUnwindRangesInput, + options?: ExecutionOptions, + ) { + try { + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + return ok( + parseElfUnwindRanges( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + options?.signal, + ), + ); + }); + } catch (cause) { + return err(elfUnwindFailure(cause, input)); + } + } +} + +const inputBudgetRemediation = ( + boundary: "file-size" | "capacity", + input: InspectElfUnwindRangesInput, +): string => + boundary === "file-size" + ? input.max_file_bytes >= MAX_INSPECT_ELF_UNWIND_FILE_BYTES + ? `The image exceeds the ${String(MAX_INSPECT_ELF_UNWIND_FILE_BYTES)}-byte input ceiling this operation accepts; select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.` + : input.max_ranges >= MAX_INSPECT_ELF_UNWIND_RANGES + ? `The image exceeds the ${String(MAX_INSPECT_ELF_UNWIND_RANGES)}-record unwind ceiling this operation accepts; select a smaller artifact.` + : `Raise max_ranges above ${String(input.max_ranges)} or select a smaller artifact; the limit is the caller-selected unwind record budget.`; + +const elfUnwindFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectElfUnwindRangesInput, +) => { + const { cause, origin } = facts; + // The page's symbol-name text allowance is fixed; only a smaller page avoids it. + if (cause instanceof ElfUnwindSymbolTextFailure) + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + cause.message, + { limit: input.limit, maximum_bytes: cause.maximumBytes }, + { + cause, + retryable: false, + remediationAction: `Request a smaller limit than ${String(input.limit)}, or page with offset; the ${String(cause.maximumBytes)}-byte symbol-name text allowance is fixed, and max_ranges and max_file_bytes do not raise it.`, + }, + ); + if (cause instanceof BinaryResourceFailure) + return new AnalysisResourceConstraintError( + OPERATION, + "memory", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + ...fixedAllowanceOptions( + "The 16 MiB ELF structural decoding budget", + "parser", + ), + }, + ); + if (cause instanceof BinaryFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { cause }, + ) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ]); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { cause }, + ); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") { + const structural = cause.boundary === "memory"; + return new AnalysisResourceConstraintError( + OPERATION, + cause.boundary ?? "memory", + cause.message, + { + max_file_bytes: input.max_file_bytes, + max_ranges: input.max_ranges, + }, + { + cause, + ...(cause.boundary === undefined + ? {} + : structural + ? fixedAllowanceOptions( + "The ELF unwind structural decoding budget", + "parser", + ) + : { + retryable: false as const, + remediationAction: inputBudgetRemediation( + cause.boundary, + input, + ), + }), + }, + ); + } + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected ELF image could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ArtifactOperationError( + OPERATION, + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure + ? cause.reason + : "io", + undefined, + cause instanceof Error ? cause.message : "ELF unwind inspection failed.", + { cause }, + ); +}; + +const elfUnwindFailure = ( + cause: unknown, + input: InspectElfUnwindRangesInput, +) => { + const primary = elfUnwindFailurePrimary( + artifactFilePrimaryFacts(cause), + input, + ); + return projectStableArtifactFailure( + cause, + primary, + input.path, + ELF_UNWIND_RANGES_PROVIDER.id, + OPERATION, + ); +}; diff --git a/src/native/elf/ElfUnwindTables.fixture.ts b/src/native/elf/ElfUnwindTables.fixture.ts new file mode 100644 index 0000000000..e67c723940 --- /dev/null +++ b/src/native/elf/ElfUnwindTables.fixture.ts @@ -0,0 +1,1230 @@ +/** Hand-assembled ELF images with .eh_frame CIE/FDE records and .eh_frame_hdr. */ + +export const UNWIND_FN_A = 0x400100; +export const UNWIND_FN_A_SIZE = 0x20; +export const UNWIND_FN_B = 0x400180; +export const UNWIND_FN_B_SIZE = 0x30; +export const UNWIND_PERSONALITY = 0x400200; +export const UNWIND_LSDA = 0x400240; +export const UNWIND_TEXT_VA = 0x400000; +export const UNWIND_TEXT_SIZE = 0x300; +export const UNWIND_EH_VA = 0x401000; +export const UNWIND_HDR_VA = 0x402000; +export const UNWIND_MISMATCH_START = 0x400300; +export const UNWIND_DEBUG_VA = 0x403000; +export const UNWIND_GOT_VA = 0x404000; +export const UNWIND_DUPLICATE_VA = 0x405000; +/** Leading `.symtab` entries in the combined symbol-cap fixture. */ +export const UNWIND_CAP_SYMTAB_ENTRIES = 40000; +/** `.dynsym` entries in the combined symbol-cap fixture; `alpha` is last. */ +export const UNWIND_CAP_DYNSYM_ENTRIES = 30000; +/** First start of the `suffixNames` FDEs, 4 bytes apart. */ +export const UNWIND_SUFFIX_VA = 0x410000; + +export interface ElfUnwindFixtureOptions { + readonly bits?: 32 | 64; + readonly little?: boolean; + readonly machine?: number; + /** + * Default `mismatch` stores a search-table start that is not an FDE. + * `duplicate-row` repeats the second row; `shared-fde` points both rows at + * the first FDE while naming both starts. + */ + readonly header?: + | "mismatch" + | "match" + | "absent" + | "duplicate-row" + | "shared-fde"; + readonly debugFrame?: boolean; + readonly symbolSource?: "symtab" | "dynsym" | "none"; + /** First CIE uses version 3 and a ULEB return-address register. */ + readonly version3?: boolean; + /** Cut the second FDE after its length word. */ + readonly truncate?: boolean; + /** Non-zero byte after a real terminator, still inside .eh_frame. */ + readonly trailingByte?: boolean; + readonly type?: number; + /** When false, the image has no `.eh_frame` or `.eh_frame_hdr`. */ + readonly unwind?: boolean; + /** Declare one unwind section as SHT_NOBITS or extending past end of file. */ + readonly backing?: { + readonly section: ".eh_frame" | ".eh_frame_hdr" | ".debug_frame"; + readonly kind: "nobits" | "past-eof"; + }; + /** + * First CIE uses `datarel|sdata4`. `got` encodes the first FDE start + * against a `.got.plt` section; `none` has no GOT and encodes it against the + * `.eh_frame_hdr` address instead. `dynamic` declares the GOT only through + * a `.dynamic` DT_PLTGOT entry. + */ + readonly datarel?: "got" | "none" | "dynamic"; + /** + * First CIE and first FDE use the 0xffffffff extended (64-bit) length + * escape; their CIE ID / CIE pointer stays 4 bytes, as `.eh_frame` requires. + */ + readonly extendedLength?: boolean; + /** + * Declared `.eh_frame_hdr` fde_count, overriding the real row count. A + * bigint is written as `DW_EH_PE_udata8`. `"omit"` writes the + * `DW_EH_PE_omit` count and table encodings GNU ld uses for a header with + * no search table. + */ + readonly headerCount?: number | bigint | "omit"; + /** Declare a second, ignored section with this name. */ + readonly duplicate?: ".eh_frame" | ".eh_frame_hdr"; + /** `.symtab` and `.dynsym` each under 65536 entries, together over it. */ + readonly symbolCap?: boolean; + /** Second FDE stores a raw zero pcrel LSDA pointer (no LSDA). */ + readonly zeroLsda?: boolean; + /** + * Insert an FDE with a raw zero pcrel initial location after the first, + * as a linker leaves for a discarded function. It has no header row. + */ + readonly discardedFde?: boolean; + /** + * First CIE is `zXR`: an unrecognized `X` (one data byte) precedes the `R` + * pointer encoding. + */ + readonly unknownAugmentation?: boolean; + /** + * First CIE uses `pcrel|udata4` (ELF32) or `pcrel|udata8` (ELF64), so the + * first FDE start stores the unsigned two's-complement distance and only + * resolves after wrapping at the address width. `size` overrides that FDE's + * unsigned address range. + */ + readonly unsignedPcrel?: { readonly size?: number }; + /** Append this many `zR` CIEs that no FDE references, before the terminator. */ + readonly extraCies?: number; + /** + * First CIE uses `indirect|pcrel|sdata4`, so the first FDE initial location + * names a pointer slot rather than its function. + */ + readonly indirectStart?: boolean; + /** Second CIE stores a raw zero `indirect|pcrel|sdata4` personality. */ + readonly nullPersonality?: boolean; + /** + * `.symtab` holds `symbols` entries at distinct addresses, the first at + * `UNWIND_FN_A`, all naming one `bytes`-byte string. + */ + readonly sharedName?: { readonly symbols: number; readonly bytes: number }; + /** + * Append this many 4-byte FDEs from `UNWIND_SUFFIX_VA`, each named by a + * distinct suffix of one 65535-byte string, so the names are long and + * pairwise different. + */ + readonly suffixNames?: number; + /** `alpha` at `UNWIND_FN_A` is a global `STT_GNU_IFUNC` resolver symbol. */ + readonly ifunc?: boolean; + /** Append this many `S` letters to the first CIE's augmentation. */ + readonly longAugmentation?: number; +} + +class Writer { + #buf = Buffer.alloc(64); + #len = 0; + readonly #little: boolean; + + constructor(little: boolean) { + this.#little = little; + } + + get length(): number { + return this.#len; + } + + #ensure(extra: number): void { + if (this.#len + extra <= this.#buf.length) return; + const next = Buffer.alloc( + Math.max(this.#buf.length * 2, this.#len + extra), + ); + this.#buf.copy(next, 0, 0, this.#len); + this.#buf = next; + } + + u8(value: number): void { + this.#ensure(1); + this.#buf[this.#len] = value & 0xff; + this.#len += 1; + } + + u32(value: number): void { + this.#ensure(4); + if (this.#little) this.#buf.writeUInt32LE(value >>> 0, this.#len); + else this.#buf.writeUInt32BE(value >>> 0, this.#len); + this.#len += 4; + } + + i32(value: number): void { + this.#ensure(4); + if (this.#little) this.#buf.writeInt32LE(value | 0, this.#len); + else this.#buf.writeInt32BE(value | 0, this.#len); + this.#len += 4; + } + + u64(value: number): void { + this.#ensure(8); + if (this.#little) this.#buf.writeBigUInt64LE(BigInt(value), this.#len); + else this.#buf.writeBigUInt64BE(BigInt(value), this.#len); + this.#len += 8; + } + + u64Big(value: bigint): void { + this.#ensure(8); + if (this.#little) this.#buf.writeBigUInt64LE(value, this.#len); + else this.#buf.writeBigUInt64BE(value, this.#len); + this.#len += 8; + } + + patchU64(offset: number, value: number): void { + if (this.#little) this.#buf.writeBigUInt64LE(BigInt(value), offset); + else this.#buf.writeBigUInt64BE(BigInt(value), offset); + } + + patchU32(offset: number, value: number): void { + if (this.#little) this.#buf.writeUInt32LE(value >>> 0, offset); + else this.#buf.writeUInt32BE(value >>> 0, offset); + } + + bytes(value: Buffer | string): void { + const bytes = + typeof value === "string" ? Buffer.from(value, "latin1") : value; + this.#ensure(bytes.length); + bytes.copy(this.#buf, this.#len); + this.#len += bytes.length; + } + + uleb(value: number): void { + let rest = value >>> 0; + do { + let byte = rest & 0x7f; + rest >>>= 7; + if (rest !== 0) byte |= 0x80; + this.u8(byte); + } while (rest !== 0); + } + + sleb(value: number): void { + let rest = value; + let more = true; + while (more) { + let byte = rest & 0x7f; + rest >>= 7; + const sign = (byte & 0x40) !== 0; + if ((rest === 0 && !sign) || (rest === -1 && sign)) more = false; + else byte |= 0x80; + this.u8(byte); + } + } + + finish(): Buffer { + return Buffer.from(this.#buf.subarray(0, this.#len)); + } +} + +const alignTo = (value: number, alignment: number): number => + Math.ceil(value / alignment) * alignment; + +const write16 = ( + bytes: Buffer, + offset: number, + value: number, + little: boolean, +): void => { + if (little) bytes.writeUInt16LE(value, offset); + else bytes.writeUInt16BE(value, offset); +}; + +const write32 = ( + bytes: Buffer, + offset: number, + value: number, + little: boolean, +): void => { + if (little) bytes.writeUInt32LE(value >>> 0, offset); + else bytes.writeUInt32BE(value >>> 0, offset); +}; + +const writeWord = ( + bytes: Buffer, + offset: number, + value: number, + bits: 32 | 64, + little: boolean, +): void => { + if (bits === 64) { + if (little) bytes.writeBigUInt64LE(BigInt(value), offset); + else bytes.writeBigUInt64BE(BigInt(value), offset); + return; + } + write32(bytes, offset, value, little); +}; + +interface BuiltFrame { + readonly bytes: Buffer; + readonly fdeOffsets: readonly number[]; +} + +/** The first CIE's `R` encoding for the selected frame options. */ +const firstPointerEncoding = (options: { + readonly datarelBase?: number; + readonly indirectStart?: boolean; + readonly unsignedPcrel?: { readonly bits: 32 | 64 }; +}): number => { + if (options.unsignedPcrel !== undefined) + return options.unsignedPcrel.bits === 64 ? 0x14 : 0x13; + if (options.datarelBase !== undefined) return 0x3b; + return options.indirectStart === true ? 0x9b : 0x1b; +}; + +/** Two CIEs (`zRS` and `zPLRB`) and two pcrel/sdata4 FDEs, then a terminator. */ +const buildUnwindFrame = ( + ehVa: number, + little: boolean, + options: { + readonly version3: boolean; + readonly functions: 1 | 2; + readonly truncate: boolean; + readonly trailingByte: boolean; + readonly signalFrame: boolean; + readonly datarelBase?: number; + readonly extendedLength?: boolean; + readonly zeroLsda?: boolean; + readonly discardedFde?: boolean; + readonly unknownAugmentation?: boolean; + readonly extraCies?: number; + readonly unsignedPcrel?: { + readonly bits: 32 | 64; + readonly size?: number; + }; + readonly indirectStart?: boolean; + readonly nullPersonality?: boolean; + readonly suffixFdes?: number; + readonly longAugmentation?: number; + }, +): BuiltFrame => { + const w = new Writer(little); + const fdeOffsets: number[] = []; + const extended = new Set(); + const begin = (wide = false): number => { + const at = w.length; + if (wide) { + extended.add(at); + w.u32(0xffffffff); + w.u64(0); + } else w.u32(0); + return at; + }; + const end = (at: number): void => { + if (extended.has(at)) w.patchU64(at + 4, w.length - (at + 12)); + else w.patchU32(at, w.length - (at + 4)); + }; + const wide = options.extendedLength === true; + const pcrel = (target: number): void => { + w.i32(target - (ehVa + w.length)); + }; + + const unknown = options.unknownAugmentation === true; + const unsigned = options.unsignedPcrel; + const cie0 = begin(wide); + w.u32(0); + w.u8(options.version3 ? 3 : 1); + w.bytes( + `${unknown ? "zXR" : "zR"}${options.signalFrame ? "S" : ""}${"S".repeat(options.longAugmentation ?? 0)}\0`, + ); + w.uleb(1); + w.sleb(-4); + if (options.version3) w.uleb(128); + else w.u8(16); + w.uleb(unknown ? 2 : 1); + if (unknown) w.u8(0); + w.u8(firstPointerEncoding(options)); + w.u8(0); + end(cie0); + + const fde0 = begin(wide); + fdeOffsets.push(fde0); + w.u32(w.length - cie0); + if (unsigned !== undefined) { + const distance = BigInt(UNWIND_FN_A - (ehVa + w.length)); + if (unsigned.bits === 64) { + w.u64Big(BigInt.asUintN(64, distance)); + w.u64Big(BigInt(unsigned.size ?? UNWIND_FN_A_SIZE)); + } else { + w.u32(Number(BigInt.asUintN(32, distance))); + w.u32(unsigned.size ?? UNWIND_FN_A_SIZE); + } + } else { + if (options.datarelBase === undefined) pcrel(UNWIND_FN_A); + else w.i32(UNWIND_FN_A - options.datarelBase); + w.i32(UNWIND_FN_A_SIZE); + } + w.uleb(0); + w.u8(0); + end(fde0); + + if (options.discardedFde === true) { + const dropped = begin(); + w.u32(w.length - cie0); + w.i32(0); + w.i32(0x10); + w.uleb(0); + w.u8(0); + end(dropped); + } + + if (options.functions === 2) { + const cie1 = begin(); + w.u32(0); + w.u8(1); + w.bytes("zPLRB\0"); + w.uleb(1); + w.sleb(-4); + w.u8(17); + w.uleb(7); + w.u8(0x9b); + if (options.nullPersonality === true) w.i32(0); + else pcrel(UNWIND_PERSONALITY); + w.u8(0x1b); + w.u8(0x1b); + w.u8(0); + end(cie1); + + const fde1 = begin(); + fdeOffsets.push(fde1); + w.u32(w.length - cie1); + pcrel(UNWIND_FN_B); + w.i32(UNWIND_FN_B_SIZE); + w.uleb(4); + if (options.zeroLsda === true) w.i32(0); + else pcrel(UNWIND_LSDA); + w.u8(0); + end(fde1); + } + + for (let extra = 0; extra < (options.extraCies ?? 0); extra += 1) { + const cie = begin(); + w.u32(0); + w.u8(1); + w.bytes("zR\0"); + w.uleb(1); + w.sleb(-4); + w.u8(16); + w.uleb(1); + w.u8(0x1b); + end(cie); + } + + writeSuffixFdes(w, cie0, ehVa, options.suffixFdes ?? 0); + + const finished = options.truncate ? fdeOffsets[1]! + 4 : w.length; + if (!options.truncate) w.u32(0); + if (options.trailingByte && !options.truncate) w.u8(1); + const bytes = options.truncate + ? Buffer.from(w.finish().subarray(0, finished)) + : w.finish(); + return { bytes, fdeOffsets }; +}; + +/** 4-byte FDEs from `UNWIND_SUFFIX_VA` under the CIE at `cie`. */ +const writeSuffixFdes = ( + w: Writer, + cie: number, + ehVa: number, + count: number, +): void => { + for (let index = 0; index < count; index += 1) { + const at = w.length; + w.u32(0); + w.u32(w.length - cie); + w.i32(UNWIND_SUFFIX_VA + index * 4 - (ehVa + w.length)); + w.i32(4); + w.uleb(0); + w.u8(0); + w.patchU32(at, w.length - (at + 4)); + } +}; + +/** Search-table rows as `[initial location, FDE index]`. */ +type HeaderRows = ReadonlyArray; + +const buildHeader = ( + hdrVa: number, + ehVa: number, + fdeOffsets: readonly number[], + rows: HeaderRows, + little: boolean, + count: number | bigint | "omit" = rows.length, +): Buffer => { + const w = new Writer(little); + w.u8(1); + w.u8(0x1b); + if (count === "omit") { + w.u8(0xff); + w.u8(0xff); + w.i32(ehVa - (hdrVa + w.length)); + return w.finish(); + } + w.u8(typeof count === "bigint" ? 0x04 : 0x03); + w.u8(0x3b); + w.i32(ehVa - (hdrVa + w.length)); + if (typeof count === "bigint") { + const low = Number(BigInt.asUintN(32, count)); + const high = Number(count >> 32n); + w.u32(little ? low : high); + w.u32(little ? high : low); + } else w.u32(count); + for (const [start, fde] of rows) { + w.i32(start - hdrVa); + w.i32(ehVa + fdeOffsets[fde]! - hdrVa); + } + return w.finish(); +}; + +const headerRows = ( + header: NonNullable, +): HeaderRows => { + if (header === "duplicate-row") + return [ + [UNWIND_FN_A, 0], + [UNWIND_FN_B, 1], + [UNWIND_FN_B, 1], + ]; + if (header === "shared-fde") + return [ + [UNWIND_FN_A, 0], + [UNWIND_FN_B, 0], + ]; + return [ + [UNWIND_FN_A, 0], + [header === "match" ? UNWIND_FN_B : UNWIND_MISMATCH_START, 1], + ]; +}; + +interface SectionSpec { + readonly name: string; + readonly type: number; + readonly flags: number; + readonly address: number; + readonly bytes: Buffer; + readonly link: number; + readonly entsize: number; + readonly align: number; + /** Extra declared `sh_size` beyond the written bytes. */ + readonly overhang?: number; +} + +const buildSymbolTable = ( + bits: 32 | 64, + little: boolean, + ifunc = false, +): { readonly symbols: Buffer; readonly strings: Buffer } => { + const strings = Buffer.from("\0alpha\0mid\0obj\0", "latin1"); + const width = bits === 64 ? 24 : 16; + const symbols = Buffer.alloc(width * 4); + const writeSymbol = ( + index: number, + name: number, + info: number, + section: number, + value: number, + size: number, + ): void => { + const at = index * width; + write32(symbols, at, name, little); + if (bits === 64) { + symbols[at + 4] = info; + write16(symbols, at + 6, section, little); + writeWord(symbols, at + 8, value, bits, little); + writeWord(symbols, at + 16, size, bits, little); + return; + } + writeWord(symbols, at + 4, value, bits, little); + writeWord(symbols, at + 8, size, bits, little); + symbols[at + 12] = info; + write16(symbols, at + 14, section, little); + }; + writeSymbol(1, 1, ifunc ? 0x1a : 0x12, 1, UNWIND_FN_A, UNWIND_FN_A_SIZE); + writeSymbol(2, 7, 0x12, 1, UNWIND_FN_A + 4, 1); + writeSymbol(3, 11, 0x11, 1, UNWIND_FN_A, UNWIND_FN_A_SIZE); + return { symbols, strings }; +}; + +/** Function symbols at distinct addresses from `UNWIND_FN_A`, all one name. */ +const buildSharedNameSymbolTable = ( + bits: 32 | 64, + little: boolean, + shared: { readonly symbols: number; readonly bytes: number }, +): { readonly symbols: Buffer; readonly strings: Buffer } => { + const strings = Buffer.concat([ + Buffer.from([0]), + Buffer.alloc(shared.bytes, "n"), + Buffer.from([0]), + ]); + const width = bits === 64 ? 24 : 16; + const symbols = Buffer.alloc(width * (shared.symbols + 1)); + for (let index = 1; index <= shared.symbols; index += 1) { + const at = index * width; + const value = UNWIND_FN_A + (index - 1) * 2; + write32(symbols, at, 1, little); + if (bits === 64) { + symbols[at + 4] = 0x12; + write16(symbols, at + 6, 1, little); + writeWord(symbols, at + 8, value, bits, little); + } else { + writeWord(symbols, at + 4, value, bits, little); + symbols[at + 12] = 0x12; + write16(symbols, at + 14, 1, little); + } + } + return { symbols, strings }; +}; + +/** One function symbol per `suffixNames` FDE, each at its own name suffix. */ +const buildSuffixNameSymbolTable = ( + bits: 32 | 64, + little: boolean, + count: number, +): { readonly symbols: Buffer; readonly strings: Buffer } => { + const strings = Buffer.concat([ + Buffer.from([0]), + Buffer.alloc(65535, "s"), + Buffer.from([0]), + ]); + const width = bits === 64 ? 24 : 16; + const symbols = Buffer.alloc(width * (count + 1)); + for (let index = 0; index < count; index += 1) { + const at = (index + 1) * width; + const value = UNWIND_SUFFIX_VA + index * 4; + write32(symbols, at, 1 + index, little); + if (bits === 64) { + symbols[at + 4] = 0x12; + write16(symbols, at + 6, 1, little); + writeWord(symbols, at + 8, value, bits, little); + } else { + writeWord(symbols, at + 4, value, bits, little); + symbols[at + 12] = 0x12; + write16(symbols, at + 14, 1, little); + } + } + return { symbols, strings }; +}; + +/** Zeroed (skipped) entries, then `alpha` at the last `.dynsym` slot. */ +const buildCapSymbolTables = ( + bits: 32 | 64, + little: boolean, +): { + readonly symtab: Buffer; + readonly dynsym: Buffer; + readonly strings: Buffer; +} => { + const width = bits === 64 ? 24 : 16; + const symtab = Buffer.alloc(width * UNWIND_CAP_SYMTAB_ENTRIES); + const dynsym = Buffer.alloc(width * UNWIND_CAP_DYNSYM_ENTRIES); + const at = (UNWIND_CAP_DYNSYM_ENTRIES - 1) * width; + write32(dynsym, at, 1, little); + if (bits === 64) { + dynsym[at + 4] = 0x12; + write16(dynsym, at + 6, 1, little); + writeWord(dynsym, at + 8, UNWIND_FN_A, bits, little); + } else { + writeWord(dynsym, at + 4, UNWIND_FN_A, bits, little); + dynsym[at + 12] = 0x12; + write16(dynsym, at + 14, 1, little); + } + return { symtab, dynsym, strings: Buffer.from("\0alpha\0", "latin1") }; +}; + +const writeSectionHeader = ( + bytes: Buffer, + offset: number, + section: SectionSpec, + nameOffset: number, + fileOffset: number, + bits: 32 | 64, + little: boolean, +): void => { + const declaredSize = section.bytes.length + (section.overhang ?? 0); + write32(bytes, offset, nameOffset, little); + write32(bytes, offset + 4, section.type, little); + writeWord(bytes, offset + 8, section.flags, bits, little); + if (bits === 64) { + writeWord(bytes, offset + 16, section.address, bits, little); + writeWord(bytes, offset + 24, fileOffset, bits, little); + writeWord(bytes, offset + 32, declaredSize, bits, little); + write32(bytes, offset + 40, section.link, little); + writeWord(bytes, offset + 48, section.align, bits, little); + writeWord(bytes, offset + 56, section.entsize, bits, little); + return; + } + writeWord(bytes, offset + 12, section.address, bits, little); + writeWord(bytes, offset + 16, fileOffset, bits, little); + writeWord(bytes, offset + 20, declaredSize, bits, little); + write32(bytes, offset + 24, section.link, little); + writeWord(bytes, offset + 32, section.align, bits, little); + writeWord(bytes, offset + 36, section.entsize, bits, little); +}; + +/** Option-driven GOT, duplicate, and combined-cap symbol sections. */ +const addOptionalSections = ( + sections: SectionSpec[], + options: ElfUnwindFixtureOptions, + bits: 32 | 64, + little: boolean, +): void => { + if (options.datarel === "got") + sections.push({ + name: ".got.plt", + type: 1, + flags: 3, + address: UNWIND_GOT_VA, + bytes: Buffer.alloc(24), + link: 0, + entsize: 8, + align: 8, + }); + if (options.datarel === "dynamic") { + const width = bits === 64 ? 16 : 8; + const dynamic = Buffer.alloc(width * 2); + // DT_PLTGOT, then DT_NULL. + writeWord(dynamic, 0, 3, bits, little); + writeWord(dynamic, width / 2, UNWIND_GOT_VA, bits, little); + sections.push({ + name: ".dynamic", + type: 6, + flags: 3, + address: UNWIND_GOT_VA - 0x100, + bytes: dynamic, + link: 0, + entsize: width, + align: 8, + }); + } + if (options.duplicate !== undefined) { + const original = sections.find( + (section) => section.name === options.duplicate, + ); + if (original !== undefined) + sections.push({ ...original, address: UNWIND_DUPLICATE_VA }); + } + if (options.symbolCap === true) { + const cap = buildCapSymbolTables(bits, little); + const strings = sections.length + 3; + const table = (name: string, type: number, bytes: Buffer): SectionSpec => ({ + name, + type, + flags: 0, + address: 0, + bytes, + link: strings, + entsize: bits === 64 ? 24 : 16, + align: 8, + }); + sections.push( + table(".symtab", 2, cap.symtab), + table(".dynsym", 11, cap.dynsym), + { + name: ".strtab", + type: 3, + flags: 0, + address: 0, + bytes: cap.strings, + link: 0, + entsize: 0, + align: 1, + }, + ); + } +}; + +/** Redeclare one unwind section as SHT_NOBITS or running past end of file. */ +const applyBacking = ( + sections: SectionSpec[], + backing: ElfUnwindFixtureOptions["backing"], +): void => { + if (backing !== undefined) { + const index = sections.findIndex( + (section) => section.name === backing.section, + ); + const target = sections[index]; + if (target !== undefined) + sections[index] = + backing.kind === "nobits" + ? { ...target, type: 8 } + : { ...target, overhang: 0x100000 }; + } +}; + +/** ELF32/ELF64 image whose unwind tables are assembled in this file. */ +export const elfUnwindFixture = ( + options: ElfUnwindFixtureOptions = {}, +): Buffer => { + const bits = options.bits ?? 64; + const little = options.little ?? true; + const header = options.header ?? "mismatch"; + const unwind = options.unwind !== false; + const frame = unwind + ? buildUnwindFrame(UNWIND_EH_VA, little, { + version3: options.version3 ?? false, + functions: 2, + truncate: options.truncate ?? false, + trailingByte: options.trailingByte ?? false, + signalFrame: true, + ...(options.datarel === undefined + ? {} + : { + datarelBase: + options.datarel === "none" ? UNWIND_HDR_VA : UNWIND_GOT_VA, + }), + extendedLength: options.extendedLength ?? false, + zeroLsda: options.zeroLsda ?? false, + discardedFde: options.discardedFde ?? false, + unknownAugmentation: options.unknownAugmentation ?? false, + extraCies: options.extraCies ?? 0, + indirectStart: options.indirectStart ?? false, + nullPersonality: options.nullPersonality ?? false, + suffixFdes: options.suffixNames ?? 0, + longAugmentation: options.longAugmentation ?? 0, + ...(options.unsignedPcrel === undefined + ? {} + : { unsignedPcrel: { ...options.unsignedPcrel, bits } }), + }) + : null; + const hdr = + !unwind || header === "absent" || frame === null + ? null + : buildHeader( + UNWIND_HDR_VA, + UNWIND_EH_VA, + frame.fdeOffsets, + headerRows(header), + little, + options.headerCount, + ); + const symbols = + (options.symbolSource ?? "symtab") === "none" || options.symbolCap === true + ? null + : options.sharedName !== undefined + ? buildSharedNameSymbolTable(bits, little, options.sharedName) + : options.suffixNames !== undefined + ? buildSuffixNameSymbolTable(bits, little, options.suffixNames) + : buildSymbolTable(bits, little, options.ifunc === true); + const debug = + options.debugFrame === false || !unwind ? null : Buffer.alloc(4); + const sections: SectionSpec[] = [ + { + name: ".text", + type: 1, + flags: 6, + address: UNWIND_TEXT_VA, + bytes: Buffer.alloc(UNWIND_TEXT_SIZE), + link: 0, + entsize: 0, + align: 16, + }, + ]; + if (frame !== null) + sections.push({ + name: ".eh_frame", + type: 1, + flags: 2, + address: UNWIND_EH_VA, + bytes: frame.bytes, + link: 0, + entsize: 0, + align: 8, + }); + if (hdr !== null) + sections.push({ + name: ".eh_frame_hdr", + type: 1, + flags: 2, + address: UNWIND_HDR_VA, + bytes: hdr, + link: 0, + entsize: 0, + align: 4, + }); + if (debug !== null) + sections.push({ + name: ".debug_frame", + type: 1, + flags: 0, + address: UNWIND_DEBUG_VA, + bytes: debug, + link: 0, + entsize: 0, + align: 4, + }); + addOptionalSections(sections, options, bits, little); + const stringIndex = symbols === null ? 0 : sections.length + 2; + if (symbols !== null) { + sections.push({ + name: options.symbolSource === "dynsym" ? ".dynsym" : ".symtab", + type: options.symbolSource === "dynsym" ? 11 : 2, + flags: 0, + address: 0, + bytes: symbols.symbols, + link: stringIndex, + entsize: bits === 64 ? 24 : 16, + align: 8, + }); + sections.push({ + name: ".strtab", + type: 3, + flags: 0, + address: 0, + bytes: symbols.strings, + link: 0, + entsize: 0, + align: 1, + }); + } + applyBacking(sections, options.backing); + sections.push({ + name: ".shstrtab", + type: 3, + flags: 0, + address: 0, + bytes: Buffer.alloc(0), + link: 0, + entsize: 0, + align: 1, + }); + return writeSectionedElf(sections, { + bits, + little, + type: options.type ?? 3, + machine: options.machine ?? (bits === 64 ? 62 : 20), + }); +}; + +/** Lay out sections after the ELF header, then append the section header table. */ +const writeSectionedElf = ( + sections: SectionSpec[], + { + bits, + little, + type, + machine, + }: { + readonly bits: 32 | 64; + readonly little: boolean; + readonly type: number; + readonly machine: number; + }, +): Buffer => { + let shstr = "\0"; + const nameOffsets = [0]; + for (const section of sections) { + nameOffsets.push(shstr.length); + shstr += `${section.name}\0`; + } + const shstrtab = Buffer.from(shstr, "latin1"); + sections[sections.length - 1] = { + ...sections[sections.length - 1]!, + bytes: shstrtab, + }; + const headerSize = bits === 64 ? 64 : 52; + const shentsize = bits === 64 ? 64 : 40; + const shnum = sections.length + 1; + let cursor = alignTo(headerSize, 8); + const fileOffsets: number[] = []; + for (const section of sections) { + cursor = alignTo(cursor, section.align); + fileOffsets.push(cursor); + cursor += section.bytes.length; + } + const shoff = alignTo(cursor, 8); + const bytes = Buffer.alloc(shoff + shnum * shentsize); + bytes.writeUInt32LE(0x464c457f, 0); + bytes[4] = bits === 64 ? 2 : 1; + bytes[5] = little ? 1 : 2; + bytes[6] = 1; + write16(bytes, 16, type, little); + write16(bytes, 18, machine, little); + write32(bytes, 20, 1, little); + write16(bytes, bits === 64 ? 52 : 40, headerSize, little); + write16(bytes, bits === 64 ? 54 : 42, bits === 64 ? 56 : 32, little); + writeWord(bytes, bits === 64 ? 40 : 32, shoff, bits, little); + write16(bytes, bits === 64 ? 58 : 46, shentsize, little); + write16(bytes, bits === 64 ? 60 : 48, shnum, little); + write16(bytes, bits === 64 ? 62 : 50, shnum - 1, little); + sections.forEach((section, index) => { + const fileOffset = fileOffsets[index]!; + section.bytes.copy(bytes, fileOffset); + writeSectionHeader( + bytes, + shoff + (index + 1) * shentsize, + section, + nameOffsets[index + 1]!, + fileOffset, + bits, + little, + ); + }); + return bytes; +}; + +interface SectionlessNamedSection { + readonly name: string; + readonly address: number; + readonly offset: number; + readonly size: number; + readonly flags?: number; +} + +/** Section headers the sectionless fixture declares for its `sections` option. */ +const sectionlessNamedSections = ( + options: { + readonly sections?: ".eh_frame" | ".eh_frame_hdr"; + readonly nonAllocOverlay?: boolean; + }, + layout: { + readonly ehVa: number; + readonly ehOff: number; + readonly ehSize: number; + readonly hdrVa: number; + readonly hdrOff: number; + readonly hdrSize: number; + }, +): SectionlessNamedSection[] => { + if (options.sections === undefined) return []; + const named: SectionlessNamedSection[] = []; + if (options.nonAllocOverlay === true) + named.push({ + name: ".comment", + address: 0x400000, + offset: 8, + size: UNWIND_FN_A - 0x400000 + 0x10, + flags: 0, + }); + named.push({ + name: ".eh_frame", + address: layout.ehVa, + offset: layout.ehOff, + size: layout.ehSize, + }); + if (options.sections === ".eh_frame_hdr") + named.push({ + name: ".eh_frame_hdr", + address: layout.hdrVa, + offset: layout.hdrOff, + size: layout.hdrSize, + }); + return named; +}; + +/** + * ELF64 image with PT_LOAD plus PT_GNU_EH_FRAME and one agreeing FDE. By + * default it has no section headers. `sections: ".eh_frame"` adds section + * headers naming only `.eh_frame`, so the header comes from PT_GNU_EH_FRAME. + * `".eh_frame_hdr"` also names `.eh_frame_hdr` while PT_GNU_EH_FRAME declares + * four fewer bytes than that section. `nonAllocOverlay` puts a non-ALLOC + * `.comment` section first, whose sh_addr covers `.eh_frame` and the function + * start but whose sh_offset is 8 bytes off the PT_LOAD mapping. + * `unterminated` drops the `.eh_frame` terminator and appends bytes that are + * not in the search table: a well-formed FDE for `UNWIND_FN_B` + * (`"decodable"`), or an FDE whose CIE pointer names the first FDE followed + * by that well-formed FDE (`"undecodable"`), or a CIE with an unrecognized + * augmentation letter followed by that well-formed FDE (`"rejected-cie"`). + * `emptyTable` declares no search-table rows. `duplicateProgramHeader` + * declares a second PT_GNU_EH_FRAME naming the same bytes. + */ +export const elfUnwindSectionlessFixture = ( + options: { + readonly sections?: ".eh_frame" | ".eh_frame_hdr"; + readonly nonAllocOverlay?: boolean; + readonly unterminated?: "decodable" | "undecodable" | "rejected-cie"; + readonly emptyTable?: boolean; + readonly duplicateProgramHeader?: boolean; + } = {}, +): Buffer => { + const little = true; + const bits = 64 as const; + const phnum = options.duplicateProgramHeader === true ? 3 : 2; + const ehOff = 64 + phnum * 56; + const ehVa = 0x400000 + ehOff; + const built = buildUnwindFrame(ehVa, little, { + version3: false, + functions: 1, + truncate: false, + trailingByte: false, + signalFrame: false, + }); + const frame = + options.unterminated === undefined + ? built + : { + fdeOffsets: built.fdeOffsets, + bytes: unterminatedFrame( + built.bytes, + built.fdeOffsets[0]!, + ehVa, + options.unterminated, + ), + }; + const hdrOff = alignTo(ehOff + frame.bytes.length, 4); + const hdrVa = 0x400000 + hdrOff; + const hdr = buildHeader( + hdrVa, + ehVa, + frame.fdeOffsets, + options.emptyTable === true ? [] : [[UNWIND_FN_A, 0]], + little, + ); + const fileSize = hdrOff + hdr.length; + const named = sectionlessNamedSections(options, { + ehVa, + ehOff, + ehSize: frame.bytes.length, + hdrVa, + hdrOff, + hdrSize: hdr.length, + }); + let shstr = "\0"; + const nameOffsets = named.map((section) => { + const at = shstr.length; + shstr += `${section.name}\0`; + return at; + }); + const shstrName = shstr.length; + shstr += ".shstrtab\0"; + const shstrOff = fileSize; + const shoff = alignTo(shstrOff + shstr.length, 8); + const shnum = named.length + 2; + const bytes = Buffer.alloc( + options.sections === undefined ? fileSize : shoff + shnum * 64, + ); + bytes.writeUInt32LE(0x464c457f, 0); + bytes[4] = 2; + bytes[5] = 1; + bytes[6] = 1; + write16(bytes, 16, 3, little); + write16(bytes, 18, 62, little); + write32(bytes, 20, 1, little); + writeWord(bytes, 32, 64, bits, little); + write16(bytes, 52, 64, little); + write16(bytes, 54, 56, little); + write16(bytes, 56, phnum, little); + write16(bytes, 58, 64, little); + const load = 64; + write32(bytes, load, 1, little); + write32(bytes, load + 4, 5, little); + writeWord(bytes, load + 16, 0x400000, bits, little); + writeWord(bytes, load + 32, fileSize, bits, little); + writeWord(bytes, load + 40, fileSize, bits, little); + writeWord(bytes, load + 48, 0x1000, bits, little); + const gnu = 64 + 56; + write32(bytes, gnu, 0x6474e550, little); + write32(bytes, gnu + 4, 4, little); + writeWord(bytes, gnu + 8, hdrOff, bits, little); + writeWord(bytes, gnu + 16, hdrVa, bits, little); + writeWord(bytes, gnu + 32, hdr.length, bits, little); + writeWord(bytes, gnu + 40, hdr.length, bits, little); + writeWord(bytes, gnu + 48, 4, bits, little); + if (phnum === 3) bytes.copy(bytes, gnu + 56, gnu, gnu + 56); + frame.bytes.copy(bytes, ehOff); + hdr.copy(bytes, hdrOff); + if (options.sections === ".eh_frame_hdr") + writeWord(bytes, gnu + 32, hdr.length - 4, bits, little); + if (options.sections !== undefined) { + Buffer.from(shstr, "latin1").copy(bytes, shstrOff); + writeWord(bytes, 40, shoff, bits, little); + write16(bytes, 60, shnum, little); + write16(bytes, 62, shnum - 1, little); + const specs = [ + ...named.map((section, index) => ({ + spec: { + name: section.name, + type: 1, + flags: section.flags ?? 2, + address: section.address, + bytes: Buffer.alloc(section.size), + link: 0, + entsize: 0, + align: 4, + }, + nameOffset: nameOffsets[index]!, + fileOffset: section.offset, + })), + { + spec: { + name: ".shstrtab", + type: 3, + flags: 0, + address: 0, + bytes: Buffer.alloc(shstr.length), + link: 0, + entsize: 0, + align: 1, + }, + nameOffset: shstrName, + fileOffset: shstrOff, + }, + ]; + specs.forEach(({ spec, nameOffset, fileOffset }, index) => + writeSectionHeader( + bytes, + shoff + (index + 1) * 64, + spec, + nameOffset, + fileOffset, + bits, + little, + ), + ); + } + return bytes; +}; + +/** + * Replace a frame's zero terminator with FDE-shaped bytes the search table + * does not name, as other data following an unterminated table would look. + * Every appended FDE uses the frame's first CIE, at offset 0. + */ +const unterminatedFrame = ( + terminated: Buffer, + firstFde: number, + ehVa: number, + kind: "decodable" | "undecodable" | "rejected-cie", +): Buffer => { + const w = new Writer(true); + w.bytes(terminated.subarray(0, terminated.length - 4)); + const fde = (ciePointerTarget: number): void => { + const at = w.length; + w.u32(14); + w.u32(w.length - ciePointerTarget); + w.i32(UNWIND_FN_B - (ehVa + w.length)); + w.i32(UNWIND_FN_B_SIZE); + w.uleb(0); + w.u8(0); + if (w.length !== at + 18) throw new Error("FDE size mismatch"); + }; + if (kind === "undecodable") fde(firstFde); + if (kind === "rejected-cie") { + const at = w.length; + w.u32(0); + w.u32(0); + w.u8(1); + w.bytes("zXR\0"); + w.uleb(1); + w.sleb(-4); + w.u8(16); + w.uleb(2); + w.u8(0); + w.u8(0x1b); + w.patchU32(at, w.length - (at + 4)); + } + fde(0); + return w.finish(); +}; + +/** Named sections and no unwind tables. Absence is observable. */ +export const elfUnwindAbsentFixture = (): Buffer => + elfUnwindFixture({ + unwind: false, + debugFrame: false, + symbolSource: "none", + }); diff --git a/src/native/elf/ElfUnwindTables.test.ts b/src/native/elf/ElfUnwindTables.test.ts new file mode 100644 index 0000000000..4da08d1fb0 --- /dev/null +++ b/src/native/elf/ElfUnwindTables.test.ts @@ -0,0 +1,1097 @@ +import { createHash } from "node:crypto"; +import { expect, it, vi } from "vitest"; + +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { + elfUnwindRangesSchema, + inspectElfUnwindRangesInputSchema, +} from "../../domain/native/elfUnwindRanges.js"; +import { + ElfUnwindSymbolTextFailure, + parseElfUnwindRanges, +} from "./ElfUnwindTables.js"; +import { + UNWIND_FN_A, + UNWIND_FN_A_SIZE, + UNWIND_FN_B, + UNWIND_GOT_VA, + UNWIND_HDR_VA, + UNWIND_LSDA, + UNWIND_PERSONALITY, + UNWIND_SUFFIX_VA, + elfUnwindAbsentFixture, + elfUnwindFixture, + elfUnwindSectionlessFixture, +} from "./ElfUnwindTables.fixture.js"; + +// Pass-through spies that count how often symbol names are scanned and decoded. +const stringTable = vi.hoisted(() => ({ + observed: [] as number[], + decoded: [] as number[], +})); +vi.mock("../../domain/elfStringTable.js", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + observeElfStringTableEntry: ( + ...args: Parameters + ) => { + const observed = original.observeElfStringTableEntry(...args); + stringTable.observed.push(observed.contentBytes); + return observed; + }, + elfStringTableText: ( + ...args: Parameters + ) => { + const text = original.elfStringTableText(...args); + stringTable.decoded.push(text?.length ?? 0); + return text; + }, + }; +}); + +const address = (value: number): string => `0x${value.toString(16)}`; + +const parse = (bytes: Buffer, raw: Readonly> = {}) => { + const input = inspectElfUnwindRangesInputSchema.parse({ + path: "/artifacts/unwind.elf", + ...raw, + }); + return elfUnwindRangesSchema.parse( + parseElfUnwindRanges( + bytes, + { + path: input.path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }, + input, + ), + ); +}; + +it("recovers two ELF64 ranges, an indirect personality, and a header mismatch", () => { + const report = parse(elfUnwindFixture()); + expect(report.format).toBe("elf64"); + expect(report.byte_order).toBe("little"); + expect(report.machine).toBe(62); + expect(report.type_name).toBe("shared"); + expect(report.eh_frame).toMatchObject({ + status: "present", + source: "section", + }); + expect(report.debug_frame.status).toBe("present"); + expect(report.cies.map((cie) => cie.augmentation.text)).toEqual([ + "zRS", + "zPLRB", + ]); + expect(report.cies[0]).toMatchObject({ + version: 1, + code_alignment: 1, + data_alignment: -4, + return_address_register: 16, + pointer_encoding: { raw: 0x1b, name: "pcrel|sdata4" }, + signal_frame: true, + b_key: false, + instruction_bytes: 1, + personality: null, + }); + expect(report.cies[1]).toMatchObject({ + version: 1, + return_address_register: 17, + signal_frame: false, + b_key: true, + lsda_encoding: { raw: 0x1b, name: "pcrel|sdata4" }, + personality: { + address: address(UNWIND_PERSONALITY), + indirect: true, + encoding: { raw: 0x9b, name: "pcrel|sdata4|indirect" }, + }, + }); + expect(report.ranges).toHaveLength(2); + expect(report.ranges[0]).toMatchObject({ + index: 0, + start: address(UNWIND_FN_A), + end: address(UNWIND_FN_A + 0x20), + size: "32", + section: { text: ".text", trust: "untrusted" }, + symbol: { text: "alpha", trust: "untrusted" }, + cie_index: 0, + lsda: null, + personality: null, + }); + expect(report.ranges[1]).toMatchObject({ + index: 1, + start: address(UNWIND_FN_B), + end: address(UNWIND_FN_B + 0x30), + size: "48", + section: { text: ".text", trust: "untrusted" }, + symbol: null, + cie_index: 1, + lsda: { address: address(UNWIND_LSDA), indirect: false }, + personality: { address: address(UNWIND_PERSONALITY), indirect: true }, + }); + expect(report.ranges.some((range) => range.symbol?.text === "mid")).toBe( + false, + ); + expect(report.ranges.some((range) => range.symbol?.text === "obj")).toBe( + false, + ); + expect(report.coverage).toMatchObject({ + status: "complete", + total_ranges: 2, + ranges_with_symbols: 1, + ranges_without_symbols: 1, + eh_frame_hdr_agreement: "disagree", + examined_records: 4, + }); + expect(report.limitations).toContain( + "The .eh_frame_hdr binary search table does not match the decoded FDEs.", + ); + expect(report.limitations).toContain( + "CFA instructions are counted but not evaluated.", + ); + expect(report.limitations).toContain( + "Indirect EH pointers were not dereferenced.", + ); + expect(report.limitations).toContain( + ".debug_frame is present and was not decoded.", + ); +}); + +it("agrees with a header whose search table names the same FDE starts", () => { + const report = parse(elfUnwindFixture({ header: "match" })); + expect(report.coverage.eh_frame_hdr_agreement).toBe("agree"); + expect(report.coverage.status).toBe("complete"); + expect(report.eh_frame_hdr).toMatchObject({ + status: "present", + version: 1, + fde_count: 2, + table_entries: 2, + eh_frame_pointer: "0x401000", + }); + expect(report.limitations).not.toContain( + "The .eh_frame_hdr binary search table does not match the decoded FDEs.", + ); +}); + +it.each([ + ["elf32", 32, false, 20], + ["elf64", 64, true, 62], +] as const)( + "decodes %s %s-endian unwind tables", + (_label, bits, little, machine) => { + const report = parse( + elfUnwindFixture({ bits, little, machine, header: "match" }), + ); + expect(report.format).toBe(bits === 64 ? "elf64" : "elf32"); + expect(report.byte_order).toBe(little ? "little" : "big"); + expect(report.machine).toBe(machine); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + address(UNWIND_FN_B), + ]); + expect(report.cies[1]?.personality?.address).toBe( + address(UNWIND_PERSONALITY), + ); + expect(report.coverage.eh_frame_hdr_agreement).toBe("agree"); + }, +); + +it("reads a version 3 return-address register and a dynsym name", () => { + const version = parse(elfUnwindFixture({ version3: true, header: "match" })); + expect(version.cies[0]).toMatchObject({ + version: 3, + return_address_register: 128, + }); + const dynsym = parse( + elfUnwindFixture({ symbolSource: "dynsym", header: "match" }), + ); + expect(dynsym.ranges[0]?.symbol?.text).toBe("alpha"); + const unnamed = parse( + elfUnwindFixture({ symbolSource: "none", header: "match" }), + ); + expect(unnamed.coverage).toMatchObject({ + ranges_with_symbols: 0, + ranges_without_symbols: 2, + status: "complete", + }); +}); + +it("keeps earlier FDEs when a later record is truncated", () => { + const report = parse(elfUnwindFixture({ truncate: true })); + expect(report.eh_frame.status).toBe("incomplete"); + expect(report.coverage.status).toBe("incomplete"); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + ]); + // The second CIE is decoded, but no returned range names it. + expect(report.cies.map((cie) => cie.index)).toEqual([0]); + expect(report.coverage.total_cies).toBe(2); + expect(report.limitations).toContain(".eh_frame ended inside a record."); + expect(report.eh_frame.status).not.toBe("absent"); +}); + +it("reports trailing bytes after the terminator as incomplete coverage", () => { + const report = parse( + elfUnwindFixture({ trailingByte: true, header: "match" }), + ); + expect(report.ranges).toHaveLength(2); + expect(report.coverage.status).toBe("incomplete"); + expect(report.limitations).toContain( + "Non-zero bytes follow the .eh_frame terminator.", + ); +}); + +it("reports a missing header without dropping decoded ranges", () => { + const report = parse(elfUnwindFixture({ header: "absent" })); + expect(report.ranges).toHaveLength(2); + expect(report.eh_frame_hdr.status).toBe("absent"); + expect(report.coverage.eh_frame_hdr_agreement).toBe("unavailable"); + expect(report.coverage.status).toBe("complete"); +}); + +it("reads one agreeing range from PT_GNU_EH_FRAME when section headers are absent", () => { + const report = parse(elfUnwindSectionlessFixture()); + expect(report.eh_frame).toMatchObject({ + status: "present", + source: "program", + }); + expect(report.eh_frame_hdr.status).toBe("present"); + expect(report.debug_frame.status).toBe("unknown"); + expect(report.ranges).toHaveLength(1); + expect(report.ranges[0]).toMatchObject({ + start: address(UNWIND_FN_A), + section: null, + symbol: null, + cie_index: 0, + }); + expect(report.cies[0]?.augmentation.text).toBe("zR"); + expect(report.coverage).toMatchObject({ + status: "complete", + total_ranges: 1, + eh_frame_hdr_agreement: "agree", + }); + expect(report.limitations).toContain(".debug_frame presence is unknown."); +}); + +it("bounds a header-located .eh_frame without a terminator by its search table", () => { + const bytes = elfUnwindSectionlessFixture({ unterminated: "decodable" }); + const report = parse(bytes); + expect(report.eh_frame).toMatchObject({ + status: "present", + source: "program", + }); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + ]); + expect(report.ranges).not.toContainEqual( + expect.objectContaining({ start: address(UNWIND_FN_B) }), + ); + expect(report.coverage).toMatchObject({ + status: "complete", + total_ranges: 1, + eh_frame_hdr_agreement: "agree", + examined_records: 2, + }); + expect(report.limitations).toContain( + "The .eh_frame located only through the .eh_frame_hdr eh_frame_ptr has no section bounds and no terminator after the last FDE its search table names; bytes after that FDE were not decoded.", + ); +}); + +it("stops a header-located .eh_frame without a search table at its first undecodable record", () => { + const report = parse( + elfUnwindSectionlessFixture({ + unterminated: "undecodable", + emptyTable: true, + }), + ); + expect(report.eh_frame).toMatchObject({ + status: "present", + source: "program", + }); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + ]); + expect(report.coverage).toMatchObject({ + status: "incomplete", + total_ranges: 1, + examined_records: 3, + }); + expect(report.limitations).toContain( + "The .eh_frame located only through the .eh_frame_hdr eh_frame_ptr has no section bounds or usable search table, so decoding stopped at the first record that failed to decode.", + ); +}); + +it("rejects a program-header table outside the file before decoding unwind tables", () => { + const bytes = elfUnwindSectionlessFixture(); + bytes.writeBigUInt64LE(BigInt(bytes.length), 32); + expect(() => parse(bytes)).toThrow( + expect.objectContaining({ kind: "malformed" }), + ); +}); + +it("reports .eh_frame as unknown when the header cannot locate it", () => { + const bytes = elfUnwindSectionlessFixture(); + const headerOffset = Number(bytes.readBigUInt64LE(64 + 56 + 8)); + bytes[headerOffset + 1] = 0x0f; + const report = parse(bytes); + expect(report.eh_frame).toEqual({ + status: "unknown", + source: null, + location: null, + }); + expect(report.eh_frame_hdr.status).toBe("incomplete"); + expect(report.ranges).toEqual([]); + expect(report.coverage).toMatchObject({ + status: "incomplete", + total_ranges: 0, + total_cies: 0, + eh_frame_hdr_agreement: "unavailable", + }); + expect(report.limitations).toContain( + ".eh_frame_hdr eh_frame_ptr could not be decoded.", + ); +}); + +it("reports known absence when no unwind section or segment is declared", () => { + const report = parse(elfUnwindAbsentFixture()); + expect(report.eh_frame.status).toBe("absent"); + expect(report.debug_frame.status).toBe("absent"); + expect(report.coverage).toMatchObject({ + status: "complete", + total_ranges: 0, + total_cies: 0, + eh_frame_hdr_agreement: "unavailable", + }); + expect(report.ranges).toEqual([]); +}); + +it("pages ranges without changing coverage totals", () => { + const report = parse(elfUnwindFixture(), { offset: 1, limit: 1 }); + expect(report.ranges.map((range) => range.index)).toEqual([1]); + expect(report.page).toEqual({ + offset: 1, + limit: 1, + returned: 1, + total: 2, + next_offset: null, + }); + const past = parse(elfUnwindFixture(), { offset: 9, limit: 1 }); + expect(past.ranges).toEqual([]); + expect(past.page).toMatchObject({ + offset: 9, + returned: 0, + total: 2, + next_offset: null, + }); +}); + +it("returns only the CIEs a page's ranges name, however many the image has", () => { + const report = parse(elfUnwindFixture({ extraCies: 500 }), { + offset: 1, + limit: 1, + }); + expect(report.ranges.map((range) => range.cie_index)).toEqual([1]); + expect(report.cies.map((cie) => cie.index)).toEqual([1]); + expect(report.cies[0]?.augmentation.text).toBe("zPLRB"); + expect(report.coverage).toMatchObject({ + total_ranges: 2, + total_cies: 502, + examined_records: 504, + }); + const first = parse(elfUnwindFixture({ extraCies: 500 }), { limit: 1 }); + expect(first.cies.map((cie) => cie.index)).toEqual([0]); + expect(first.coverage.total_cies).toBe(502); +}); + +it("fails closed when the CIE or FDE budget is exhausted", () => { + const bytes = elfUnwindFixture(); + const input = inspectElfUnwindRangesInputSchema.parse({ + path: "/artifacts/unwind.elf", + max_ranges: 1, + }); + expect(() => + parseElfUnwindRanges( + bytes, + { + path: input.path, + sha256: "a".repeat(64), + bytes: bytes.length, + }, + input, + ), + ).toThrow(ArtifactReaderFailure); +}); + +it("stops when the caller has already cancelled", () => { + const bytes = elfUnwindFixture(); + const input = inspectElfUnwindRangesInputSchema.parse({ + path: "/artifacts/unwind.elf", + }); + expect(() => + parseElfUnwindRanges( + bytes, + { + path: input.path, + sha256: "a".repeat(64), + bytes: bytes.length, + }, + input, + AbortSignal.abort(), + ), + ).toThrow(ArtifactReaderFailure); +}); + +const findSection = (bytes: Buffer, name: string) => { + const shoff = Number(bytes.readBigUInt64LE(40)); + const count = bytes.readUInt16LE(60); + const names = shoff + bytes.readUInt16LE(62) * 64; + const namesOffset = Number(bytes.readBigUInt64LE(names + 24)); + for (let index = 1; index < count; index += 1) { + const at = shoff + index * 64; + const nameAt = namesOffset + bytes.readUInt32LE(at); + const end = bytes.indexOf(0, nameAt); + if (bytes.toString("latin1", nameAt, end) === name) + return { + offset: Number(bytes.readBigUInt64LE(at + 24)), + headerOffset: at, + }; + } + throw new Error(`fixture has no ${name}`); +}; + +it("keeps .eh_frame ranges when .debug_frame is SHT_NOBITS", () => { + const report = parse( + elfUnwindFixture({ + header: "match", + backing: { section: ".debug_frame", kind: "nobits" }, + }), + ); + expect(report.debug_frame).toEqual({ status: "incomplete", location: null }); + expect(report.eh_frame.status).toBe("present"); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + address(UNWIND_FN_B), + ]); + expect(report.coverage).toMatchObject({ + status: "incomplete", + eh_frame_hdr_agreement: "agree", + }); + expect(report.limitations).toContain( + ".debug_frame is declared but its bytes are not in the file; it was not examined.", + ); +}); + +it("reports a .debug_frame past the end of file without a file range", () => { + const report = parse( + elfUnwindFixture({ + header: "match", + backing: { section: ".debug_frame", kind: "past-eof" }, + }), + ); + expect(report.debug_frame).toEqual({ status: "incomplete", location: null }); + expect(report.ranges).toHaveLength(2); + expect(report.coverage.status).toBe("incomplete"); +}); + +it("decodes the in-file prefix of an .eh_frame that runs past the end of file", () => { + const bytes = elfUnwindFixture({ + header: "match", + backing: { section: ".eh_frame", kind: "past-eof" }, + }); + const report = parse(bytes); + const { offset } = findSection(bytes, ".eh_frame"); + expect(report.eh_frame).toEqual({ + status: "incomplete", + source: "section", + location: { offset, bytes: bytes.length - offset }, + }); + expect(report.ranges).toHaveLength(2); + expect(report.coverage).toMatchObject({ + status: "incomplete", + total_ranges: 2, + eh_frame_hdr_agreement: "agree", + }); + expect(report.limitations).toContain( + ".eh_frame extends past the end of the file; only its in-file bytes were decoded.", + ); +}); + +it.each([ + [".eh_frame", ".eh_frame is declared but not file-backed."], + [".eh_frame_hdr", ".eh_frame_hdr is declared but not file-backed."], +] as const)("reports a SHT_NOBITS %s without a file range", (section, text) => { + const report = parse( + elfUnwindFixture({ header: "match", backing: { section, kind: "nobits" } }), + ); + const table = section === ".eh_frame" ? report.eh_frame : report.eh_frame_hdr; + expect(table.status).toBe("incomplete"); + expect(table.location).toBeNull(); + expect(report.coverage.status).toBe("incomplete"); + expect(report.limitations).toContain(text); + if (section === ".eh_frame_hdr") expect(report.ranges).toHaveLength(2); +}); + +it("resolves .eh_frame datarel pointers against the GOT, not .eh_frame_hdr", () => { + const report = parse(elfUnwindFixture({ header: "match", datarel: "got" })); + expect(report.cies[0]?.pointer_encoding).toEqual({ + raw: 0x3b, + name: "datarel|sdata4", + }); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + address(UNWIND_FN_B), + ]); + expect(report.coverage).toMatchObject({ + status: "complete", + eh_frame_hdr_agreement: "agree", + }); + expect(report.limitations).toContain( + "DW_EH_PE_datarel .eh_frame values were resolved against the GOT base from the .got.plt section.", + ); + expect(UNWIND_GOT_VA).not.toBe(UNWIND_HDR_VA); +}); + +it("does not use a non-allocated GOT section as the data-relative base", () => { + const bytes = elfUnwindFixture({ header: "match", datarel: "got" }); + const { headerOffset } = findSection(bytes, ".got.plt"); + bytes.writeBigUInt64LE(1n, headerOffset + 8); + bytes.writeBigUInt64LE(0x500000n, headerOffset + 16); + const report = parse(bytes); + expect(report.coverage.status).toBe("incomplete"); + expect(report.ranges.map((range) => range.start)).not.toContain("0x4fc100"); +}); + +it("leaves .eh_frame datarel pointers unresolved when no GOT is declared", () => { + const report = parse(elfUnwindFixture({ header: "match", datarel: "none" })); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_B), + ]); + expect(report.coverage).toMatchObject({ + status: "incomplete", + total_ranges: 1, + eh_frame_hdr_agreement: "disagree", + }); + expect(report.limitations).toContain( + "A DW_EH_PE_datarel .eh_frame value needs a GOT base that could not be determined; that value is unresolved.", + ); +}); + +it("caps symbol lookup across .symtab and .dynsym together", () => { + const report = parse(elfUnwindFixture({ header: "match", symbolCap: true })); + expect(report.ranges).toHaveLength(2); + expect(report.coverage).toMatchObject({ + status: "incomplete", + ranges_with_symbols: 0, + }); + expect(report.limitations).toContain( + "Symbol tables were capped at 65536 combined entries.", + ); +}); + +it.each([".eh_frame", ".eh_frame_hdr"] as const)( + "reports coverage incomplete when a duplicate %s is ignored", + (duplicate) => { + const report = parse(elfUnwindFixture({ header: "match", duplicate })); + expect(report.ranges).toHaveLength(2); + expect(report.coverage.status).toBe("incomplete"); + expect(report.limitations).toContain( + `Multiple ${duplicate} sections were declared; only the first was decoded.`, + ); + }, +); + +it.each(["duplicate-row", "shared-fde"] as const)( + "reports header disagreement for a %s search table", + (header) => { + const report = parse(elfUnwindFixture({ header })); + expect(report.ranges).toHaveLength(2); + expect(report.coverage.eh_frame_hdr_agreement).toBe("disagree"); + expect(report.limitations).toContain( + "The .eh_frame_hdr binary search table does not match the decoded FDEs.", + ); + }, +); + +it.each([ + ["elf64 little-endian", 64, true], + ["elf32 big-endian", 32, false], +] as const)( + "keeps a 4-byte CIE ID after a 64-bit .eh_frame length (%s)", + (_label, bits, little) => { + const report = parse( + elfUnwindFixture({ + bits, + little, + header: "match", + extendedLength: true, + }), + ); + expect(report.cies.map((cie) => cie.augmentation.text)).toEqual([ + "zRS", + "zPLRB", + ]); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + address(UNWIND_FN_B), + ]); + expect(report.coverage).toMatchObject({ + status: "complete", + examined_records: 4, + eh_frame_hdr_agreement: "agree", + }); + }, +); + +it.each([ + ["elf64 little-endian", 64, true], + ["elf32 big-endian", 32, false], +] as const)( + "resolves .eh_frame datarel pointers against DT_PLTGOT (%s)", + (_label, bits, little) => { + const report = parse( + elfUnwindFixture({ bits, little, header: "match", datarel: "dynamic" }), + ); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + address(UNWIND_FN_B), + ]); + expect(report.coverage.status).toBe("complete"); + expect(report.limitations).toContain( + "DW_EH_PE_datarel .eh_frame values were resolved against the GOT base from DT_PLTGOT.", + ); + }, +); + +it("caps .eh_frame_hdr rows at the max_ranges budget and marks the comparison incomplete", () => { + const report = parse(elfUnwindFixture({ header: "duplicate-row" }), { + max_ranges: 2, + }); + expect(report.ranges).toHaveLength(2); + expect(report.eh_frame_hdr).toMatchObject({ + status: "incomplete", + fde_count: 3, + table_entries: 2, + }); + expect(report.coverage).toMatchObject({ + status: "incomplete", + eh_frame_hdr_agreement: "unavailable", + }); + expect(report.limitations).toContain( + ".eh_frame_hdr declares 3 FDEs, more than the max_ranges budget of 2; only 2 search-table rows were read and the table was not compared with the decoded FDEs.", + ); +}); + +it("bounds an overstated .eh_frame_hdr fde_count by the section bytes", () => { + const report = parse( + elfUnwindFixture({ header: "match", headerCount: 0x7fffffff }), + { max_ranges: 65536 }, + ); + expect(report.ranges).toHaveLength(2); + expect(report.eh_frame_hdr).toMatchObject({ + status: "incomplete", + fde_count: 0x7fffffff, + table_entries: 2, + }); + expect(report.coverage.eh_frame_hdr_agreement).toBe("unavailable"); + expect(report.limitations).toContain( + `.eh_frame_hdr declares ${String(0x7fffffff)} FDEs but its bytes hold at most 2 search-table rows; the table is truncated.`, + ); +}); + +it("reports an .eh_frame_hdr fde_count above 32 bits as undecoded, keeping the FDEs", () => { + const report = parse( + elfUnwindFixture({ header: "match", headerCount: 0x1_0000_0002n }), + ); + expect(report.ranges).toHaveLength(2); + expect(report.eh_frame_hdr).toMatchObject({ + status: "incomplete", + fde_count: null, + table_entries: 0, + }); + expect(report.coverage.eh_frame_hdr_agreement).toBe("unavailable"); + expect(report.limitations).toContain( + ".eh_frame_hdr FDE count does not fit in 32 bits.", + ); +}); + +it("reports a header with an omitted search table as present without failing coverage", () => { + const report = parse( + elfUnwindFixture({ header: "match", headerCount: "omit" }), + ); + expect(report.ranges).toHaveLength(2); + expect(report.eh_frame_hdr).toMatchObject({ + status: "present", + fde_count: null, + table_entries: 0, + fde_count_encoding: { raw: 0xff }, + table_encoding: { raw: 0xff }, + }); + expect(report.coverage.eh_frame_hdr_agreement).toBe("unavailable"); + expect(report.coverage.status).toBe("complete"); +}); + +it("reports a raw zero LSDA pointer as no LSDA, not the field address", () => { + const report = parse(elfUnwindFixture({ header: "match", zeroLsda: true })); + expect(report.cies[1]?.lsda_encoding).toEqual({ + raw: 0x1b, + name: "pcrel|sdata4", + }); + expect(report.ranges[1]).toMatchObject({ + start: address(UNWIND_FN_B), + lsda: null, + }); + expect(report.coverage.status).toBe("complete"); +}); + +it("skips an FDE with a raw zero initial location as a discarded entry", () => { + const report = parse( + elfUnwindFixture({ header: "match", discardedFde: true }), + ); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + address(UNWIND_FN_B), + ]); + expect(report.coverage).toMatchObject({ + status: "complete", + total_ranges: 2, + examined_records: 5, + eh_frame_hdr_agreement: "agree", + }); + expect(report.limitations).toContain( + "1 FDE has a zero initial location and was skipped as a discarded entry.", + ); +}); + +it("does not claim PT_GNU_EH_FRAME was ignored when it supplied the header", () => { + const report = parse(elfUnwindSectionlessFixture({ sections: ".eh_frame" })); + expect(report.eh_frame).toMatchObject({ + status: "present", + source: "section", + }); + expect(report.eh_frame_hdr.status).toBe("present"); + expect(report.coverage.eh_frame_hdr_agreement).toBe("agree"); + expect( + report.limitations.some((text) => text.includes("PT_GNU_EH_FRAME was")), + ).toBe(false); + expect(report.limitations.some((text) => text.includes("ignored"))).toBe( + false, + ); +}); + +it("notes an ignored PT_GNU_EH_FRAME only when a .eh_frame_hdr section overrides different bytes", () => { + const report = parse( + elfUnwindSectionlessFixture({ sections: ".eh_frame_hdr" }), + ); + expect(report.eh_frame_hdr.status).toBe("present"); + expect(report.coverage.eh_frame_hdr_agreement).toBe("agree"); + expect(report.limitations).toContain( + "PT_GNU_EH_FRAME names different bytes than the .eh_frame_hdr section; it was ignored because the section is present.", + ); +}); + +it("uses the load mapping for a non-allocated unwind header", () => { + const bytes = elfUnwindSectionlessFixture({ sections: ".eh_frame_hdr" }); + const { headerOffset } = findSection(bytes, ".eh_frame_hdr"); + bytes.writeBigUInt64LE(0n, headerOffset + 8); + bytes.writeBigUInt64LE(0n, headerOffset + 16); + const report = parse(bytes); + expect(report.coverage).toMatchObject({ + status: "complete", + eh_frame_hdr_agreement: "agree", + }); +}); + +it("translates and places addresses only through SHF_ALLOC sections", () => { + const report = parse( + elfUnwindSectionlessFixture({ + sections: ".eh_frame", + nonAllocOverlay: true, + }), + ); + expect(report.eh_frame).toMatchObject({ + status: "present", + source: "section", + }); + expect(report.eh_frame_hdr.status).toBe("present"); + expect(report.ranges).toHaveLength(1); + expect(report.ranges[0]).toMatchObject({ + start: address(UNWIND_FN_A), + section: null, + }); + expect(report.coverage).toMatchObject({ + status: "complete", + eh_frame_hdr_agreement: "agree", + }); +}); + +it("does not decode FDEs against a CIE with an unrecognized augmentation letter", () => { + const report = parse( + elfUnwindFixture({ header: "match", unknownAugmentation: true }), + ); + expect(report.cies.map((cie) => cie.augmentation.text)).toEqual(["zPLRB"]); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_B), + ]); + expect(report.coverage.status).toBe("incomplete"); + expect(report.limitations).toEqual( + expect.arrayContaining([ + "A CIE augmentation has an unrecognized letter, so its pointer encodings cannot be trusted; that CIE was not decoded.", + "1 FDE uses a CIE with an unrecognized augmentation letter and was not decoded.", + ]), + ); +}); + +it.each([32, 64] as const)( + "wraps an unsigned pcrel ELF%i initial location at the address width", + (bits) => { + const report = parse( + elfUnwindFixture({ bits, header: "match", unsignedPcrel: {} }), + ); + expect(report.ranges[0]).toMatchObject({ + start: address(UNWIND_FN_A), + end: address(UNWIND_FN_A + UNWIND_FN_A_SIZE), + }); + expect(report.coverage.eh_frame_hdr_agreement).toBe("agree"); + }, +); + +it("wraps an ELF32 FDE end that overflows the address space", () => { + const report = parse( + elfUnwindFixture({ + bits: 32, + header: "match", + unsignedPcrel: { size: 0xffffff00 }, + }), + ); + expect(report.ranges[0]).toMatchObject({ + start: address(UNWIND_FN_A), + end: address((UNWIND_FN_A + 0xffffff00) % 2 ** 32), + size: String(0xffffff00), + }); +}); + +it("scans and decodes a symbol name shared by many symbols once, only for FDE starts", () => { + const bytes = elfUnwindFixture({ + header: "match", + sharedName: { symbols: 4096, bytes: 60000 }, + }); + stringTable.observed.length = 0; + stringTable.decoded.length = 0; + const report = parse(bytes, { limit: 1 }); + expect(report.ranges).toHaveLength(1); + expect(report.ranges[0]?.symbol?.text).toBe("n".repeat(60000)); + expect(report.coverage).toMatchObject({ + total_ranges: 2, + ranges_with_symbols: 2, + }); + // Section names are observed too; only the shared symbol name is counted. + const shared = (lengths: readonly number[]) => + lengths.filter((length) => length === 60000).length; + expect(shared(stringTable.observed)).toBe(1); + expect(shared(stringTable.decoded)).toBe(1); +}); + +it("skips an FDE whose initial location is indirect instead of reporting the pointer slot", () => { + const report = parse( + elfUnwindFixture({ header: "match", indirectStart: true }), + ); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_B), + ]); + expect(report.coverage.status).toBe("incomplete"); + expect(report.limitations).toEqual( + expect.arrayContaining([ + "1 FDE has an indirect initial location that was not dereferenced; its function start is unknown and it was skipped.", + "Indirect EH pointers were not dereferenced.", + ]), + ); +}); + +it("does not locate .eh_frame through an indirect .eh_frame_hdr eh_frame_ptr", () => { + const bytes = elfUnwindSectionlessFixture(); + bytes[Number(bytes.readBigUInt64LE(64 + 56 + 8)) + 1] = 0x9b; + const report = parse(bytes); + expect(report.eh_frame.status).toBe("unknown"); + expect(report.ranges).toEqual([]); + expect(report.eh_frame_hdr).toMatchObject({ + status: "incomplete", + eh_frame_pointer: null, + }); + expect(report.coverage.status).toBe("incomplete"); + expect(report.limitations).toContain( + "The .eh_frame_hdr eh_frame_ptr is indirect and was not dereferenced, so it does not locate .eh_frame.", + ); +}); + +it("stops a header-located .eh_frame without a search table at a CIE with an unrecognized augmentation", () => { + const report = parse( + elfUnwindSectionlessFixture({ + unterminated: "rejected-cie", + emptyTable: true, + }), + ); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + ]); + expect(report.coverage).toMatchObject({ + status: "incomplete", + total_ranges: 1, + examined_records: 3, + }); + expect(report.limitations).toEqual( + expect.arrayContaining([ + "A CIE augmentation has an unrecognized letter, so its pointer encodings cannot be trusted; that CIE was not decoded.", + "The .eh_frame located only through the .eh_frame_hdr eh_frame_ptr has no section bounds or usable search table, so decoding stopped at the first record that failed to decode.", + ]), + ); +}); + +it("reports coverage incomplete when a duplicate PT_GNU_EH_FRAME is ignored", () => { + const report = parse( + elfUnwindSectionlessFixture({ duplicateProgramHeader: true }), + ); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + ]); + expect(report.coverage.status).toBe("incomplete"); + expect(report.limitations).toContain( + "Multiple PT_GNU_EH_FRAME program headers were declared; only the first was decoded.", + ); +}); + +it("bounds the symbol-name text decoded for one page by its own allowance", () => { + const bytes = elfUnwindFixture({ header: "absent", suffixNames: 300 }); + const page = parse(bytes, { limit: 200 }); + expect(page.ranges).toHaveLength(200); + expect(page.ranges[2]).toMatchObject({ + start: address(UNWIND_SUFFIX_VA), + symbol: { text: "s".repeat(65535) }, + }); + expect(() => parse(bytes, { limit: 300 })).toThrow( + ElfUnwindSymbolTextFailure, + ); +}); + +it("reads a long CIE augmentation as one byte slice instead of building it per character", () => { + const letters = 1 << 20; + const bytes = elfUnwindFixture({ + header: "match", + longAugmentation: letters, + }); + const fromCharCode = vi.spyOn(String, "fromCharCode"); + try { + const report = parse(bytes, { limit: 1 }); + expect(report.cies[0]).toMatchObject({ + augmentation: { text: `zRS${"S".repeat(letters)}` }, + signal_frame: true, + }); + expect(report.coverage.status).toBe("complete"); + expect(fromCharCode.mock.calls.length).toBeLessThan(1000); + } finally { + fromCharCode.mockRestore(); + } +}); + +it("does not compare or bound by an .eh_frame_hdr search table with an indirect encoding", () => { + const bytes = elfUnwindFixture({ header: "match" }); + bytes[findSection(bytes, ".eh_frame_hdr").offset + 3] = 0xbb; + const report = parse(bytes); + expect(report.ranges.map((range) => range.start)).toEqual([ + address(UNWIND_FN_A), + address(UNWIND_FN_B), + ]); + expect(report.eh_frame_hdr).toMatchObject({ + status: "incomplete", + table_entries: 0, + fde_count: 2, + }); + expect(report.coverage).toMatchObject({ + status: "incomplete", + eh_frame_hdr_agreement: "unavailable", + }); + expect(report.limitations).toContain( + "The .eh_frame_hdr search table uses an indirect encoding that was not dereferenced; it was not compared with the decoded FDEs.", + ); + + const sectionless = elfUnwindSectionlessFixture({ + unterminated: "decodable", + }); + sectionless[Number(sectionless.readBigUInt64LE(64 + 56 + 8)) + 3] = 0xbb; + const unbounded = parse(sectionless); + expect(unbounded.coverage.status).toBe("incomplete"); + expect(unbounded.limitations).not.toContain( + "The .eh_frame located only through the .eh_frame_hdr eh_frame_ptr has no section bounds and no terminator after the last FDE its search table names; bytes after that FDE were not decoded.", + ); +}); + +it("leaves a non-zero indirect .eh_frame_hdr FDE count unresolved", () => { + const bytes = elfUnwindFixture({ header: "match" }); + bytes[findSection(bytes, ".eh_frame_hdr").offset + 2] = 0x83; + const report = parse(bytes); + expect(report.ranges).toHaveLength(2); + expect(report.eh_frame_hdr).toMatchObject({ + status: "incomplete", + fde_count: null, + table_entries: 0, + }); + expect(report.coverage).toMatchObject({ + status: "incomplete", + eh_frame_hdr_agreement: "unavailable", + }); + expect(report.limitations).toContain( + "The .eh_frame_hdr FDE count uses an indirect encoding that was not dereferenced, so the count and search table are unknown.", + ); +}); + +it("reports a header that declares a count but omits its search table as present", () => { + const bytes = elfUnwindFixture({ header: "match" }); + bytes[findSection(bytes, ".eh_frame_hdr").offset + 3] = 0xff; + const report = parse(bytes); + expect(report.eh_frame_hdr).toMatchObject({ + status: "present", + fde_count: 2, + table_entries: 0, + table_encoding: { raw: 0xff }, + }); + expect(report.coverage).toMatchObject({ + status: "complete", + eh_frame_hdr_agreement: "unavailable", + }); + expect(report.limitations).not.toContain( + "The .eh_frame_hdr search table uses an indirect encoding that was not dereferenced; it was not compared with the decoded FDEs.", + ); +}); + +it("names a range from an STT_GNU_IFUNC resolver symbol at its exact start", () => { + const report = parse(elfUnwindFixture({ header: "match", ifunc: true })); + expect(report.ranges[0]).toMatchObject({ + start: address(UNWIND_FN_A), + symbol: { text: "alpha" }, + }); + expect(report.coverage.ranges_with_symbols).toBe(1); + expect(report.limitations).toContain( + "A symbol name from an STT_GNU_IFUNC symbol names the resolver that starts at that address, not the implementation it selects at run time.", + ); +}); + +it("excludes load mappings whose file size exceeds memory size", () => { + const bytes = elfUnwindSectionlessFixture(); + const phoff = Number(bytes.readBigUInt64LE(32)); + bytes.writeBigUInt64LE(0n, phoff + 40); + const report = parse(bytes); + expect(report.coverage.status).toBe("incomplete"); + expect(report.limitations).toContain( + "An invalid PT_LOAD mapping was excluded from unwind address resolution.", + ); +}); + +it("compares the header pointer with a nonallocated frame's mapped address", () => { + const bytes = elfUnwindSectionlessFixture({ sections: ".eh_frame_hdr" }); + const section = findSection(bytes, ".eh_frame"); + bytes.writeBigUInt64LE(0n, section.headerOffset + 8); + bytes.writeBigUInt64LE(0n, section.headerOffset + 16); + expect(parse(bytes).coverage.eh_frame_hdr_agreement).toBe("agree"); +}); diff --git a/src/native/elf/ElfUnwindTables.ts b/src/native/elf/ElfUnwindTables.ts new file mode 100644 index 0000000000..79abd19760 --- /dev/null +++ b/src/native/elf/ElfUnwindTables.ts @@ -0,0 +1,2001 @@ +import { admitElfLoadSegment } from "../../domain/elfProgramHeader.js"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readElfDynamicEntry } from "../../domain/elfDynamicEntry.js"; +import { readElfSymbol } from "../../domain/elfSymbol.js"; +import { + elfStringTableText, + observeElfStringTableEntry, + type ElfStringTableObservation, +} from "../../domain/elfStringTable.js"; +import { elfObjectTypeName } from "../../domain/native/elfNotes.js"; +import { + ELF_UNWIND_LIMITATIONS, + elfEhPointerEncodingName, + MAX_ELF_UNWIND_SYMBOL_TEXT_BYTES, + MAX_ELF_UNWIND_SYMBOLS, + type ElfUnwindRanges, + type InspectElfUnwindRangesInput, +} from "../../domain/native/elfUnwindRanges.js"; +import { buildIntervalIndex } from "./ElfIntervalIndex.js"; +import { + ELF_PT_GNU_EH_FRAME, + readElfDirectoryFromBuffer, + type ElfProgramDeclaration, + type NativeSection, +} from "../metadata/NativeSectionDirectory.js"; + +const DW_EH_PE_OMIT = 0xff; +/** CIE augmentation letters, compared as bytes. */ +const AUG_Z = 0x7a; +const AUG_L = 0x4c; +const AUG_P = 0x50; +const AUG_R = 0x52; +const AUG_S = 0x53; +const AUG_B = 0x42; +const SHT_SYMTAB = 2; +const SHT_DYNAMIC = 6; +const SHT_NOBITS = 8; +const SHT_DYNSYM = 11; +const DT_NULL = 0n; +const DT_PLTGOT = 3n; +/** Bound on dynamic entries scanned for DT_PLTGOT. */ +const MAX_DYNAMIC_ENTRIES = 4096; +const STT_NOTYPE = 0; +const STT_FUNC = 2; +const STT_GNU_IFUNC = 10; +const SHN_UNDEF = 0; +const SHN_ABS = 0xfff1; +const SHN_LORESERVE = 0xff00; +const SHF_ALLOC = 2n; +const SHF_EXECINSTR = 4n; +const SHF_COMPRESSED = 0x800n; + +const { + cfa: CFA_LIMITATION, + untrusted: UNTRUSTED_LIMITATION, + indirect: INDIRECT_LIMITATION, + headerMismatch: HEADER_MISMATCH, + frameAbsent: FRAME_ABSENT, + headerAbsent: HEADER_ABSENT, + debugPresent: DEBUG_PRESENT, + debugAbsent: DEBUG_ABSENT, + debugUnknown: DEBUG_UNKNOWN, + debugOutside: DEBUG_OUTSIDE, +} = ELF_UNWIND_LIMITATIONS; +const DATAREL_UNRESOLVED = + "A DW_EH_PE_datarel .eh_frame value needs a GOT base that could not be determined; that value is unresolved."; + +/** Counted note for FDEs skipped because their initial location is zero. */ +const discardedLimitation = (count: number): string => + count === 1 + ? "1 FDE has a zero initial location and was skipped as a discarded entry." + : `${String(count)} FDEs have a zero initial location and were skipped as discarded entries.`; + +const TABLE_BOUNDED = + "The .eh_frame located only through the .eh_frame_hdr eh_frame_ptr has no section bounds and no terminator after the last FDE its search table names; bytes after that FDE were not decoded."; +const UNBOUNDED_STOPPED = + "The .eh_frame located only through the .eh_frame_hdr eh_frame_ptr has no section bounds or usable search table, so decoding stopped at the first record that failed to decode."; +const UNBOUNDED_UNTERMINATED = + "The .eh_frame located only through the .eh_frame_hdr eh_frame_ptr has no section bounds, usable search table or terminator before the end of its PT_LOAD segment, so its extent is unknown."; + +/** Counted note for FDEs skipped because their initial location is indirect. */ +const indirectFdeLimitation = (count: number): string => + count === 1 + ? "1 FDE has an indirect initial location that was not dereferenced; its function start is unknown and it was skipped." + : `${String(count)} FDEs have indirect initial locations that were not dereferenced; their function starts are unknown and they were skipped.`; +const INDIRECT_COUNT = + "The .eh_frame_hdr FDE count uses an indirect encoding that was not dereferenced, so the count and search table are unknown."; +const IFUNC_NAMES = + "A symbol name from an STT_GNU_IFUNC symbol names the resolver that starts at that address, not the implementation it selects at run time."; +const INDIRECT_TABLE = + "The .eh_frame_hdr search table uses an indirect encoding that was not dereferenced; it was not compared with the decoded FDEs."; +const INDIRECT_FRAME_POINTER = + "The .eh_frame_hdr eh_frame_ptr is indirect and was not dereferenced, so it does not locate .eh_frame."; + +const POINTER_MISMATCH = + "The .eh_frame_hdr eh_frame_ptr does not match the .eh_frame section address."; +const UNKNOWN_AUGMENTATION = + "A CIE augmentation has an unrecognized letter, so its pointer encodings cannot be trusted; that CIE was not decoded."; + +/** Counted note for FDEs skipped because their CIE has an unknown augmentation. */ +const unknownAugmentationLimitation = (count: number): string => + count === 1 + ? "1 FDE uses a CIE with an unrecognized augmentation letter and was not decoded." + : `${String(count)} FDEs use a CIE with an unrecognized augmentation letter and were not decoded.`; + +interface FileRange { + readonly offset: number; + readonly bytes: number; +} + +interface Encoding { + readonly raw: number; + readonly name: string; +} + +interface Personality { + readonly address: string; + readonly encoding: Encoding; + readonly indirect: boolean; +} + +interface CieRecord { + readonly index: number; + readonly version: 1 | 3; + /** Augmentation bytes before their NUL, decoded only for returned CIEs. */ + readonly augmentation: Buffer; + readonly code_alignment: number; + readonly data_alignment: number; + readonly return_address_register: number; + readonly pointer_encoding: Encoding; + readonly lsda_encoding: Encoding | null; + readonly personality: Personality | null; + readonly signal_frame: boolean; + readonly b_key: boolean; + readonly instruction_bytes: number; + readonly location: FileRange; + readonly hasZ: boolean; +} + +interface FunctionRange { + readonly index: number; + readonly start: string; + readonly end: string; + readonly size: string; + readonly section: { + readonly text: string; + readonly trust: "untrusted"; + } | null; + readonly symbol: { + readonly text: string; + readonly trust: "untrusted"; + } | null; + readonly cie_index: number; + readonly lsda: { + readonly address: string; + readonly indirect: boolean; + } | null; + readonly personality: { + readonly address: string; + readonly indirect: boolean; + } | null; + readonly location: FileRange; + readonly startValue: bigint; +} + +interface HeaderRow { + readonly start: string; + readonly fdeFileOffset: number | null; +} + +interface HeaderDecode { + readonly status: "present" | "absent" | "incomplete" | "unknown"; + readonly location: FileRange | null; + readonly version: number | null; + readonly eh_frame_pointer_encoding: Encoding | null; + readonly fde_count_encoding: Encoding | null; + readonly table_encoding: Encoding | null; + readonly eh_frame_pointer: string | null; + readonly ehFramePointer: bigint | null; + readonly fde_count: number | null; + readonly table_entries: number; + readonly rows: readonly HeaderRow[]; + readonly decoded: boolean; +} + +interface DecodedPointer { + readonly address: bigint; + readonly indirect: boolean; + readonly next: number; +} + +interface PointerBases { + /** `.eh_frame_hdr` fields use the header address; `.eh_frame` uses the GOT. */ + readonly datarel: bigint | null; + readonly textrel: bigint | null; + readonly funcrel: bigint | null; + readonly lengthOnly: boolean; +} + +type ReadEncoded = ( + offset: number, + end: number, + encoding: number, + bases: PointerBases, +) => DecodedPointer | "omit" | null; + +/** + * Where a section's declared bytes sit in the file. A range that runs past the + * end of file is clamped to the in-file prefix; NOBITS or wholly outside + * sections have no file range. + */ +interface SectionPlacement { + readonly location: FileRange | null; + readonly problem: "nobits" | "outside" | "clamped" | null; +} + +const placeSection = ( + section: NativeSection, + fileBytes: number, +): SectionPlacement => { + if (section.type === SHT_NOBITS) return { location: null, problem: "nobits" }; + const declared = fileRange(section); + if (declared === null || declared.offset > fileBytes) + return { location: null, problem: "outside" }; + if (declared.bytes > fileBytes - declared.offset) + return { + location: { + offset: declared.offset, + bytes: fileBytes - declared.offset, + }, + problem: "clamped", + }; + return { location: declared, problem: null }; +}; + +/** + * Symbol names for one page exceeded their fixed text allowance. Unlike the + * record budgets, max_ranges does not raise it; a smaller page avoids it. + */ +export class ElfUnwindSymbolTextFailure extends Error { + readonly maximumBytes = MAX_ELF_UNWIND_SYMBOL_TEXT_BYTES; + + constructor() { + super( + `ELF unwind symbol names on this page exceed the ${String(MAX_ELF_UNWIND_SYMBOL_TEXT_BYTES)}-byte symbol-name text allowance.`, + ); + this.name = "ElfUnwindSymbolTextFailure"; + } +} + +/** Read .eh_frame/.eh_frame_hdr function ranges. Truncation stays incomplete. */ +export const parseElfUnwindRanges = ( + bytes: Buffer, + artifact: { + readonly path: string; + readonly sha256: string; + readonly bytes: number; + }, + input: InspectElfUnwindRangesInput, + signal?: AbortSignal, +): ElfUnwindRanges => { + assertArtifactReadActive(signal); + const directory = readElfDirectoryFromBuffer(bytes); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const little = directory.little; + const bits = directory.bits; + const addressSize = bits === 64 ? 8 : 4; + const limitations: string[] = []; + let incomplete = false; + let sawIndirect = false; + const note = (text: string): void => { + if (!limitations.includes(text)) limitations.push(text); + }; + const fail = (text: string): void => { + incomplete = true; + note(text); + }; + + // Without section headers, sections outside every segment stay unknown. + const namesKnown = + directory.sections.length > 0 && + directory.section_names_limitation === undefined && + directory.sections.every((section) => section.name_state === "known"); + if (directory.section_names_limitation !== undefined) { + fail(directory.section_names_limitation); + } + + // The section directory has already admitted every program header, + // including a PN_XNUM count, or rejected the image. + const loads = directory.programs.flatMap((program) => { + if (program.type !== 1) return []; + const admitted = admitElfLoadSegment( + { + virtualAddress: program.virtual_address, + memorySize: program.memory_size, + fileSize: program.file_size, + }, + directory.bits, + ); + if ( + !admitted.ok || + program.file_offset + program.file_size > BigInt(bytes.length) + ) { + fail( + "An invalid PT_LOAD mapping was excluded from unwind address resolution.", + ); + return []; + } + return [programSpan(program)]; + }); + const gnuHeaders = directory.programs.flatMap((program) => + program.type === ELF_PT_GNU_EH_FRAME ? [programSpan(program)] : [], + ); + const gnu = gnuHeaders[0]; + + const named = (name: string): NativeSection[] => + directory.sections.filter((section) => section.name === name); + const ehSections = named(".eh_frame"); + const hdrSections = named(".eh_frame_hdr"); + const debugSections = named(".debug_frame"); + // Ranges in ignored duplicate tables are omitted, so coverage is incomplete. + if (ehSections.length > 1) + fail( + "Multiple .eh_frame sections were declared; only the first was decoded.", + ); + if (hdrSections.length > 1) + fail( + "Multiple .eh_frame_hdr sections were declared; only the first was decoded.", + ); + // Only the first PT_GNU_EH_FRAME is read. Its duplicates matter only when + // no .eh_frame_hdr section supplies the header instead. + if (gnuHeaders.length > 1 && hdrSections.length === 0) + fail( + "Multiple PT_GNU_EH_FRAME program headers were declared; only the first was decoded.", + ); + + const debugSection = debugSections[0]; + const debugPlacement = + debugSection === undefined + ? null + : placeSection(debugSection, bytes.length); + let debugFrame: ElfUnwindRanges["debug_frame"]; + if (debugPlacement === null) + debugFrame = { + status: namesKnown ? "absent" : "unknown", + location: null, + }; + else if (debugPlacement.problem === null) + debugFrame = { status: "present", location: debugPlacement.location }; + else { + debugFrame = { status: "incomplete", location: null }; + fail(DEBUG_OUTSIDE); + } + + // In-file bytes of each SHF_ALLOC section; a section running past EOF keeps + // its prefix. A non-ALLOC section's sh_addr is not a load address, so + // translation falls through to PT_LOAD for bytes no ALLOC section maps. + const mapped = directory.sections.flatMap((section) => { + if ((section.flags & SHF_ALLOC) === 0n) return []; + const location = placeSection(section, bytes.length).location; + return location === null || location.bytes === 0 + ? [] + : [{ section, size: BigInt(location.bytes) }]; + }); + // Overlaps resolve once per image: the smallest section, then the lowest + // index. Each lookup is then a binary search. + const sectionAtFileOffset = buildIntervalIndex( + mapped.map(({ section, size }) => ({ + low: section.offset, + high: section.offset + size, + value: section, + })), + (left, right) => { + if (left.size !== right.size) return left.size < right.size ? -1 : 1; + return left.index - right.index; + }, + ); + // The first mapped section in directory order owns an address. + const sectionAtAddress = buildIntervalIndex( + mapped.map(({ section, size }, order) => ({ + low: section.address, + high: section.address + size, + value: { section, order }, + })), + (left, right) => left.order - right.order, + ); + // PT_LOAD fallbacks are indexed once too; the first segment in program + // header order owns an overlapping byte or address. + const loadAtFileOffset = buildIntervalIndex( + loads.map((load, order) => ({ + low: load.offset, + high: load.offset + load.fileSize, + value: { load, order }, + })), + (left, right) => left.order - right.order, + ); + const loadAtAddress = buildIntervalIndex( + loads.map((load, order) => ({ + low: load.virtualAddress, + high: load.virtualAddress + load.fileSize, + value: { load, order }, + })), + (left, right) => left.order - right.order, + ); + const vaForFileOffset = (fileOff: number): bigint | null => { + const off = BigInt(fileOff); + const chosen = sectionAtFileOffset(off); + if (chosen !== undefined) return chosen.address + (off - chosen.offset); + const load = loadAtFileOffset(off)?.load; + return load === undefined + ? null + : load.virtualAddress + (off - load.offset); + }; + const fileOffsetForVa = (address: bigint): number | null => { + const owner = sectionAtAddress(address)?.section; + if (owner !== undefined) { + const file = owner.offset + (address - owner.address); + if (file < 0n || file > BigInt(Number.MAX_SAFE_INTEGER)) return null; + return Number(file); + } + const load = loadAtAddress(address)?.load; + if (load === undefined) return null; + const file = load.offset + (address - load.virtualAddress); + if (file < 0n || file > BigInt(Number.MAX_SAFE_INTEGER)) return null; + return Number(file); + }; + // DW_EH_PE_textrel base, resolved once per image. + const textBaseAddress: bigint | null = + loads.find((load) => (load.flags & 1) !== 0)?.virtualAddress ?? + named(".text").find((section) => (section.flags & SHF_ALLOC) !== 0n) + ?.address ?? + null; + + const within = (offset: number, size: number): boolean => + offset >= 0 && + size >= 0 && + offset <= bytes.length && + size <= bytes.length - offset; + const readU8 = (offset: number, end: number): number | null => + offset < end && offset < bytes.length ? (bytes[offset] ?? null) : null; + const readU32 = (offset: number, end: number): number | null => { + if (offset < 0 || offset + 4 > end || offset + 4 > bytes.length) + return null; + return view.getUint32(offset, little); + }; + const readU64 = (offset: number, end: number): bigint | null => { + if (offset < 0 || offset + 8 > end || offset + 8 > bytes.length) + return null; + return view.getBigUint64(offset, little); + }; + const readS16 = (offset: number, end: number): number | null => { + if (offset < 0 || offset + 2 > end || offset + 2 > bytes.length) + return null; + return view.getInt16(offset, little); + }; + const readU16 = (offset: number, end: number): number | null => { + if (offset < 0 || offset + 2 > end || offset + 2 > bytes.length) + return null; + return view.getUint16(offset, little); + }; + const readS32 = (offset: number, end: number): number | null => { + if (offset < 0 || offset + 4 > end || offset + 4 > bytes.length) + return null; + return view.getInt32(offset, little); + }; + const readS64 = (offset: number, end: number): bigint | null => { + if (offset < 0 || offset + 8 > end || offset + 8 > bytes.length) + return null; + return view.getBigInt64(offset, little); + }; + const readLeb = ( + offset: number, + end: number, + signed: boolean, + ): { readonly value: bigint; readonly next: number } | null => { + let result = 0n; + let shift = 0n; + let pos = offset; + let byte = 0; + while (pos < end && pos < bytes.length && shift <= 63n) { + byte = bytes[pos] ?? 0; + pos += 1; + result |= BigInt(byte & 0x7f) << shift; + shift += 7n; + if ((byte & 0x80) === 0) { + if (signed && shift < 64n && (byte & 0x40) !== 0) + result |= -1n << shift; + return { value: result, next: pos }; + } + } + return null; + }; + const safeInt = (value: bigint): number | null => + value >= BigInt(Number.MIN_SAFE_INTEGER) && + value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(value) + : null; + + let datarelMissing = false; + let datarelUsed = false; + const readEncoded: ReadEncoded = (offset, end, encoding, bases) => { + if ((encoding & 0xff) === 0xff) return "omit"; + if (encoding === 0x50) { + const field = vaForFileOffset(offset); + if (field === null) return null; + const align = BigInt(addressSize); + const aligned = (field + align - 1n) & ~(align - 1n); + const file = offset + Number(aligned - field); + const width = addressSize; + const raw = width === 8 ? readU64(file, end) : readU32(file, end); + if (raw === null) return null; + return { + address: typeof raw === "bigint" ? raw : BigInt(raw), + indirect: false, + next: file + width, + }; + } + const format = encoding & 0x0f; + let raw: bigint | null = null; + let next = offset; + switch (format) { + case 0: { + const value = + addressSize === 8 ? readU64(offset, end) : readU32(offset, end); + if (value === null) return null; + raw = typeof value === "bigint" ? value : BigInt(value); + next = offset + addressSize; + break; + } + case 1: { + const value = readLeb(offset, end, false); + if (value === null) return null; + raw = value.value; + next = value.next; + break; + } + case 2: { + const value = readU16(offset, end); + if (value === null) return null; + raw = BigInt(value); + next = offset + 2; + break; + } + case 3: { + const value = readU32(offset, end); + if (value === null) return null; + raw = BigInt(value); + next = offset + 4; + break; + } + case 4: { + const value = readU64(offset, end); + if (value === null) return null; + raw = value; + next = offset + 8; + break; + } + case 9: { + const value = readLeb(offset, end, true); + if (value === null) return null; + raw = value.value; + next = value.next; + break; + } + case 0x0a: { + const value = readS16(offset, end); + if (value === null) return null; + raw = BigInt(value); + next = offset + 2; + break; + } + case 0x0b: { + const value = readS32(offset, end); + if (value === null) return null; + raw = BigInt(value); + next = offset + 4; + break; + } + case 0x0c: { + const value = readS64(offset, end); + if (value === null) return null; + raw = value; + next = offset + 8; + break; + } + default: + return null; + } + if (bases.lengthOnly) return { address: raw, indirect: false, next }; + // libgcc reads a raw zero as a null pointer: no base is added and an + // indirect pointer is not followed. + if (raw === 0n) return { address: 0n, indirect: false, next }; + const application = encoding & 0x70; + let address = raw; + if (application === 0x10) { + const field = vaForFileOffset(offset); + if (field === null) return null; + address += field; + } else if (application === 0x20) { + if (bases.textrel === null) return null; + address += bases.textrel; + } else if (application === 0x30) { + if (bases.datarel === null) { + datarelMissing = true; + return null; + } + datarelUsed = true; + address += bases.datarel; + } else if (application === 0x40) { + if (bases.funcrel === null) return null; + address += bases.funcrel; + } else if (application !== 0) return null; + // libgcc computes in _Unwind_Ptr, so a resolved address wraps at the ELF + // address width. + address = BigInt.asUintN(bits, address); + const indirect = (encoding & 0x80) !== 0; + if (indirect) sawIndirect = true; + return { address, indirect, next }; + }; + + const encodingOf = (raw: number): Encoding => ({ + raw: raw & 0xff, + name: elfEhPointerEncodingName(raw), + }); + const hex = (value: bigint): string => `0x${value.toString(16)}`; + + // DW_EH_PE_datarel in .eh_frame_hdr is relative to the header itself. + let hdrBase: bigint | null = null; + const hdrSection = hdrSections[0]; + let headerLocation: FileRange | null = null; + let headerStatus: HeaderDecode["status"] = "absent"; + if (hdrSection !== undefined) { + const placement = placeSection(hdrSection, bytes.length); + if (placement.problem !== null) { + headerStatus = "incomplete"; + fail( + placement.problem === "nobits" + ? ".eh_frame_hdr is declared but not file-backed." + : ".eh_frame_hdr extends past the end of the file and was not decoded.", + ); + } else if ((hdrSection.flags & SHF_COMPRESSED) !== 0n) { + headerLocation = placement.location; + headerStatus = "incomplete"; + fail(".eh_frame_hdr is SHF_COMPRESSED and was not decompressed."); + } else { + headerLocation = placement.location; + headerStatus = "present"; + hdrBase = + headerLocation === null ? null : vaForFileOffset(headerLocation.offset); + } + } else if (gnu !== undefined) { + const offset = safeInt(gnu.offset); + const size = safeInt(gnu.fileSize); + if ( + offset === null || + size === null || + !within(offset, size) || + size > 0xffffffff + ) { + headerStatus = "incomplete"; + fail("PT_GNU_EH_FRAME is outside the file."); + } else { + headerLocation = { offset, bytes: size }; + headerStatus = "present"; + hdrBase = vaForFileOffset(offset); + } + } else if (!namesKnown) headerStatus = "unknown"; + else headerStatus = "absent"; + + const header = + headerStatus === "present" && headerLocation !== null + ? decodeHeader( + headerLocation, + hdrBase, + textBaseAddress, + readEncoded, + readU8, + safeInt, + hex, + fileOffsetForVa, + fail, + signal, + { maxRows: input.max_ranges, addressSize }, + ) + : emptyHeader(headerStatus, headerLocation); + + const ehSection = ehSections[0]; + let frameStatus: ElfUnwindRanges["eh_frame"]["status"] = "absent"; + let frameSource: "section" | "program" | null = null; + let frameLocation: FileRange | null = null; + let frameEnd = 0; + let scanPastTerminator = false; + let frameClamped = false; + // File offset of the last FDE the search table names, bounding an + // .eh_frame that only the header locates. + let tableBound: number | null = null; + if (ehSection !== undefined) { + frameSource = "section"; + const placement = placeSection(ehSection, bytes.length); + frameLocation = placement.location; + if (placement.problem === "nobits") { + frameStatus = "incomplete"; + fail(".eh_frame is declared but not file-backed."); + } else if (frameLocation === null) { + frameStatus = "incomplete"; + fail(".eh_frame lies outside the file."); + } else if ((ehSection.flags & SHF_COMPRESSED) !== 0n) { + frameStatus = "incomplete"; + fail(".eh_frame is SHF_COMPRESSED and was not decompressed."); + } else { + frameStatus = "present"; + frameEnd = frameLocation.offset + frameLocation.bytes; + // A clamped section's declared tail is missing, so bytes after the + // terminator are not judged against it. + frameClamped = placement.problem === "clamped"; + scanPastTerminator = !frameClamped; + if (frameClamped) + fail( + ".eh_frame extends past the end of the file; only its in-file bytes were decoded.", + ); + } + } else if (header.ehFramePointer !== null) { + const start = fileOffsetForVa(header.ehFramePointer); + const load = loads.find((item) => { + if (start === null) return false; + const off = BigInt(start); + return off >= item.offset && off < item.offset + item.fileSize; + }); + if (start === null || load === undefined) { + frameStatus = "incomplete"; + frameSource = "program"; + fail("The .eh_frame_hdr eh_frame_ptr does not map into a file range."); + } else { + const end = safeInt(load.offset + load.fileSize); + frameStatus = "present"; + frameSource = "program"; + frameEnd = Math.min(end ?? bytes.length, bytes.length); + frameLocation = { + offset: start, + bytes: Math.max(0, frameEnd - start), + }; + scanPastTerminator = false; + // Without a section, the segment end is not the table end: other data + // such as .gcc_except_table can follow an unterminated .eh_frame. + // libgcc's PT_GNU_EH_FRAME lookup only binary-searches this table, so + // nothing past the last FDE it names is reachable through it. + if (header.decoded) + for (const row of header.rows) { + const at = row.fdeFileOffset; + if (at !== null && at >= start && at < frameEnd) + tableBound = Math.max(tableBound ?? at, at); + } + } + } else if (header.status !== "absent" && header.status !== "unknown") { + frameStatus = "unknown"; + fail( + "No .eh_frame section was found and the .eh_frame_hdr eh_frame_ptr could not be decoded, so .eh_frame presence is unknown.", + ); + } else if (!namesKnown) { + frameStatus = "unknown"; + fail( + "Section names are unknown and no .eh_frame section or mapped PT_GNU_EH_FRAME target was found, so .eh_frame presence is unknown.", + ); + } else { + frameStatus = "absent"; + note(FRAME_ABSENT); + } + + // The header is decoded from the .eh_frame_hdr section when one exists, so + // a PT_GNU_EH_FRAME naming different bytes is the one that was not read. + if ( + hdrSection !== undefined && + gnu !== undefined && + headerLocation !== null && + (gnu.offset !== BigInt(headerLocation.offset) || + gnu.fileSize !== BigInt(headerLocation.bytes)) + ) { + note( + "PT_GNU_EH_FRAME names different bytes than the .eh_frame_hdr section; it was ignored because the section is present.", + ); + } + + // DW_EH_PE_datarel in .eh_frame is GOT-relative (LSB; i386/x86-64 psABI). + const gotBase = resolveGotBase(bytes, directory, named); + datarelMissing = false; + datarelUsed = false; + + const cies: CieRecord[] = []; + const ranges: Array> = []; + const cieAt = new Map(); + const rejectedCies = new Set(); + let rejectedFdes = 0; + let examined = 0; + let cieRecords = 0; + let fdeRecords = 0; + let discarded = 0; + let indirectFdes = 0; + const budget = (kind: "CIE" | "FDE"): void => { + throw new ArtifactReaderFailure( + "limit", + `ELF unwind ${kind} count exceeds the selected max_ranges budget of ${String(input.max_ranges)}.`, + { boundary: "capacity" }, + ); + }; + + if (frameStatus === "present" && frameLocation !== null) { + let cursor = frameLocation.offset; + const extent = frameEnd; + // An unbounded header-located table stops at its first undecodable + // record, as libgcc's linear FDE search does, instead of reading on. + const stopAtBadRecord = frameSource === "program" && tableBound === null; + let terminated = false; + while (cursor < extent) { + assertArtifactReadActive(signal); + if (cursor + 4 > extent) { + fail(".eh_frame ended inside a record."); + frameStatus = "incomplete"; + break; + } + const initialLength = readU32(cursor, extent); + if (initialLength === null) { + fail(".eh_frame ended inside a record."); + frameStatus = "incomplete"; + break; + } + if (initialLength === 0) { + const tail = bytes.subarray(cursor + 4, extent); + if (scanPastTerminator && tail.some((byte) => byte !== 0)) + fail("Non-zero bytes follow the .eh_frame terminator."); + cursor += 4; + terminated = true; + break; + } + if (tableBound !== null && cursor > tableBound) { + note(TABLE_BOUNDED); + terminated = true; + break; + } + let body = cursor + 4; + let recordEnd = body + initialLength; + // .eh_frame keeps a 4-byte CIE ID / CIE pointer even after a 64-bit + // extended length (LSB, libunwind, LLVM IsEH); only .debug_frame's DWARF + // 64-bit format widens that field, and .debug_frame is not decoded here. + const idWidth = 4; + if (initialLength === 0xffffffff) { + const extended = readU64(cursor + 4, extent); + if (extended === null || extended > BigInt(Number.MAX_SAFE_INTEGER)) { + fail(".eh_frame ended inside a record."); + frameStatus = "incomplete"; + break; + } + body = cursor + 12; + recordEnd = body + Number(extended); + } + if (recordEnd > extent || recordEnd < body) { + fail(".eh_frame ended inside a record."); + frameStatus = "incomplete"; + break; + } + const recordStart = cursor; + const id = readU32(body, recordEnd); + if (id === null) { + fail(".eh_frame ended inside a record."); + frameStatus = "incomplete"; + break; + } + const isCie = id === 0; + if (isCie) { + if (cieRecords + 1 > input.max_ranges) budget("CIE"); + cieRecords += 1; + } else if (fdeRecords + 1 > input.max_ranges) budget("FDE"); + else fdeRecords += 1; + examined += 1; + let undecoded = false; + if (isCie) { + const cie = decodeCie({ + bytes, + recordStart, + recordEnd, + body: body + idWidth, + readU8, + readLeb, + readEncoded, + safeInt, + encodingOf, + datarel: gotBase?.address ?? null, + textrel: textBaseAddress, + fail, + }); + // A rejected CIE also ends an unbounded scan: libgcc rejects the + // whole table at an unrecognized augmentation. + if (cie === "unknown-augmentation") { + rejectedCies.add(recordStart); + undecoded = true; + } else if (cie === null) undecoded = true; + else { + const stored = { ...cie, index: cies.length }; + cies.push(stored); + cieAt.set(recordStart, stored); + } + } else { + // The CIE pointer is a byte distance from this field back to the CIE + // length, the same subtraction GCC performs on void pointers. + const field = body; + if (id > field) { + fail("An FDE referenced a CIE that was not decoded."); + undecoded = true; + } else { + const cie = cieAt.get(field - id); + const rejected = rejectedCies.has(field - id); + const decoded = + cie === undefined + ? null + : decodeFde({ + recordStart, + recordEnd, + body: body + idWidth, + cie, + readLeb, + readEncoded, + hex, + safeInt, + datarel: gotBase?.address ?? null, + textrel: textBaseAddress, + addressBits: bits, + fail, + }); + if (rejected) { + rejectedFdes += 1; + undecoded = true; + } else if (cie === undefined) { + fail("An FDE referenced a CIE that was not decoded."); + undecoded = true; + } else if (decoded === "discarded") discarded += 1; + else if (decoded === "indirect") indirectFdes += 1; + else if (decoded === null) undecoded = true; + else ranges.push({ ...decoded, index: ranges.length }); + } + } + cursor = recordEnd; + if (undecoded && stopAtBadRecord) { + fail(UNBOUNDED_STOPPED); + terminated = true; + break; + } + } + if (stopAtBadRecord && !terminated && frameStatus === "present") + fail(UNBOUNDED_UNTERMINATED); + if (frameClamped) frameStatus = "incomplete"; + if (discarded > 0) note(discardedLimitation(discarded)); + if (indirectFdes > 0) fail(indirectFdeLimitation(indirectFdes)); + if (rejectedFdes > 0) fail(unknownAugmentationLimitation(rejectedFdes)); + if (datarelMissing) fail(DATAREL_UNRESOLVED); + else if (datarelUsed && gotBase !== null) + note( + `DW_EH_PE_datarel .eh_frame values were resolved against the GOT base from ${gotBase.source}.`, + ); + if (frameSource === "program" && frameLocation !== null) { + frameLocation = { + offset: frameLocation.offset, + bytes: Math.max(0, Math.min(cursor, extent) - frameLocation.offset), + }; + } + } + + const symbols = scanSymbols( + bytes, + directory, + new Set(ranges.map((range) => range.startValue)), + signal, + { fail, note }, + ); + const relocatable = directory.elf_type === 1; + if (relocatable) + fail( + "Relocations are not applied. Relocatable symbol values use the section address plus the symbol value.", + ); + const zeroAddresses = + relocatable && + directory.sections.every((section) => section.address === 0n); + if (zeroAddresses && ranges.length > 0) + fail( + "Function ranges could not be placed in a section because every section address is zero.", + ); + const containingSection = zeroAddresses + ? null + : containingSectionIndex(directory.sections); + const withSymbols = ranges.filter((range) => + symbols.has(range.startValue), + ).length; + const decodeName = symbolNameDecoder(); + // Names are decoded only for the returned page. + const pageRows = ( + input.offset >= ranges.length + ? [] + : ranges.slice(input.offset, input.offset + input.limit) + ).map((range) => { + const symbol = symbols.get(range.startValue); + const section = containingSection?.(range.startValue) ?? null; + return { + index: range.index, + start: range.start, + end: range.end, + size: range.size, + section: + section?.name === null || + section?.name === undefined || + section.name.length === 0 + ? null + : { text: section.name, trust: "untrusted" as const }, + symbol: + symbol === undefined + ? null + : { text: decodeName(symbol), trust: "untrusted" as const }, + cie_index: range.cie_index, + lsda: range.lsda, + personality: range.personality, + location: range.location, + }; + }); + const pageCieIndexes = new Set(pageRows.map((range) => range.cie_index)); + const pageCies = cies.filter((cie) => pageCieIndexes.has(cie.index)); + const agreement = headerAgreement( + header, + ranges, + ehSection === undefined + ? undefined + : (vaForFileOffset(Number(ehSection.offset)) ?? undefined), + note, + ); + + if (header.status === "absent" && frameStatus !== "unknown") + note(HEADER_ABSENT); + note(CFA_LIMITATION); + if (debugFrame.status === "present") note(DEBUG_PRESENT); + else if (debugFrame.status === "absent") note(DEBUG_ABSENT); + else if (debugFrame.status === "unknown") note(DEBUG_UNKNOWN); + note(UNTRUSTED_LIMITATION); + if (sawIndirect) note(INDIRECT_LIMITATION); + + const returned: ElfUnwindRanges = { + artifact, + format: bits === 64 ? "elf64" : "elf32", + byte_order: little ? "little" : "big", + machine: directory.machine, + type: directory.elf_type, + type_name: elfObjectTypeName(directory.elf_type), + eh_frame: { + status: frameStatus, + source: frameSource, + location: frameLocation, + }, + eh_frame_hdr: { + status: header.status, + location: header.location, + version: header.version, + eh_frame_pointer_encoding: header.eh_frame_pointer_encoding, + fde_count_encoding: header.fde_count_encoding, + table_encoding: header.table_encoding, + eh_frame_pointer: header.eh_frame_pointer, + fde_count: header.fde_count, + table_entries: header.table_entries, + }, + debug_frame: debugFrame, + // Only CIEs that this page's ranges name, so the page size bounds output. + cies: pageCies.map((cie) => ({ + index: cie.index, + version: cie.version, + augmentation: { + text: cie.augmentation.toString("latin1"), + trust: "untrusted" as const, + }, + code_alignment: cie.code_alignment, + data_alignment: cie.data_alignment, + return_address_register: cie.return_address_register, + pointer_encoding: cie.pointer_encoding, + lsda_encoding: cie.lsda_encoding, + personality: cie.personality, + signal_frame: cie.signal_frame, + b_key: cie.b_key, + instruction_bytes: cie.instruction_bytes, + location: cie.location, + })), + ranges: pageRows, + page: { + offset: input.offset, + limit: input.limit, + returned: pageRows.length, + total: ranges.length, + next_offset: + input.offset + pageRows.length >= ranges.length + ? null + : input.offset + pageRows.length, + }, + coverage: { + status: incomplete ? "incomplete" : "complete", + total_ranges: ranges.length, + total_cies: cies.length, + ranges_with_symbols: withSymbols, + ranges_without_symbols: ranges.length - withSymbols, + eh_frame_hdr_agreement: agreement, + examined_records: examined, + }, + limitations, + }; + return returned; +}; + +const fileRange = (section: NativeSection): FileRange | null => { + if ( + section.offset < 0n || + section.size < 0n || + section.offset > 0xffffffffn || + section.size > 0xffffffffn + ) + return null; + return { offset: Number(section.offset), bytes: Number(section.size) }; +}; + +/** + * GOT base for `.eh_frame` DW_EH_PE_datarel values: DT_PLTGOT, then the + * `.got.plt` address, then the `.got` address. Null when none is declared. + */ +const resolveGotBase = ( + bytes: Buffer, + directory: ReturnType, + named: (name: string) => NativeSection[], +): { readonly address: bigint; readonly source: string } | null => { + const dynamicProgram = directory.programs.find( + (program) => program.type === 2, + ); + const dynamic = + (dynamicProgram === undefined + ? undefined + : { offset: dynamicProgram.offset, fileSize: dynamicProgram.size }) ?? + directory.sections + .filter((section) => section.type === SHT_DYNAMIC && section.file_backed) + .map((section) => ({ + offset: section.offset, + fileSize: section.size, + }))[0]; + if (dynamic !== undefined) { + const start = dynamic.offset; + const end = dynamic.offset + dynamic.fileSize; + if (start >= 0n && end <= BigInt(bytes.length)) { + const limit = Number(end); + const width = directory.bits === 64 ? 16 : 8; + for ( + let at = Number(start), index = 0; + at + width <= limit && index < MAX_DYNAMIC_ENTRIES; + at += width, index += 1 + ) { + const entry = readElfDynamicEntry( + bytes.subarray(at, at + width), + directory.bits, + directory.little, + ); + if (!entry.ok || entry.value.tag === DT_NULL) break; + if (entry.value.tag === DT_PLTGOT && entry.value.value !== 0n) + return { address: entry.value.value, source: "DT_PLTGOT" }; + } + } + } + for (const name of [".got.plt", ".got"]) { + const section = named(name).find( + (candidate) => (candidate.flags & SHF_ALLOC) !== 0n, + ); + if (section !== undefined && section.address !== 0n) + return { address: section.address, source: `the ${name} section` }; + } + return null; +}; + +interface ProgramSpan { + readonly flags: number; + readonly offset: bigint; + readonly virtualAddress: bigint; + readonly fileSize: bigint; +} + +const programSpan = ( + program: Exclude, +): ProgramSpan => ({ + flags: program.flags, + offset: program.file_offset, + virtualAddress: program.virtual_address, + fileSize: program.file_size, +}); + +const emptyHeader = ( + status: HeaderDecode["status"], + location: FileRange | null, +): HeaderDecode => ({ + status, + location, + version: null, + eh_frame_pointer_encoding: null, + fde_count_encoding: null, + table_encoding: null, + eh_frame_pointer: null, + ehFramePointer: null, + fde_count: null, + table_entries: 0, + rows: [], + decoded: false, +}); + +const decodeHeader = ( + location: FileRange, + datarel: bigint | null, + textrel: bigint | null, + readEncoded: ReadEncoded, + readU8: (offset: number, end: number) => number | null, + safeInt: (value: bigint) => number | null, + hex: (value: bigint) => string, + fileOffsetForVa: (address: bigint) => number | null, + fail: (text: string) => void, + signal: AbortSignal | undefined, + limits: { readonly maxRows: number; readonly addressSize: 4 | 8 }, +): HeaderDecode => { + const end = location.offset + location.bytes; + const version = readU8(location.offset, end); + const ptrEnc = readU8(location.offset + 1, end); + const countEnc = readU8(location.offset + 2, end); + const tableEnc = readU8(location.offset + 3, end); + const base = { + status: "incomplete" as const, + location, + version, + eh_frame_pointer_encoding: + ptrEnc === null + ? null + : { raw: ptrEnc, name: elfEhPointerEncodingName(ptrEnc) }, + fde_count_encoding: + countEnc === null + ? null + : { raw: countEnc, name: elfEhPointerEncodingName(countEnc) }, + table_encoding: + tableEnc === null + ? null + : { raw: tableEnc, name: elfEhPointerEncodingName(tableEnc) }, + eh_frame_pointer: null, + ehFramePointer: null, + fde_count: null, + table_entries: 0, + rows: [], + decoded: false, + }; + if ( + version === null || + ptrEnc === null || + countEnc === null || + tableEnc === null + ) { + fail(".eh_frame_hdr is truncated."); + return base; + } + if (version !== 1) { + fail(`.eh_frame_hdr version ${String(version)} is not decoded.`); + return { ...base, version }; + } + const pointer = readEncoded(location.offset + 4, end, ptrEnc, { + datarel, + textrel, + funcrel: null, + lengthOnly: false, + }); + if (pointer === null || pointer === "omit") { + fail(".eh_frame_hdr eh_frame_ptr could not be decoded."); + return { ...base, version }; + } + // An indirect eh_frame_ptr names a pointer slot, not .eh_frame, and is not + // followed, so it locates nothing. + if (pointer.indirect) { + fail(INDIRECT_FRAME_POINTER); + return { ...base, version }; + } + // GNU ld writes omitted count and table encodings when it cannot build the + // binary-search table. That is a valid header with no table, not a failure. + if (countEnc === DW_EH_PE_OMIT) + return { + ...base, + status: "present", + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + }; + const countValue = readEncoded(pointer.next, end, countEnc, { + datarel, + textrel, + funcrel: null, + lengthOnly: (countEnc & 0x70) === 0, + }); + if (countValue === null || countValue === "omit" || countValue.address < 0n) { + fail(".eh_frame_hdr FDE count could not be decoded."); + return { + ...base, + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + }; + } + // A non-zero indirect count is the address of a slot holding the count. The + // slot is not followed, so the count, and with it the table, is unknown. + if ((countEnc & 0x80) !== 0 && countValue.address !== 0n) { + fail(INDIRECT_COUNT); + return { + ...base, + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + }; + } + if (countValue.address > 0xffffffffn) { + fail(".eh_frame_hdr FDE count does not fit in 32 bits."); + return { + ...base, + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + }; + } + const count = safeInt(countValue.address); + if (count === null || count < 0) { + fail(".eh_frame_hdr FDE count could not be decoded."); + return { + ...base, + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + }; + } + // An omitted table is a valid header without a binary-search table; libgcc + // then searches linearly. Test the sentinel before the indirect bit it sets. + if (tableEnc === DW_EH_PE_OMIT) + return { + ...base, + status: "present", + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + fde_count: count, + }; + // Indirect table entries name pointer slots, not starts or FDEs. They are + // not followed, so the table is neither compared nor used as a scan bound. + if ((tableEnc & 0x80) !== 0) { + fail(INDIRECT_TABLE); + return { + ...base, + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + fde_count: count, + }; + } + // The declared count is target-controlled: bound rows by the caller's + // max_ranges budget and by how many rows the section bytes can hold before + // allocating any, and report a capped table as incomplete. + const rowBytes = 2 * minimumEncodedBytes(tableEnc, limits.addressSize); + const fitting = Math.floor(Math.max(0, end - countValue.next) / rowBytes); + const readable = Math.min(count, limits.maxRows, fitting); + if (count > limits.maxRows) + fail( + `.eh_frame_hdr declares ${String(count)} FDEs, more than the max_ranges budget of ${String(limits.maxRows)}; only ${String(readable)} search-table rows were read and the table was not compared with the decoded FDEs.`, + ); + if (count > fitting) + fail( + `.eh_frame_hdr declares ${String(count)} FDEs but its bytes hold at most ${String(fitting)} search-table rows; the table is truncated.`, + ); + const rows: HeaderRow[] = []; + let cursor = countValue.next; + for (let index = 0; index < readable; index += 1) { + assertArtifactReadActive(signal); + const start = readEncoded(cursor, end, tableEnc, { + datarel, + textrel, + funcrel: null, + lengthOnly: false, + }); + if (start === null || start === "omit") { + fail(".eh_frame_hdr table is truncated."); + return { + ...base, + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + fde_count: count, + table_entries: rows.length, + rows, + }; + } + const fde = readEncoded(start.next, end, tableEnc, { + datarel, + textrel, + funcrel: null, + lengthOnly: false, + }); + if (fde === null || fde === "omit") { + fail(".eh_frame_hdr table is truncated."); + return { + ...base, + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + fde_count: count, + table_entries: rows.length, + rows, + }; + } + rows.push({ + start: hex(start.address), + fdeFileOffset: fileOffsetForVa(fde.address), + }); + cursor = fde.next; + } + if (readable < count) + return { + ...base, + version, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + fde_count: count, + table_entries: rows.length, + rows, + }; + return { + status: "present", + location, + version, + eh_frame_pointer_encoding: base.eh_frame_pointer_encoding, + fde_count_encoding: base.fde_count_encoding, + table_encoding: base.table_encoding, + eh_frame_pointer: hex(pointer.address), + ehFramePointer: pointer.address, + fde_count: count, + table_entries: rows.length, + rows, + decoded: true, + }; +}; + +/** Fewest bytes one DW_EH_PE value of this encoding can occupy. */ +const minimumEncodedBytes = (encoding: number, addressSize: 4 | 8): number => { + if (encoding === 0x50) return addressSize; + switch (encoding & 0x0f) { + case 0x00: + return addressSize; + case 0x02: + case 0x0a: + return 2; + case 0x03: + case 0x0b: + return 4; + case 0x04: + case 0x0c: + return 8; + default: + return 1; + } +}; + +const decodeCie = (args: { + readonly bytes: Buffer; + readonly recordStart: number; + readonly recordEnd: number; + readonly body: number; + readonly readU8: (offset: number, end: number) => number | null; + readonly readLeb: ( + offset: number, + end: number, + signed: boolean, + ) => { readonly value: bigint; readonly next: number } | null; + readonly readEncoded: ReadEncoded; + readonly safeInt: (value: bigint) => number | null; + readonly encodingOf: (raw: number) => Encoding; + readonly datarel: bigint | null; + readonly textrel: bigint | null; + readonly fail: (text: string) => void; +}): Omit | "unknown-augmentation" | null => { + const { + bytes, + recordStart, + recordEnd, + body, + readU8, + readLeb, + readEncoded, + safeInt, + encodingOf, + datarel, + textrel, + fail, + } = args; + const version = readU8(body, recordEnd); + if (version === null) { + fail("A CIE record is truncated."); + return null; + } + let cursor = body + 1; + // One bounded NUL search inside the record. The text stays a view of the + // record bytes, so a long target-controlled augmentation is never built up + // character by character. + const terminator = bytes.subarray(cursor, recordEnd).indexOf(0); + const augmentation = bytes.subarray( + cursor, + terminator < 0 ? recordEnd : cursor + terminator, + ); + cursor = terminator < 0 ? recordEnd : cursor + terminator + 1; + if (version !== 1 && version !== 3) { + fail(`CIE version ${String(version)} is not decoded.`); + return null; + } + const code = readLeb(cursor, recordEnd, false); + if (code === null) { + fail("A CIE record is truncated."); + return null; + } + const data = readLeb(code.next, recordEnd, true); + if (data === null) { + fail("A CIE record is truncated."); + return null; + } + const codeAlignment = safeInt(code.value); + const dataAlignment = safeInt(data.value); + if (codeAlignment === null || codeAlignment < 0 || dataAlignment === null) { + fail("A CIE alignment does not fit an integer."); + return null; + } + cursor = data.next; + let returnAddress: number | null; + if (version === 1) { + returnAddress = readU8(cursor, recordEnd); + cursor += 1; + } else { + const register = readLeb(cursor, recordEnd, false); + if (register === null) { + fail("A CIE record is truncated."); + return null; + } + returnAddress = safeInt(register.value); + cursor = register.next; + } + if (returnAddress === null || returnAddress < 0) { + fail("A CIE return-address register does not fit an integer."); + return null; + } + const hasZ = augmentation[0] === AUG_Z; + let augEnd = cursor; + if (hasZ) { + const length = readLeb(cursor, recordEnd, false); + if (length === null) { + fail("A CIE augmentation length is truncated."); + return null; + } + const augLength = safeInt(length.value); + if ( + augLength === null || + augLength < 0 || + length.next + augLength > recordEnd + ) { + fail("A CIE augmentation length is truncated."); + return null; + } + cursor = length.next; + augEnd = cursor + augLength; + } + let lsda: Encoding | null = null; + let personality: Personality | null = null; + let pointer = encodingOf(0); + let signal = false; + let bKey = false; + const letters = hasZ ? augmentation.subarray(1) : augmentation; + for (const letter of letters) { + if (letter === AUG_L) { + const raw = readU8(cursor, hasZ ? augEnd : recordEnd); + if (raw === null) { + fail("A CIE augmentation is truncated."); + return null; + } + lsda = encodingOf(raw); + cursor += 1; + } else if (letter === AUG_P) { + const raw = readU8(cursor, hasZ ? augEnd : recordEnd); + if (raw === null) { + fail("A CIE augmentation is truncated."); + return null; + } + const encoded = readEncoded(cursor + 1, hasZ ? augEnd : recordEnd, raw, { + datarel, + textrel, + funcrel: null, + lengthOnly: false, + }); + if (encoded === null || encoded === "omit") { + fail("A CIE personality pointer could not be decoded."); + return null; + } + personality = { + address: `0x${encoded.address.toString(16)}`, + encoding: encodingOf(raw), + indirect: encoded.indirect, + }; + cursor = encoded.next; + } else if (letter === AUG_R) { + const raw = readU8(cursor, hasZ ? augEnd : recordEnd); + if (raw === null) { + fail("A CIE augmentation is truncated."); + return null; + } + pointer = encodingOf(raw); + cursor += 1; + } else if (letter === AUG_S) signal = true; + else if (letter === AUG_B) bKey = true; + else { + // The augmentation data length would let a z CIE skip unknown data, but + // the encodings of letters after an unknown one cannot be located, so + // FDEs are never decoded against this CIE (libgcc rejects it too). + fail(UNKNOWN_AUGMENTATION); + return "unknown-augmentation"; + } + } + const instructionStart = hasZ ? augEnd : cursor; + if (instructionStart > recordEnd) { + fail("A CIE augmentation is truncated."); + return null; + } + const instructionBytes = recordEnd - instructionStart; + if (instructionBytes > 0xffffffff) { + fail("A CIE instruction block does not fit a file range."); + return null; + } + return { + version, + augmentation, + code_alignment: codeAlignment, + data_alignment: dataAlignment, + return_address_register: returnAddress, + pointer_encoding: pointer, + lsda_encoding: lsda, + personality, + signal_frame: signal, + b_key: bKey, + instruction_bytes: instructionBytes, + location: { offset: recordStart, bytes: recordEnd - recordStart }, + hasZ, + }; +}; + +const decodeFde = (args: { + readonly recordStart: number; + readonly recordEnd: number; + readonly body: number; + readonly cie: CieRecord; + readonly readLeb: ( + offset: number, + end: number, + signed: boolean, + ) => { readonly value: bigint; readonly next: number } | null; + readonly readEncoded: ReadEncoded; + readonly hex: (value: bigint) => string; + readonly safeInt: (value: bigint) => number | null; + readonly datarel: bigint | null; + readonly textrel: bigint | null; + readonly addressBits: 32 | 64; + readonly fail: (text: string) => void; +}): + | Omit + | "discarded" + | "indirect" + | null => { + const { + recordStart, + recordEnd, + body, + cie, + readLeb, + readEncoded, + hex, + safeInt, + datarel, + textrel, + addressBits, + fail, + } = args; + const bases = { + datarel, + textrel, + funcrel: null, + lengthOnly: false as const, + }; + const start = readEncoded(body, recordEnd, cie.pointer_encoding.raw, bases); + if (start === null || start === "omit") { + fail("An FDE initial location could not be decoded."); + return null; + } + // A zero initial location marks an FDE whose function the linker discarded + // (libgcc skips these); it does not name a function. + if (start.address === 0n) return "discarded"; + // An indirect initial location is the address of a pointer slot. The slot + // is not followed, so the function start is unknown. + if (start.indirect) return "indirect"; + // address_range uses only the value format. It is not PC-relative. + const length = readEncoded( + start.next, + recordEnd, + cie.pointer_encoding.raw & 0x0f, + { ...bases, lengthOnly: true }, + ); + if (length === null || length === "omit") { + fail("An FDE address range could not be decoded."); + return null; + } + if (length.address < 0n) { + fail("An FDE address range is negative."); + return null; + } + let cursor = length.next; + let lsda: FunctionRange["lsda"] = null; + if (cie.hasZ) { + const aug = readLeb(cursor, recordEnd, false); + if (aug === null) { + fail("An FDE augmentation length is truncated."); + return null; + } + const augLength = safeInt(aug.value); + if ( + augLength === null || + augLength < 0 || + aug.next + augLength > recordEnd + ) { + fail("An FDE augmentation length is truncated."); + return null; + } + const augEnd = aug.next + augLength; + if (cie.lsda_encoding !== null && cie.lsda_encoding.raw !== 0xff) { + const encoded = readEncoded(aug.next, augEnd, cie.lsda_encoding.raw, { + datarel, + textrel, + funcrel: start.address, + lengthOnly: false, + }); + if (encoded === null || encoded === "omit") { + fail("An FDE LSDA pointer could not be decoded."); + return null; + } + // A zero LSDA pointer means the FDE has no LSDA. + if (encoded.address !== 0n) + lsda = { address: hex(encoded.address), indirect: encoded.indirect }; + } + cursor = augEnd; + } + if (cursor > recordEnd) { + fail("An FDE record is truncated."); + return null; + } + // pc_begin + pc_range wraps at the address width, as in libgcc. + const end = BigInt.asUintN(addressBits, start.address + length.address); + return { + start: hex(start.address), + end: hex(end), + size: length.address.toString(), + cie_index: cie.index, + lsda, + personality: + cie.personality === null + ? null + : { + address: cie.personality.address, + indirect: cie.personality.indirect, + }, + location: { offset: recordStart, bytes: recordEnd - recordStart }, + startValue: start.address, + }; +}; + +/** A readable, non-empty symbol name still held as string-table bytes. */ +type SymbolName = Extract; + +/** + * Exact-start symbol names for the FDE starts in `wanted`. Each name stays a + * view of the string table: text is decoded later, only for returned rows. + * Observations are cached per table and name offset, so a name shared by many + * symbols is scanned once and decoded at most once. + */ +const scanSymbols = ( + bytes: Buffer, + directory: ReturnType, + wanted: ReadonlySet, + signal: AbortSignal | undefined, + report: { + readonly fail: (text: string) => void; + readonly note: (text: string) => void; + }, +): Map => { + const { fail, note } = report; + const found = new Map< + bigint, + { + readonly name: SymbolName; + readonly score: number; + readonly ifunc: boolean; + } + >(); + const tables = directory.sections.filter( + (section) => section.type === SHT_SYMTAB || section.type === SHT_DYNSYM, + ); + const standard = directory.bits === 64 ? 24 : 16; + // First section per index, matching a directory-order find. + const byIndex = new Map(); + for (const section of directory.sections) + if (!byIndex.has(section.index)) byIndex.set(section.index, section); + // One budget across .symtab and .dynsym together. + let remaining = MAX_ELF_UNWIND_SYMBOLS; + for (const table of tables) { + assertArtifactReadActive(signal); + const width = table.entry_size === 0n ? standard : Number(table.entry_size); + if (width !== standard) { + fail( + "Symbol table entry width is not the ELF32/ELF64 symbol size; that table was skipped.", + ); + continue; + } + if (!table.file_backed || table.size <= 0n) { + fail("A symbol table is declared but not file-backed."); + continue; + } + const start = Number(table.offset); + const size = Number(table.size); + if (size % width !== 0) + fail("A symbol table size is not a multiple of its entry width."); + let count = Math.floor(size / width); + if (count > remaining) { + fail( + `Symbol tables were capped at ${String(MAX_ELF_UNWIND_SYMBOLS)} combined entries.`, + ); + count = remaining; + } + remaining -= count; + const strings = byIndex.get(table.link); + const stringBytes = + strings !== undefined && strings.file_backed + ? bytes.subarray( + Number(strings.offset), + Number(strings.offset + strings.size), + ) + : null; + if (stringBytes === null) fail("A symbol string table could not be read."); + // Observation per string-table offset; null when the name is unreadable. + const names = new Map(); + for (let index = 0; index < count; index += 1) { + assertArtifactReadActive(signal); + const parsed = readElfSymbol( + bytes.subarray(start + index * width, start + (index + 1) * width), + directory.bits, + directory.little, + ); + if (!parsed.ok) { + fail("A symbol record is truncated."); + break; + } + const symbol = parsed.value; + if ( + symbol.type !== STT_FUNC && + symbol.type !== STT_GNU_IFUNC && + symbol.type !== STT_NOTYPE + ) + continue; + if (symbol.section_index === SHN_UNDEF) continue; + if ( + symbol.section_index >= SHN_LORESERVE && + symbol.section_index !== SHN_ABS + ) + continue; + let address = symbol.value; + if (directory.elf_type === 1 && symbol.section_index !== SHN_ABS) { + const owner = byIndex.get(symbol.section_index); + if (owner !== undefined) address += owner.address; + } + // Only a symbol at a decoded FDE start can name a range. + if (stringBytes === null || !wanted.has(address)) continue; + let name = names.get(symbol.name_offset); + if (name === undefined) { + const observed = observeElfStringTableEntry( + stringBytes, + symbol.name_offset, + 65536, + ); + name = observed.state === "terminated" ? observed : null; + names.set(symbol.name_offset, name); + } + if (name === null) { + fail("A symbol name could not be read."); + continue; + } + if (name.contentBytes === 0) continue; + // A function symbol is preferred, then an IFUNC resolver, then an + // untyped label; .symtab breaks ties. + const score = + (symbol.type === STT_FUNC ? 4 : symbol.type === STT_GNU_IFUNC ? 2 : 0) + + (table.type === SHT_SYMTAB ? 1 : 0); + const previous = found.get(address); + if (previous === undefined || score > previous.score) + found.set(address, { + name, + score, + ifunc: symbol.type === STT_GNU_IFUNC, + }); + } + } + if ([...found.values()].some((item) => item.ifunc)) note(IFUNC_NAMES); + return new Map([...found].map(([address, item]) => [address, item.name])); +}; + +/** + * Decode returned symbol names once each, charging their text against a fixed + * allowance before converting it. + */ +const symbolNameDecoder = (): ((name: SymbolName) => string) => { + const decoded = new Map(); + let charged = 0; + return (name) => { + const cached = decoded.get(name); + if (cached !== undefined) return cached; + charged += name.contentBytes; + if (charged > MAX_ELF_UNWIND_SYMBOL_TEXT_BYTES) + throw new ElfUnwindSymbolTextFailure(); + const text = elfStringTableText(name) ?? ""; + decoded.set(name, text); + return text; + }; +}; + +/** + * Index of the SHF_ALLOC section that owns each address: SHF_EXECINSTR first, + * then the smallest section, then the lowest index. Non-ALLOC sections such as + * .comment or .debug_* are not loaded, so they never contain a function. + * Built once per image. + */ +const containingSectionIndex = ( + sections: readonly NativeSection[], +): ((address: bigint) => NativeSection | undefined) => + buildIntervalIndex( + sections + .filter( + (section) => section.size > 0n && (section.flags & SHF_ALLOC) !== 0n, + ) + .map((section) => ({ + low: section.address, + high: section.address + section.size, + value: section, + })), + (left, right) => { + const leftExec = (left.flags & SHF_EXECINSTR) !== 0n; + const rightExec = (right.flags & SHF_EXECINSTR) !== 0n; + if (leftExec !== rightExec) return leftExec ? -1 : 1; + if (left.size !== right.size) return left.size < right.size ? -1 : 1; + return left.index - right.index; + }, + ); + +const headerAgreement = ( + header: HeaderDecode, + ranges: readonly { readonly start: string; readonly location: FileRange }[], + sectionAddress: bigint | undefined, + note: (text: string) => void, +): "agree" | "disagree" | "unavailable" => { + // The only owner of the eh_frame_ptr check; it also runs for a header + // whose search table was not fully decoded. + const pointerMismatch = + sectionAddress !== undefined && + header.ehFramePointer !== null && + header.ehFramePointer !== sectionAddress; + if (pointerMismatch) note(POINTER_MISMATCH); + if (!header.decoded || header.status !== "present") return "unavailable"; + // Exact row-for-FDE agreement: equal start multisets, and every table row + // names a distinct decoded FDE with that same start. + const sorted = (values: readonly string[]): string[] => + values + .map((value) => BigInt(value)) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .map((value) => value.toString(16)); + const starts = sorted(ranges.map((range) => range.start)); + const tableStarts = sorted(header.rows.map((row) => row.start)); + const sameStarts = + starts.length === tableStarts.length && + starts.every((start, index) => start === tableStarts[index]); + const fdeStarts = new Map( + ranges.map((range) => [range.location.offset, range.start] as const), + ); + const claimed = new Set(); + const pointersLand = header.rows.every((row) => { + if (row.fdeFileOffset === null || claimed.has(row.fdeFileOffset)) + return false; + claimed.add(row.fdeFileOffset); + return fdeStarts.get(row.fdeFileOffset) === row.start; + }); + if (sameStarts && pointersLand && !pointerMismatch) return "agree"; + note(HEADER_MISMATCH); + return "disagree"; +}; diff --git a/src/native/metadata/NativeSectionDirectory.test.ts b/src/native/metadata/NativeSectionDirectory.test.ts index df71988a9d..27f03116e7 100644 --- a/src/native/metadata/NativeSectionDirectory.test.ts +++ b/src/native/metadata/NativeSectionDirectory.test.ts @@ -1,7 +1,9 @@ import { expect, it } from "vitest"; import { pe } from "../../domain/binaryTarget.fixture.js"; import { elfHardeningFixture } from "../hardening/BinaryHardening.fixture.js"; +import { elfUnwindSectionlessFixture } from "../elf/ElfUnwindTables.fixture.js"; import { + ELF_PT_GNU_EH_FRAME, ELF_PT_GNU_RELRO, ELF_PT_GNU_STACK, ELF_PT_INTERP, @@ -64,6 +66,22 @@ it("admits PT_INTERP, PT_GNU_STACK and PT_GNU_RELRO program headers", () => { ); }); +it("retains PT_GNU_EH_FRAME with its file and address span", () => { + const bytes = elfUnwindSectionlessFixture(); + const directory = readElfDirectoryFromBuffer(bytes); + expect(directory.programs.map((program) => program.type)).toEqual([ + 1, + ELF_PT_GNU_EH_FRAME, + ]); + expect(directory.programs[1]).toMatchObject({ + type: ELF_PT_GNU_EH_FRAME, + header_offset: 64 + 56, + file_offset: bytes.readBigUInt64LE(64 + 56 + 8), + virtual_address: bytes.readBigUInt64LE(64 + 56 + 16), + file_size: bytes.readBigUInt64LE(64 + 56 + 32), + }); +}); + it("retains a confirmed overlapping PE when the COFF record extends past EOF", () => { const bytes = Buffer.alloc(64); bytes.write("MZ", 0, "ascii"); diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts index 14d1f08ccf..d5c39e8443 100644 --- a/src/native/metadata/NativeSectionDirectory.ts +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -47,9 +47,10 @@ export interface NativeSection { readonly alignment: bigint; readonly file_backed: boolean; } -/** Program header types retained for load, dynamic, and hardening consumers. */ +/** Program header types retained for load, dynamic, hardening, and unwind consumers. */ export const ELF_PT_NOTE = 4 as const; export const ELF_PT_INTERP = 3 as const; +export const ELF_PT_GNU_EH_FRAME = 0x6474e550 as const; export const ELF_PT_GNU_STACK = 0x6474e551 as const; export const ELF_PT_GNU_RELRO = 0x6474e552 as const; export const ELF_PT_GNU_PROPERTY = 0x6474e553 as const; @@ -70,6 +71,7 @@ export type ElfProgramDeclaration = | ({ readonly type: 2; readonly header_offset: number } & ElfDynamicSegment) | ({ readonly type: typeof ELF_PT_NOTE } & ElfProgramSpan) | ({ readonly type: typeof ELF_PT_INTERP } & ElfProgramSpan) + | ({ readonly type: typeof ELF_PT_GNU_EH_FRAME } & ElfProgramSpan) | ({ readonly type: typeof ELF_PT_GNU_STACK } & ElfProgramSpan) | ({ readonly type: typeof ELF_PT_GNU_RELRO } & ElfProgramSpan) | ({ readonly type: typeof ELF_PT_GNU_PROPERTY } & ElfProgramSpan); @@ -184,6 +186,8 @@ export function* readElfSectionDirectory( programDeclarations.push({ type: ELF_PT_NOTE, ...span }); else if (type === ELF_PT_INTERP) programDeclarations.push({ type: ELF_PT_INTERP, ...span }); + else if (type === ELF_PT_GNU_EH_FRAME) + programDeclarations.push({ type: ELF_PT_GNU_EH_FRAME, ...span }); else if (type === ELF_PT_GNU_STACK) programDeclarations.push({ type: ELF_PT_GNU_STACK, ...span }); else if (type === ELF_PT_GNU_RELRO) diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 6399f3f443..c2ef9c3755 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -19,6 +19,7 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { captureProviderEnvironment } from "./providerEnvironment.js"; import { createByteEntropyService } from "../composition/binaryDiagnostics.js"; import { registerElfNotesTool } from "./registerElfNotesTool.js"; +import { registerElfUnwindRangesTool } from "./registerElfUnwindRangesTool.js"; import { registerAppleIpsTool } from "./registerAppleIpsTool.js"; import { registerKernelModuleTool } from "./registerKernelModuleTool.js"; import { registerKernelBuildConfigTool } from "./registerKernelBuildConfigTool.js"; @@ -220,6 +221,7 @@ export const createServer = ( nativeExports, devicetree, elfNotes, + elfUnwindRanges, appleIps, kernelModule, kernelBuildConfig, @@ -501,6 +503,13 @@ export const createServer = ( recordEvidence, analysisAdmission, ); + registerElfUnwindRangesTool( + server, + elfUnwindRanges, + toolLogger, + recordEvidence, + analysisAdmission, + ); registerAppleIpsTool( server, appleIps, diff --git a/src/server/registerElfUnwindRangesTool.ts b/src/server/registerElfUnwindRangesTool.ts new file mode 100644 index 0000000000..88ae7fb95e --- /dev/null +++ b/src/server/registerElfUnwindRangesTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { ElfUnwindRangesService } from "../application/binaryDiagnostics/ElfUnwindRangesService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the explicit-file ELF unwind-range workflow and retain its complete Evidence. */ +export const registerElfUnwindRangesTool = ( + server: EvidenceMcpServer, + service: ElfUnwindRangesService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_elf_unwind_ranges"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/elfUnwindRangesCli.test.ts b/tests/acceptance/elfUnwindRangesCli.test.ts new file mode 100644 index 0000000000..2cfb6e1397 --- /dev/null +++ b/tests/acceptance/elfUnwindRangesCli.test.ts @@ -0,0 +1,144 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { + UNWIND_FN_A, + UNWIND_FN_B, + UNWIND_LSDA, + elfUnwindFixture, +} from "../../src/native/elf/ElfUnwindTables.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const address = (value: number): string => `0x${value.toString(16)}`; + +cliTest( + "reports two unwind ranges, a symbol, an LSDA, and a header mismatch", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-unwind-cli-"); + const path = join(root, "program"); + await writeFile(path, elfUnwindFixture()); + const result = await cli.run({ + arguments: ["inspect-elf-unwind-ranges", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract( + "inspect_elf_unwind_ranges", + ).outputSchema.parse(result.json); + const report = evidence.normalized_result; + expect(report.ranges).toHaveLength(2); + expect(report.ranges[0]).toMatchObject({ + start: address(UNWIND_FN_A), + symbol: { text: "alpha", trust: "untrusted" }, + lsda: null, + }); + expect(report.ranges[1]).toMatchObject({ + start: address(UNWIND_FN_B), + symbol: null, + lsda: { address: address(UNWIND_LSDA), indirect: false }, + }); + expect(report.coverage).toMatchObject({ + status: "complete", + total_ranges: 2, + ranges_with_symbols: 1, + ranges_without_symbols: 1, + eh_frame_hdr_agreement: "disagree", + }); + expect(report.limitations).toContain( + "The .eh_frame_hdr binary search table does not match the decoded FDEs.", + ); + }, +); + +cliTest( + "resolves a relative ELF path against the working directory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-unwind-cli-rel-"); + await writeFile(join(root, "program"), elfUnwindFixture()); + const result = await cli.run({ + arguments: ["inspect-elf-unwind-ranges", "program", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract( + "inspect_elf_unwind_ranges", + ).outputSchema.parse(result.json); + expect(evidence.subject?.local_path).toBe(join(root, "program")); + expect(evidence.normalized_result.coverage.total_ranges).toBe(2); + }, +); + +cliTest("reports a non-ELF file as invalid input", async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-unwind-cli-bad-"); + const path = join(root, "broken"); + await writeFile(path, Buffer.from("not an elf")); + const result = await cli.run({ + arguments: ["inspect-elf-unwind-ranges", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_elf_unwind_ranges" }, + }); +}); + +cliTest("pages unwind ranges with --offset and --limit", async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-unwind-cli-page-"); + const path = join(root, "program"); + await writeFile(path, elfUnwindFixture()); + const result = await cli.run({ + arguments: [ + "inspect-elf-unwind-ranges", + path, + "--json", + "--offset", + "1", + "--limit", + "1", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_elf_unwind_ranges").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.ranges.map((range) => range.index)).toEqual( + [1], + ); + expect(evidence.normalized_result.ranges[0]?.start).toBe( + address(UNWIND_FN_B), + ); + expect(evidence.normalized_result.page).toEqual({ + offset: 1, + limit: 1, + returned: 1, + total: 2, + next_offset: null, + }); + expect(evidence.normalized_result.coverage.total_ranges).toBe(2); +}); + +cliTest("decodes an ELF32 big-endian unwind table", async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-unwind-cli-be-"); + const path = join(root, "program"); + await writeFile( + path, + elfUnwindFixture({ bits: 32, little: false, machine: 20, header: "match" }), + ); + const result = await cli.run({ + arguments: ["inspect-elf-unwind-ranges", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_elf_unwind_ranges").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.format).toBe("elf32"); + expect(evidence.normalized_result.byte_order).toBe("big"); + expect(evidence.normalized_result.machine).toBe(20); + expect(evidence.normalized_result.ranges.map((range) => range.start)).toEqual( + [address(UNWIND_FN_A), address(UNWIND_FN_B)], + ); + expect(evidence.normalized_result.coverage.eh_frame_hdr_agreement).toBe( + "agree", + ); +}); diff --git a/tests/acceptance/localPathAdmissionCli.test.ts b/tests/acceptance/localPathAdmissionCli.test.ts index 4f012e698d..2c78ab2692 100644 --- a/tests/acceptance/localPathAdmissionCli.test.ts +++ b/tests/acceptance/localPathAdmissionCli.test.ts @@ -16,6 +16,7 @@ const fileCommands = [ "decode-signal-recording", "inspect-model-artifact", "inspect-elf-notes", + "inspect-elf-unwind-ranges", "inspect-kernel-module", "inspect-kernel-build-config", "inspect-byte-entropy", diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index b0eb57585b..3b4270ae73 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -250,6 +250,7 @@ const pathRequests: readonly [string, Record][] = [ ["inspect_windows_shortcut", { path: nul }], ["inspect_native_exports", { path: nul }], ["inspect_elf_notes", { path: nul }], + ["inspect_elf_unwind_ranges", { path: nul }], ["inspect_apple_ips", { path: nul }], ["inspect_devicetree", { path: nul }], ["inspect_kernel_module", { path: nul }], diff --git a/tests/boundary/filesystem/elfUnwindRanges.test.ts b/tests/boundary/filesystem/elfUnwindRanges.test.ts new file mode 100644 index 0000000000..0cc1a78519 --- /dev/null +++ b/tests/boundary/filesystem/elfUnwindRanges.test.ts @@ -0,0 +1,144 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createElfUnwindRangesService } from "../../../src/composition/binaryDiagnostics.js"; +import { elfUnwindFixture } from "../../../src/native/elf/ElfUnwindTables.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("ELF unwind range stable filesystem boundary", () => { + it("reports non-file and malformed ELF selections as caller input errors", async () => { + const root = await createTestTempDirectory("rea-elf-unwind-files-"); + const path = join(root, "program"); + const alias = join(root, "alias"); + const malformed = join(root, "malformed"); + await writeFile(path, elfUnwindFixture()); + await writeFile(malformed, Buffer.from("not an elf")); + await symlink(path, alias); + const service = createElfUnwindRangesService(); + try { + for (const [candidate, reason] of [ + [root, "invalid_format"], + [alias, "invalid_format"], + [malformed, "invalid_format"], + [join(root, "missing"), "invalid_value"], + [join(path, "child"), "invalid_value"], + ] as const) { + const result = await service.inspect({ path: candidate }); + if (result.ok) throw new Error("Expected file acquisition failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + operation: "inspect_elf_unwind_ranges", + issues: [ + { + path: ["path"], + reason, + message: expect.any(String), + }, + ], + }); + } + } finally { + await service.close(); + } + }); + + it("accepts a header whose search table is omitted, as GNU ld writes it", async () => { + const root = await createTestTempDirectory("rea-elf-unwind-omit-"); + const path = join(root, "program"); + await writeFile( + path, + elfUnwindFixture({ header: "match", headerCount: "omit" }), + ); + const service = createElfUnwindRangesService(); + try { + const result = await service.inspect({ path }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + eh_frame_hdr: { status: "present", fde_count: null, table_entries: 0 }, + coverage: { status: "complete", eh_frame_hdr_agreement: "unavailable" }, + }); + } finally { + await service.close(); + } + }); + + it("returns an ELF32 range whose end wraps at the address width", async () => { + const root = await createTestTempDirectory("rea-elf-unwind-wrap-"); + const path = join(root, "program"); + await writeFile( + path, + elfUnwindFixture({ + bits: 32, + header: "match", + unsignedPcrel: { size: 0xffffff00 }, + }), + ); + const service = createElfUnwindRangesService(); + try { + const result = await service.inspect({ path }); + if (!result.ok) throw result.error; + const report = result.value.normalized_result as { + ranges: { start: string; end: string; size: string }[]; + }; + const first = report.ranges[0]; + if (first === undefined) throw new Error("Expected a range"); + expect(first.size).toBe(String(0xffffff00)); + expect(BigInt(first.end)).toBe( + BigInt.asUintN(32, BigInt(first.start) + 0xffffff00n), + ); + } finally { + await service.close(); + } + }); + + it("tells a caller over the symbol-name text allowance to request a smaller page", async () => { + const root = await createTestTempDirectory("rea-elf-unwind-names-"); + const path = join(root, "program"); + await writeFile( + path, + elfUnwindFixture({ header: "absent", suffixNames: 300 }), + ); + const service = createElfUnwindRangesService(); + try { + const result = await service.inspect({ path, limit: 300 }); + if (result.ok) throw new Error("Expected symbol-name text failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + retryable: false, + }); + const remediation = + "remediationAction" in result.error + ? result.error.remediationAction + : undefined; + expect(remediation).toContain("smaller limit than 300"); + expect(remediation).not.toContain("Raise max_ranges"); + const smaller = await service.inspect({ path, limit: 200 }); + expect(smaller.ok).toBe(true); + } finally { + await service.close(); + } + }); + + it("returns typed resource and cancellation failures", async () => { + const root = await createTestTempDirectory("rea-elf-unwind-budget-"); + const path = join(root, "program"); + await writeFile(path, elfUnwindFixture()); + const service = createElfUnwindRangesService(); + try { + const tooSmall = await service.inspect({ path, max_file_bytes: 64 }); + if (tooSmall.ok) throw new Error("Expected resource budget failure"); + expect(tooSmall.error._tag).toBe("AnalysisResourceConstraintError"); + const tooMany = await service.inspect({ path, max_ranges: 1 }); + if (tooMany.ok) throw new Error("Expected range budget failure"); + expect(tooMany.error._tag).toBe("AnalysisResourceConstraintError"); + const cancelled = await service.inspect( + { path }, + { signal: AbortSignal.abort() }, + ); + if (cancelled.ok) throw new Error("Expected cancellation"); + expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + } finally { + await service.close(); + } + }); +}); diff --git a/tests/boundary/mcp/absoluteLocalPathTools.test.ts b/tests/boundary/mcp/absoluteLocalPathTools.test.ts index e3011462bc..350eb3b6b9 100644 --- a/tests/boundary/mcp/absoluteLocalPathTools.test.ts +++ b/tests/boundary/mcp/absoluteLocalPathTools.test.ts @@ -26,6 +26,7 @@ const CONTRACTS = [ const PATH_FIELDS = { inspect_apple_ips: ["path"], inspect_elf_notes: ["path"], + inspect_elf_unwind_ranges: ["path"], inspect_kernel_module: ["path"], inspect_byte_entropy: ["path"], inspect_api_contract: ["path", "operations_path"], @@ -125,6 +126,7 @@ it("rejects one relative path field at a time on tools/list", async () => { expect(checked).toEqual([ "inspect_apple_ips.path", "inspect_elf_notes.path", + "inspect_elf_unwind_ranges.path", "inspect_kernel_module.path", "inspect_byte_entropy.path", "inspect_api_contract.path", diff --git a/tests/boundary/mcp/elfUnwindRangesMcp.test.ts b/tests/boundary/mcp/elfUnwindRangesMcp.test.ts new file mode 100644 index 0000000000..fecfee11a9 --- /dev/null +++ b/tests/boundary/mcp/elfUnwindRangesMcp.test.ts @@ -0,0 +1,155 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { + UNWIND_FN_A, + UNWIND_FN_B, + UNWIND_LSDA, + elfUnwindFixture, +} from "../../../src/native/elf/ElfUnwindTables.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; + +const address = (value: number): string => `0x${value.toString(16)}`; + +const connect = async (mcp: TestMcp) => { + const session = createTestBinarySession(() => { + throw new Error("ELF unwind inspection must not start a deep provider"); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + return { client, session }; +}; + +mcpTest( + "advertises inspect_elf_unwind_ranges and reads FDE ranges", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_elf_unwind_ranges", + ); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-elf-unwind-mcp-"); + const path = join(root, "program"); + await writeFile(path, elfUnwindFixture()); + const response = await client.callTool({ + name: "inspect_elf_unwind_ranges", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract( + "inspect_elf_unwind_ranges", + ).outputSchema.parse(response.structuredContent); + const report = evidence.normalized_result; + expect(report.format).toBe("elf64"); + expect(report.ranges[0]).toMatchObject({ + start: address(UNWIND_FN_A), + symbol: { text: "alpha", trust: "untrusted" }, + }); + expect(report.ranges[1]?.lsda).toEqual({ + address: address(UNWIND_LSDA), + indirect: false, + }); + expect(report.coverage.eh_frame_hdr_agreement).toBe("disagree"); + expect(report.limitations).toContain( + "CFA instructions are counted but not evaluated.", + ); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "requires an absolute path for inspect_elf_unwind_ranges", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const response = await client.callTool({ + name: "inspect_elf_unwind_ranges", + arguments: { path: "program" }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_elf_unwind_ranges" }, + }); + } finally { + await session.close(); + } + }, +); + +mcpTest("pages one unwind range over MCP", async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-elf-unwind-mcp-page-"); + const path = join(root, "program"); + await writeFile(path, elfUnwindFixture()); + const response = await client.callTool({ + name: "inspect_elf_unwind_ranges", + arguments: { path, offset: 1, limit: 1 }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract( + "inspect_elf_unwind_ranges", + ).outputSchema.parse(response.structuredContent); + expect( + evidence.normalized_result.ranges.map((range) => range.start), + ).toEqual([address(UNWIND_FN_B)]); + expect(evidence.normalized_result.page).toMatchObject({ + offset: 1, + limit: 1, + returned: 1, + total: 2, + next_offset: null, + }); + } finally { + await session.close(); + } +}); + +mcpTest( + "decodes an ELF32 big-endian unwind table over MCP", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-elf-unwind-mcp-be-"); + const path = join(root, "program"); + await writeFile( + path, + elfUnwindFixture({ + bits: 32, + little: false, + machine: 20, + header: "match", + }), + ); + const response = await client.callTool({ + name: "inspect_elf_unwind_ranges", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract( + "inspect_elf_unwind_ranges", + ).outputSchema.parse(response.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + format: "elf32", + byte_order: "big", + machine: 20, + }); + expect(evidence.normalized_result.coverage.eh_frame_hdr_agreement).toBe( + "agree", + ); + } finally { + await session.close(); + } + }, +); diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index 8ff58ad423..a61fcb4f5d 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -111,6 +111,7 @@ import { inspectPythonPycInputSchema } from "../../src/domain/python/pythonPyc.j import { PeResourcesProvider } from "../../src/native/pe/PeResourcesProvider.js"; import { Il2cppMetadataProvider } from "../../src/il2cpp/Il2cppMetadataProvider.js"; import { ElfNotesProvider } from "../../src/native/elf/ElfNotesProvider.js"; +import { ElfUnwindRangesProvider } from "../../src/native/elf/ElfUnwindRangesProvider.js"; import { AppleIpsProvider } from "../../src/native/apple/AppleIpsProvider.js"; import { KernelModuleProvider } from "../../src/native/elf/KernelModuleProvider.js"; import { KernelBuildConfigProvider } from "../../src/native/elf/KernelBuildConfigProvider.js"; @@ -120,6 +121,7 @@ import { WindowsShortcutProvider } from "../../src/native/windows/WindowsShortcu import { NativeExportsProvider } from "../../src/native/exports/NativeExportsProvider.js"; import { BeamProvider } from "../../src/erlang/BeamProvider.js"; import { inspectElfNotesInputSchema } from "../../src/domain/native/elfNotes.js"; +import { inspectElfUnwindRangesInputSchema } from "../../src/domain/native/elfUnwindRanges.js"; import { inspectAppleIpsInputSchema } from "../../src/domain/native/appleIps.js"; import { inspectKernelModuleInputSchema } from "../../src/domain/native/kernelModule.js"; import { inspectKernelBuildConfigInputSchema } from "../../src/domain/native/kernelBuildConfig.js"; @@ -1002,6 +1004,7 @@ it.each([ const stableArtifactProviders = [ ...staticProviders, ["ELF notes", () => new ElfNotesProvider()], + ["ELF unwind ranges", () => new ElfUnwindRangesProvider()], ["Apple IPS", () => new AppleIpsProvider()], ["kernel module", () => new KernelModuleProvider()], ["kernel build config", () => new KernelBuildConfigProvider()], @@ -1035,6 +1038,11 @@ const inspectFileFailure = ( inspectElfNotesInputSchema.parse({ path }), options, ); + if (provider instanceof ElfUnwindRangesProvider) + return provider.inspect( + inspectElfUnwindRangesInputSchema.parse({ path }), + options, + ); if (provider instanceof AppleIpsProvider) return provider.inspect( inspectAppleIpsInputSchema.parse({ path }), @@ -1121,6 +1129,7 @@ it.each( expect(result.error._tag).toBe( expectedTag ?? (provider instanceof ElfNotesProvider || + provider instanceof ElfUnwindRangesProvider || provider instanceof KernelModuleProvider || provider instanceof KernelBuildConfigProvider || provider instanceof PeCertificatesProvider ||