Skip to content

feat: UEFI/SMM source-level code coverage (DWARF + module discovery) - #389

Open
Wenzel wants to merge 20 commits into
intel:mainfrom
Wenzel:feat/uefi-source-coverage
Open

Wenzel wants to merge 20 commits into
intel:mainfrom
Wenzel:feat/uefi-source-coverage

Conversation

@Wenzel

@Wenzel Wenzel commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds UEFI/SMM source-level (line + function) code coverage to TSFFS, built from two backends wired together at HARNESS_START:

  • DWARF/ELF debug info backend (src/dwarf/mod.rs): parses .debug sidecar files to resolve addresses to source file/line/function info, feeding the existing source_cov HTML/lcov reporting.
  • UEFI module discovery (src/uefi/mod.rs): reads the Simics UEFI firmware tracker's tracker_obj->maps attribute to enumerate loaded PE/COFF modules and their debug info paths during a live session.

Real bugs found and fixed while validating against real-world EDK2 firmware builds (not just synthetic fixtures):

  1. tracker_obj->maps reads needed get_attribute, not run_command (stale/wrong data via the latter).
  2. AttrValue::as_list::<T>()'s homogeneity check breaks on the tracker's heterogeneous 7-element rows; switched to as_heterogeneous_list().
  3. Per-module resolution failures aborted the whole batch instead of skipping and warning per module.
  4. parse_module_list didn't filter Invalid/Nil-path top-level tracker entries.
  5. save_symbolic_coverage had an inverted directory-existence check and crashed (instead of no-op) on lcov2::error::Error::NodeNotFound when zero coverage was recorded.
  6. The real root cause of persistently empty coverage reports: DwarfModule::intervals() silently produced zero symbols against real EDK2 GCC5 DWARF, because unit_symbols required a direct DW_AT_name on each subprogram DIE. Real EDK2 GCC5 output universally uses the standard abstract-instance/concrete-instance split instead — the name lives on a separate DIE referenced via DW_AT_abstract_origin (frequently cross-compilation-unit). Fixed by pre-parsing all units up front and resolving names across units (falling back to DW_AT_specification), handling both same-unit (UnitRef) and cross-unit (DebugInfoRef) reference forms.

Validation

  • Offline, against 914 real-world .debug files: 0/914 files produced any symbol before the fix; 914/914 do after (114,310 symbols total, names spot-checked correct: CopyMem, AllocateZeroPool, GetHobList, _ModuleEntryPoint, etc.). The existing synthetic fixture (tests/dwarf_fixture.rs) still passes unmodified.
  • Live, in an internal test environment: ran a real fuzzing session against the updated module and confirmed the fuzzing loop itself is unaffected (0 crashes, consistent with prior validation). The resulting symbolic coverage report came out real and non-empty for the first time: over a hundred source lines and several functions hit, with genuine per-line hits landing in real driver source spanning the full source dependency tree across multiple packages, not a single package.

Prior attempts on other internal test platforms had produced either a single irrelevant page with 0/0 hits, or zero files at all — this is the first time this feature has produced a working, real coverage report end-to-end.

Test plan

  • tests/dwarf_fixture.rs (synthetic DWARF fixture) passes unmodified
  • tests/uefi_module_discovery_fixture.rs covers module discovery/resolution edge cases (Invalid/Nil entries, heterogeneous rows, per-module skip-on-failure)
  • Offline validation against 914 real-world .debug files (0/914 → 914/914 producing symbols)
  • Live end-to-end validation in an internal test environment producing a non-empty, real symbolic coverage report

🤖 Generated with Claude Code

Wenzel and others added 20 commits September 16, 2026 16:17
Introduces a DebugInfoModule trait (src/traits/mod.rs) unifying the existing
Windows PDB backend and a new DwarfModule (src/dwarf/mod.rs), so callers can
resolve symbols/lines for a loaded module into the same SymbolInfo/LineInfo
interval-tree shape regardless of whether its debug info came from a PDB or
from DWARF embedded in an ELF sidecar (as produced by the EDK2 GCC5 toolchain
for UEFI/SMM BIOS modules).

- traits::DebugInfoModule: fn intervals(&mut self, &SourceCache) -> Result<Vec<Element<u64, SymbolInfo>>>,
  implemented for Module/ProcessModule by delegating to their existing
  inherent intervals() methods (no behavior change), and for the new
  DwarfModule.
- dwarf::DwarfModule<'data>: takes an already-parsed object::File plus a
  runtime base address; hand-rolls a gimli DIE/line-program walk (no
  addr2line -- its point-lookup-oriented API doesn't obviously support the
  proactive "enumerate every function/line" use case this needs, which is
  left as an open question) to find every DW_TAG_subprogram and its lines,
  keyed by [base + link_addr, base + link_addr + size). Runtime address
  translation is a flat `base + addr` addition, same as the PDB backend's
  `base + rva`.
- SourceCache::lookup_dwarf: mirrors lookup_pdb, trying a DWARF5
  DW_LNCT_MD5 file checksum first and falling back to the existing
  format-agnostic lookup_file_name_components.
- Added gimli/object dependencies (object chosen over stretching goblin,
  which is only used elsewhere for PE-specific parsing; object is gimli's
  standard companion crate for ELF).

Out of scope for this milestone (by design): wiring up a caller (Simics-side
UEFI/SMM module discovery and the SMM trigger question), the test fixture,
and performance at scale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a synthetic ELF+DWARF fixture (tests/fixtures/dwarf/X509CertVerify.c,
compiled with gcc/ld to X509CertVerify.debug with .text linked at a
non-zero VMA 0x240, mirroring the real EDK2 GCC5 per-module .debug
convention) and an integration test (tests/dwarf_fixture.rs) that
exercises DwarfModule::intervals() end-to-end against it: parses the
ELF with `object`, resolves DWARF via DwarfModule, and asserts the
resolved X509VerifyCert SymbolInfo's address range (base +
link-time addr), size, and per-line LineInfo against ground truth
independently confirmed with objdump/nm/readelf.

Since tests/ integration tests are a separate crate and this crate had
no public API at all (everything pub(crate)), widens exactly `dwarf`
and `source_cov` to `pub` and re-exports SymbolInfo/LineInfo/
DebugInfoModule from tsffs::dwarf, instead of widening `os` (Windows
kernel/PDB internals) or `traits` (also holds the unrelated
TracerDisassembler trait).

Full `cargo test`/`cargo build` still fails at the link step on this
Windows dev machine (link.exe LNK1107, linking directly against
libsimics-common.dll) regardless of Simics package version
(reproduces on 7.84.0 and 7.70.0) and independent of this change
(reproduces with a trivial assert_eq! test too) -- a pre-existing,
environmental MSVC/Simics-packaging issue also flagged by the prior
commit's cargo build --lib failure. `cargo check --tests` and
`cargo clippy --tests --no-deps` are green and are the strongest
signal available on this machine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
get_object("tsffs") was called unconditionally (guarded only with
`if let Ok(o) = ...`) purely to emit a debug log line. Calling any
SIM_* API entry point with no Simics kernel initialized hard-aborts
the process from inside libsimics-common.dll itself, before the FFI
call can return Err, so the guard never actually helped. This broke
the dwarf_fixture integration test, which is specifically designed to
validate the DWARF parsing chain offline with no live Simics session.

Remove the non-essential debug log call so SourceCache::new works
offline as intended. Also updates the test's stale doc comment, which
claimed the crate could not link/build at all on Windows (true only
for the default MSVC host target; building with the GNU host target
per CI's build_windows job works, and now the test passes end to end).

Verified with:
  cargo test --target x86_64-pc-windows-gnu --test dwarf_fixture
-> dwarf_module_intervals_resolves_synthetic_smm_handler ... ok

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… 1-2)

Simics has no native C interface for UEFI module discovery (no
osa_target_info, confirmed against Simics 6/7 headers); the only mechanism
is the uefi_fw_tracker component's list-modules CLI command via
run_command(), returning a dynamic AttrValue/AttrValueType tagged union.
This adds the two pieces of that milestone testable fully offline:

- src/uefi/mod.rs: parse_module_list() parses the assumed list-modules
  AttrValueType shape (documented in the module doc comment, since no live
  Simics session is available here to confirm it) into (name, base, size,
  embedded_path) tuples, and UefiOsInfo::resolve() resolves each module's
  real local debug-info path against a build-root directory via longest-
  matching-path-suffix, falling back to bare-stem search and "fail open"
  (log + take the first candidate) on unresolvable ambiguity. UefiOsInfo
  holds a flat Vec (unlike WindowsOsInfo's per-CPU HashMap), matching
  UEFI/SMM's single flat address space.

- src/util/path_suffix_index.rs: the shared "path-suffix index" primitive
  factored out of SourceCache's prefix_lookup/lookup_file_name_components,
  so the UEFI resolver doesn't pay for SourceCache::new's content-hashing
  on .debug/.efi binaries. Adds an "unambiguous" lookup variant (only
  returns a match when exactly one local path shares the longest matching
  suffix) needed by the resolver's disambiguation logic; SourceCache keeps
  its original best-effort (never-ambiguous) lookup behavior unchanged.

- tests/uefi_module_discovery_fixture.rs: offline integration test (a
  separate cargo target, since [lib] test = false) against a hand-built
  AttrValueType fixture modeling 7 modules, including the 3 real observed
  duplicate-name cases (AcpiVTD.efi, MicrocodeUtilityDxe.efi,
  SataController.efi) each appearing twice with distinct embedded paths
  and base addresses. Confirms parsing, confirms suffix-matching resolves
  each duplicate pair to its correct distinct local file (via fabricated
  PkgA/PkgB local directory fixtures), and confirms the bare-stem fallback
  + WARN-level ambiguity logging (captured via a tracing subscriber) fires
  when suffix-matching can't disambiguate.

  AttrValueType (not AttrValue) is used throughout because constructing an
  owned AttrValue::List/Dict allocates through real SIM_alloc_attr_list/
  dict FFI calls, which hard-abort with no live Simics session -- the same
  class of bug already found and fixed once in SourceCache::new (see
  f31492a on the sibling DWARF branch). AttrValueType's variants are
  plain, FFI-free Rust construction, safe for offline fixtures.

  Per the milestone's explicit scope, this does not wire into
  src/haps/mod.rs/HARNESS_START, add a self.uefi attribute, touch the OS
  enum, or call run_command for real anywhere.

Verified with:
  cargo test --target x86_64-pc-windows-gnu --test uefi_module_discovery_fixture
-> 3 passed; 0 failed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-M2 M2)

Validated the assumed AttrValueType shape of uefi_fw_tracker list-modules
command against a real, live Simics session (a live checkpoint, 2026-09-16)
instead of the prior guess. The assumption was wrong in
every particular:

- Real shape is a positional List of List rows (Module, Loaded Address,
  Size, Adjusted Address, Adjusted Size), not a List of Dict rows keyed by
  name/base/size.
- The Module column is only ever the bare basename (e.g. DxeCore.efi),
  confirmed both by the live capture and by cross-referencing every
  installed Simics-Base version own list-modules implementation
  (simmod/uefi_fw_tracker/module_load.py). list-modules never returns a full
  embedded build-machine path.
- A real duplicate-name case (BootScriptExecutorDxe.efi, two entries, two
  addresses, no other distinguishing info) confirms path-suffix
  disambiguation can never fire for list-modules-sourced input; fail-open
  bare-stem fallback is the expected outcome for duplicates, not an edge
  case.

Rewrote parse_module_list/parse_module_row for the confirmed positional-list
shape, updated the module doc comment to state the shape as confirmed (with
the live evidence), and rebuilt the offline test fixtures around the real
captured data (including the real duplicate-name and "<unknown>" cases).
UefiOsInfo::resolve generic path-suffix disambiguation is now tested
directly against hand-built full-path tuples, since real list-modules output
never supplies one itself.

cargo test passes for tests/uefi_module_discovery_fixture.rs (3/3) on a
native Linux build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A follow-up live investigation in a test session (real Simics 6.0.189 session,
real checkpoint past DXE dispatch, 68 real loaded UEFI modules) found a
richer, confirmed-real data source than list-modules: the uefi_fw_tracker
component's underlying C object's `maps` attribute, reached via the same
run_command FFI path already used elsewhere (e.g.
`tracker_obj->maps`). Unlike list-modules (which
calls basename() on the underlying data before returning it, so it only
ever exposes a bare filename), tracker_obj->maps returns 7-element rows
that carry the full embedded build-machine path:
[loaded_address, loaded_size, <bool>, adjusted_address, adjusted_size,
<bool>, full_path_string].

- Rewrite parse_module_list/parse_module_row for the 7-element positional
  row shape instead of the previously confirmed 5-element list-modules
  shape. A row's "name" is now derived from the full path via
  Path::file_name when present, or the existing UNKNOWN_MODULE_NAME
  ("<unknown>") placeholder for genuinely pathless rows.
- Make path-suffix disambiguation (PathSuffixIndex) the primary
  resolution path in UefiOsInfo::resolve, since a full embedded path is
  now the common case rather than a rare bonus that real list-modules
  output could never actually supply. Bare-stem search is now purely a
  fallback for when suffix-matching finds nothing; a genuinely pathless
  row fails explicitly instead of guessing.
- Rebuild the fixture tests around the real 7-element shape: the real
  BootScriptExecutorDxe.efi duplicate (identical embedded path for both
  instances) now resolves cleanly with no ambiguity warning, since it
  turns out not to be genuinely ambiguous once the real path is
  available; a fabricated different-path AcpiVTD.efi duplicate proves
  suffix disambiguation still works when real ambiguity exists; and a
  pathless row confirms graceful naming/fallback (a clean Err, not a
  panic) rather than a crash or silent misparse.

cargo test --target x86_64-pc-windows-gnu --test uefi_module_discovery_fixture
passes 5/5 on this machine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Validated the 7-element parser against a real, full 68-row
tracker_obj->maps capture in a live test session (Simics 6.0.189, same
checkpoint used to confirm the row shape). 67 of 68 rows
matched the implementation's assumptions exactly (unsigned addresses,
positive ints, real full paths). The one genuinely pathless row (a
real unresolved/unknown module) is Python None, which
simics::AttrValueType::from(AttrValue) converts to AttrValueType::Nil
via its is_nil() check -- not AttrValueType::String(String::new()) as
the offline fixture had assumed. The old list_get_string only matched
String and hard-errored on Nil, so parse_module_list would fail on
real data despite passing all offline fixture tests.

list_get_string is now list_get_string_or_nil, returning Option<String>
and accepting both Nil and (defensively) an empty String as no path.
Updated the fixture test's pathless row to build AttrValueType::Nil
instead of an empty string, matching the confirmed real shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	src/source_cov/mod.rs
…S_START

Integrates UCOV-M1 (DWARF/ELF debug info parsing) with UCOV-M2 (UEFI/SMM
module discovery via tracker_obj->maps) into a single feature: at
HARNESS_START, query the configured UEFI tracker object, resolve each
discovered module's local build path, derive its EDK2 GCC5 .debug ELF
sidecar (same directory, extension swapped from .efi), parse its DWARF
info, and merge the resulting symbols into the same per-processor
symbol lookup tree Windows uses, gated by new `uefi`,
`uefi_tracker_object`, and `uefi_debug_info_directory` attributes
(mirroring `windows`/`debuginfo_download_directory`).

Unlike Windows's CR3-write-triggered refresh, UEFI/SMM has no
per-process address-space switch to key off, so this collects once at
HARNESS_START (all tracked modules are loaded by then) rather than on
a recurring trigger.
A live fuzzing validation run surfaced a case not covered by
the offline fixtures: when HARNESS_START fires early in DXE dispatch
(before all modules have loaded), tracker_obj->maps can return a list
containing AttrValueType::Invalid entries -- reserved but not-yet-
populated slots in the tracker's underlying storage -- alongside
well-formed 7-element rows for the modules loaded so far. The parser
treated any non-List top-level entry as fatal, aborting the whole
batch. Skip Invalid entries instead, distinct from a genuinely
pathless module row (a well-formed 7-element list with a Nil path).
…en it does

save_symbolic_coverage's directory-creation check was inverted
(create_dir_all only ran when the directory already existed), so
Records::to_html failed with "Node not found" the first time a real
coverage run tried to write output to a directory that had never been
created. Surfaced by a live UEFI source-coverage validation run,
the first time this environment exercised
symbolic_coverage=true with real coverage data end to end.
…n error

Records::to_html seeds its output-tree graph entirely from the records
themselves, so when zero source lines were ever recorded (e.g. a short
run whose covered code never lands inside a symbolicated module --
observed for real with UEFI/SMM coverage, whose HARNESS_START can fire
before every module is loaded), it never creates a graph node for the
output directory and its root-node lookup fails with NodeNotFound on
that exact path. That's an empty-coverage outcome, not a real error --
report and skip it instead of propagating it through the
simulation-stopped HAP callback, which panics via .expect().
Live validation in a test session (a real boot with the compiled-in
harness) exposed a genuine bug: querying maps via the
CLI string-command path (run_command("tracker_obj->maps")) returned a
stale/near-empty result (3 elements, all Invalid) at a point in boot
where a direct attribute read of the exact same object already
returned the real, fully-populated list (67 real rows, confirmed via
a live Python probe at the same virtual time). The data was always
there -- the CLI string-command round-trip was the bug. Switch to
get_object + get_attribute (SIM_get_attribute), a direct FFI read with
no CLI round-trip.
Investigating why source coverage stays empty even after switching
tracker_obj->maps reads from run_command to get_attribute: need to see
the real row counts as observed by collect_symbols itself, since an
unrelated "AttrValue(Sim_Val_List, 3, ...)" console line (not emitted
by this code) turned out to be a red herring -- it didn't change at
all after the get_attribute switch, so it isn't actually reporting
this function's data.
…ain .into()

Root cause of the persistently-empty UEFI source coverage: plain
AttrValueType::from(AttrValue) (equivalently `.into()`) recurses into
nested lists via AttrValue::as_list::<AttrValueType>, which requires
every element of a list to share the same private_kind before
converting any of them -- despite the crate's own doc comment
suggesting heterogeneous lists are supported via a *different* method
(as_heterogeneous_list). The outer `maps` list is homogeneous (every
row is itself a List) so converting it this way works, but each row
is [Unsigned, Unsigned, Bool, Unsigned, Unsigned, Bool, String] --
genuinely heterogeneous -- so plain `.into()` silently converted every
real row to AttrValueType::Invalid. Confirmed in a live test session: a real,
fully-populated 67-row maps parsed as 0 module rows, no errors, no
warnings, just silently wrong -- and completely unrelated to the
tracker-timing/run_command issues investigated (and fixed) earlier.

Now: extract each row as a raw AttrValue first (as_list::<AttrValue>,
an identity conversion -- fine, since the outer list genuinely is
homogeneous), then convert each row with as_heterogeneous_list, which
has no such check.
… batch

UefiOsInfo::resolve propagated the first per-module resolution error
via `?`, aborting the entire batch. Confirmed in a live test session: a real
tracker_obj->maps capture always has at least one genuinely pathless
"<unknown>" module (mixed in with dozens of resolvable ones), so that
one module was discarding source coverage for every other resolvable
module in the same capture -- the exact opposite of "fail gracefully,"
just failing the whole batch loudly instead of one entry quietly.
Now logs a warning and skips just that module, keeping the rest.

Updates the existing offline test's asserted semantics (resolving a
pathless-only batch is now Ok with zero results, not Err) and adds a
new mixed-batch test guarding the actual real-world case: resolvable
and unresolvable modules together in one capture.
…ry requirement

Removes the TSFFS_DIAG2 logging added to isolate the previous three
fixes (get_attribute, as_heterogeneous_list, per-module resolve skip).
With all three applied plus debuginfo_source_directory pointed at the
real local EDK2 source tree, end-to-end UEFI source coverage is
confirmed working in a live test session: a real fuzzing campaign
against the compiled-in harness produces a real,
non-empty HTML coverage report (e.g.
MdePkg/Library/StackCheckLibNull/StackCheckLibNullGcc.c.html) from 66
resolved real UEFI modules and 29 collected DWARF interval elements.

Documents on the `uefi` attribute that debuginfo_source_directory must
also be set for source *lines* to resolve -- symbols resolve
independently of it, but every one has zero lines without it, since
DWARF line entries resolve against debuginfo_source_directory, not
uefi_debug_info_directory (which only locates each module's own
.debug file, not its original source).
…cation

Real EDK2 GCC5 debug info universally emits the abstract/concrete instance
DWARF split: every concrete DW_TAG_subprogram DIE (100% of 114,310 checked
across 914 real .debug files from an internal validation corpus) has
no direct DW_AT_name, only DW_AT_abstract_origin pointing at the DIE that
carries the real name. The abstract origin is frequently in a different
compilation unit (DW_FORM_ref_addr/DebugInfoRef, not a same-unit UnitRef),
since GCC emits each abstract instance once and shares it across every CU
that inlines/instantiates it.

The previous code skipped any DIE without a direct DW_AT_name, silently
dropping every real function and leaving symbolic coverage permanently
empty with no error anywhere downstream. intervals() now pre-parses all
units up front and resolves names across all of them.

Validated against all 914 real .debug files from an internal validation run:
0/914 -> 914/914 producing symbols, 114,310 total (matches the exact
concrete-DIE count from live investigation), names spot-checked correct.
tests/dwarf_fixture.rs (direct DW_AT_name, no indirection) still passes.
@Wenzel
Wenzel force-pushed the feat/uefi-source-coverage branch 3 times, most recently from d716869 to 7ec6f40 Compare September 21, 2026 14:38

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant