diff --git a/Cargo.toml b/Cargo.toml index 6e6c87b3..340a220e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,8 @@ sha2 = "0.10.8" typed-path = "0.9.0" thiserror = "1.0.63" lcov2 = "0.1.0" +gimli = "0.34.0" +object = "0.40.0" [dev-dependencies] simics-test = "0.2.6" @@ -111,6 +113,7 @@ command-ext = "0.1.2" indoc = "2.0.5" ispm-wrapper = "0.2.6" versions = { version = "6.2.0", features = ["serde"] } +tempfile = "3.13.0" [build-dependencies] simics = "0.2.6" diff --git a/src/dwarf/mod.rs b/src/dwarf/mod.rs new file mode 100644 index 00000000..97b8a69e --- /dev/null +++ b/src/dwarf/mod.rs @@ -0,0 +1,429 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +//! DWARF/ELF debug info backend. +//! +//! UEFI/SMM BIOS modules built with the EDK2 GCC5 toolchain ship debug info as DWARF +//! embedded in an ELF `.debug` sidecar file, unlike Windows kernel/PE modules, which +//! ship Microsoft PDB (see `crate::os::windows::debug_info`). [`DwarfModule`] parses +//! that DWARF/ELF debug info and implements the shared [`DebugInfoModule`] trait +//! (`crate::traits::DebugInfoModule`), so callers get the exact same +//! [`SymbolInfo`]/[`LineInfo`] output regardless of whether a module's debug info +//! came from a PDB or from DWARF/ELF. +//! +//! # Address translation +//! +//! DWARF line-table and DIE addresses in these per-module ELF files are link-time +//! addresses relative to the module's own image (the linked `.text` VMA is commonly a +//! small non-zero value such as `0x240`, not `0`). The runtime address for any DWARF +//! address `a` in a module loaded at `base` (its EDK2 `ImageBase`) is simply +//! `base + a` -- a flat addition, exactly analogous to the PDB backend's +//! `self.base + rva`. No special-case subtraction of a link base is needed. +//! +//! # Open questions / explicitly out of scope for this milestone +//! +//! - `addr2line` (gimli's usual companion crate for point address lookups) is +//! intentionally not used here. Whether it's viable for *proactive* enumeration of +//! every function/line (needed to seed coverage denominators, mirroring what +//! `Module`/`ProcessModule::intervals` do for PDB) is an open question -- its public +//! API is oriented around "what function/line is at this address", not "list all +//! functions/lines". Until that's resolved, this module hand-rolls the DIE and line +//! program walk directly on `gimli`. +//! - How a `DwarfModule` actually gets constructed at runtime (Simics-side UEFI/SMM +//! module discovery: finding the loaded module's `ImageBase` and locating its +//! `.debug` file) and the trigger question (there is no CR3-equivalent signal for +//! SMM, unlike `Tsffs::on_control_register_write_windows_symcov` for Windows) are +//! out of scope here. +//! - Performance at scale (~247 modules) is not addressed; `intervals` below does a +//! straightforward per-unit, per-subprogram walk. + +use std::{borrow::Cow, collections::HashMap, path::PathBuf}; + +use anyhow::{anyhow, Result}; +use gimli::{ + AttributeValue, DebuggingInformationEntry, DwarfSections, EndianSlice, LineProgramHeader, + Reader, RunTimeEndian, SectionId, Unit, UnitRef, +}; +use intervaltree::Element; +use object::{Object, ObjectSection}; + +use crate::source_cov::SourceCache; + +// Re-exported (rather than left as a plain `use`) so that `tests/dwarf_fixture.rs` +// -- a separate crate, since it's an integration test -- can name these as +// `tsffs::dwarf::{SymbolInfo, LineInfo, DebugInfoModule}` without requiring all of +// `crate::os` (Windows kernel/PDB internals) or `crate::traits` (which also holds +// the unrelated `TracerDisassembler` trait) to be made public too. +pub use crate::os::windows::debug_info::{LineInfo, SymbolInfo}; +pub use crate::traits::DebugInfoModule; + +/// A UEFI/SMM module's DWARF/ELF debug info, resolved into the same +/// [`SymbolInfo`]/[`LineInfo`] shape the PDB backend produces. +/// +/// Unlike the PDB backend (`crate::os::windows::debug_info::DebugInfo`), which owns +/// the file handle it parses, `DwarfModule` is constructed from an already-parsed +/// [`object::File`] -- the caller is responsible for reading the module's `.debug` +/// ELF file into a buffer that outlives this module and parsing it with +/// `object::File::parse`. +#[derive(Debug)] +pub struct DwarfModule<'data> { + /// The runtime base address (EDK2 `ImageBase`) this module is loaded at in guest + /// memory. Every DWARF address in `object` is a link-time address relative to the + /// module's own image and is translated to a runtime address via `base + addr` + /// (see the module-level docs above). + pub base: u64, + /// The name of the module (e.g. its `.efi`/driver name), used to populate + /// `SymbolInfo::module`. + pub full_name: String, + /// The already-parsed ELF file containing the DWARF debug info. + object: object::File<'data>, +} + +impl<'data> DwarfModule<'data> { + /// Construct a new DWARF-backed debug info module from an already-parsed ELF + /// file, treating it as loaded at `base` in guest memory. + pub fn new(full_name: String, base: u64, object: object::File<'data>) -> Self { + Self { + base, + full_name, + object, + } + } + + /// Load the raw contents of a DWARF section from `self.object`, decompressing it + /// if necessary. Returns an empty slice for sections that aren't present, which is + /// how `gimli::Dwarf::load` expects missing sections to be reported. + fn load_section(&self, id: SectionId) -> Result, object::Error> { + Ok(match self.object.section_by_name(id.name()) { + Some(section) => section.uncompressed_data()?, + None => Cow::Borrowed(&[][..]), + }) + } + + /// Render a DWARF line-program file entry's directory + file name into a single + /// path-like string suitable as the fallback lookup key for + /// `SourceCache::lookup_dwarf` (i.e. the DWARF-embedded name, not a resolved local + /// path). + fn render_file_name( + unit_ref: UnitRef, + file: &gimli::FileEntry, + header: &LineProgramHeader, + ) -> Result { + let mut components = Vec::new(); + + // Directory index 0 is defined to mean the compilation directory, which we + // don't have a reliable local analog for, so we only record explicit + // subdirectories here. `SourceCache::lookup_file_name_components` matches by + // path suffix, so omitting the compilation directory prefix does not affect + // correctness. + if file.directory_index() != 0 { + if let Some(directory) = file.directory(header) { + let directory = unit_ref + .attr_string(directory) + .map_err(|e| anyhow!("Failed to read DWARF directory name: {e}"))? + .to_string_lossy() + .map_err(|e| anyhow!("Failed to decode DWARF directory name: {e}"))? + .into_owned(); + components.push(directory); + } + } + + let name = unit_ref + .attr_string(file.path_name()) + .map_err(|e| anyhow!("Failed to read DWARF file name: {e}"))? + .to_string_lossy() + .map_err(|e| anyhow!("Failed to decode DWARF file name: {e}"))? + .into_owned(); + components.push(name); + + Ok(components.join("/")) + } + + /// Resolve a DWARF line-program file entry (by index) to a local source file path + /// via `source_cache`, trying the DWARF5 `DW_LNCT_MD5` checksum first (mirroring + /// `SourceCache::lookup_pdb`'s use of the PDB-embedded checksum) and falling back + /// to path-suffix matching on the DWARF-embedded file name. + fn resolve_file_path( + unit_ref: UnitRef, + header: &LineProgramHeader, + file_index: u64, + source_cache: &SourceCache, + ) -> Option { + let file = header.file(file_index)?; + let rendered = Self::render_file_name(unit_ref, file, header).ok()?; + let md5 = header.file_has_md5().then(|| *file.md5()); + + source_cache + .lookup_dwarf(md5.as_ref(), &rendered) + .ok() + .flatten() + .map(|p| p.to_path_buf()) + } + + /// Walk every `DW_TAG_subprogram` DIE in `unit_ref`'s compilation unit, resolving + /// each one's address range and source lines (via its line number program) into + /// [`SymbolInfo`] with module-relative (link-time) addresses in `rva`/`LineInfo::rva`. + fn unit_symbols( + &self, + unit_ref: UnitRef, + all_units: &[Unit], + source_cache: &SourceCache, + ) -> Result> { + let Some(incomplete_line_program) = unit_ref.line_program.clone() else { + // No line number program for this unit (e.g. a unit with no debug lines); + // there is nothing to resolve lines against, so skip it entirely. + return Ok(Vec::new()); + }; + + // Build a flat, address-sorted list of line rows for the unit, and (lazily, + // memoized by file index) resolve each referenced file to a local source path + // up front, since many rows share the same file. + let mut file_paths: HashMap> = HashMap::new(); + let mut rows: Vec<(u64, u64, u32, bool)> = Vec::new(); + + let mut line_rows = incomplete_line_program.rows(); + + while let Some((header, row)) = line_rows + .next_row() + .map_err(|e| anyhow!("Failed to read DWARF line program row: {e}"))? + { + let file_index = row.file_index(); + + file_paths.entry(file_index).or_insert_with(|| { + Self::resolve_file_path(unit_ref, header, file_index, source_cache) + }); + + rows.push(( + row.address(), + file_index, + row.line().map(|line| line.get() as u32).unwrap_or(0), + row.end_sequence(), + )); + } + + rows.sort_by_key(|row| row.0); + + let mut symbols = Vec::new(); + + let mut cursor = unit_ref.entries(); + + while let Some(entry) = cursor + .next_dfs() + .map_err(|e| anyhow!("Failed to walk DWARF DIE tree: {e}"))? + { + if entry.tag() != gimli::DW_TAG_subprogram { + continue; + } + + let Some((low, high)) = Self::subprogram_range(unit_ref, entry)? else { + // No low_pc/high_pc/ranges on this DIE -- it's a declaration or + // abstract instance root with no code of its own, not a concrete + // subprogram we can key an interval on. + continue; + }; + + if high <= low { + continue; + } + + let Some((name, name_unit_ref)) = Self::resolve_name(entry, unit_ref, all_units) else { + // No name resolvable via DW_AT_name, DW_AT_abstract_origin, or + // DW_AT_specification -- not a concrete named subprogram we can key + // symbol info on. + continue; + }; + + let name = name_unit_ref + .attr_string(name) + .map_err(|e| anyhow!("Failed to read DWARF subprogram name: {e}"))? + .to_string_lossy() + .map_err(|e| anyhow!("Failed to decode DWARF subprogram name: {e}"))? + .into_owned(); + + let lines = Self::lines_in_range(&rows, low, high, &file_paths); + + symbols.push(SymbolInfo::new( + low, + self.base, + high - low, + name, + self.full_name.clone(), + lines, + )); + } + + Ok(symbols) + } + + /// Resolve a DIE's effective `DW_AT_name` attribute for stringification, following + /// `DW_AT_abstract_origin` (falling back to `DW_AT_specification`) to the referenced + /// DIE when `entry` has no direct `DW_AT_name` of its own. Returns the resolved + /// `DW_AT_name` attribute value together with the `UnitRef` of the unit that DIE + /// actually lives in -- needed to correctly stringify forms such as + /// `DW_FORM_strx` (relative to a per-unit string-offsets base); not needed for the + /// common `DW_FORM_strp` (absolute `.debug_str` offset) case seen in practice, but + /// cheap to keep correct either way. + /// + /// GCC5/EDK2 universally emits the standard "abstract instance / concrete + /// instance" DWARF split for every real function: the concrete DIE (the one with + /// `DW_AT_low_pc`/`DW_AT_high_pc`, i.e. `entry` here) has `DW_AT_abstract_origin` + /// pointing at a separate DIE that carries the real `DW_AT_name`, instead of a + /// direct name on itself. Confirmed against 914 real EDK2 GCC5 `.debug` files, + /// that reference is universally `DW_FORM_ref_addr` (a raw `.debug_info`-section + /// offset, decoded by gimli as `AttributeValue::DebugInfoRef`), rather than a same-unit + /// `AttributeValue::UnitRef` -- GCC emits each "abstract instance" DIE once, in + /// whichever compilation unit first defines it, and references it from every other + /// unit that inlines/instantiates it, so the referenced DIE is frequently in a + /// *different* CU than `entry`. Hence resolving it requires searching + /// `all_units` (every unit in this module, pre-parsed by `intervals`), not just + /// `unit_ref`'s own unit -- `AttributeValue::UnitRef` is still handled too, in case + /// some DIEs reference same-unit offsets instead. + fn resolve_name<'u, R: Reader>( + entry: &DebuggingInformationEntry, + unit_ref: UnitRef<'u, R>, + all_units: &'u [Unit], + ) -> Option<(AttributeValue, UnitRef<'u, R>)> { + if let Some(name) = entry.attr_value(gimli::DW_AT_name) { + return Some((name, unit_ref)); + } + + let origin = entry + .attr_value(gimli::DW_AT_abstract_origin) + .or_else(|| entry.attr_value(gimli::DW_AT_specification))?; + + match origin { + AttributeValue::UnitRef(offset) => { + let origin_entry = unit_ref.entry(offset).ok()?; + let name = origin_entry.attr_value(gimli::DW_AT_name)?; + Some((name, unit_ref)) + } + AttributeValue::DebugInfoRef(offset) => all_units.iter().find_map(|candidate| { + let local_offset = offset.to_unit_offset(&candidate.header)?; + let origin_entry = candidate.entry(local_offset).ok()?; + let name = origin_entry.attr_value(gimli::DW_AT_name)?; + Some((name, candidate.unit_ref(unit_ref.dwarf))) + }), + _ => None, + } + } + + /// Compute the `[low, high)` link-time address range of a DIE from its + /// `DW_AT_low_pc`/`DW_AT_high_pc`/`DW_AT_ranges` attributes, aggregating over all + /// ranges if there is more than one (e.g. for a DIE split into disjoint pieces). + /// Returns `None` if the DIE has no address range at all (e.g. a declaration). + fn subprogram_range( + unit_ref: UnitRef, + entry: &DebuggingInformationEntry, + ) -> Result> { + let mut ranges = unit_ref + .die_ranges(entry) + .map_err(|e| anyhow!("Failed to read DWARF DIE address ranges: {e}"))?; + + let mut result: Option<(u64, u64)> = None; + + while let Some(range) = ranges + .next() + .map_err(|e| anyhow!("Failed to read next DWARF address range: {e}"))? + { + result = Some(match result { + Some((low, high)) => (low.min(range.begin), high.max(range.end)), + None => (range.begin, range.end), + }); + } + + Ok(result) + } + + /// Collect the `LineInfo`s for every non-synthetic (`line != 0`), non-end-of-sequence + /// row in `rows` (sorted by address, as built by `unit_symbols`) that falls within + /// `[low, high)`, sizing each line by the address of the following row, resolving + /// its file via the memoized `file_paths` (skipping rows whose file didn't resolve + /// to a local path). + fn lines_in_range( + rows: &[(u64, u64, u32, bool)], + low: u64, + high: u64, + file_paths: &HashMap>, + ) -> Vec { + rows.iter() + .enumerate() + .filter(|(_, (address, _, line, end_sequence))| { + !end_sequence && *line != 0 && *address >= low && *address < high + }) + .filter_map(|(index, &(address, file_index, line, _))| { + let file_path = file_paths.get(&file_index)?.clone()?; + + let next_address = rows + .get(index + 1) + .map(|next| next.0) + .unwrap_or(high) + .min(high); + + Some(LineInfo { + rva: address, + size: next_address.saturating_sub(address).max(1) as u32, + file_path, + start_line: line, + end_line: line, + }) + }) + .collect() + } +} + +impl<'data> DebugInfoModule for DwarfModule<'data> { + /// Resolve every `DW_TAG_subprogram` in this module's DWARF debug info into + /// interval-tree elements keyed by `[base + low_pc, base + high_pc)`, with source + /// lines resolved through `source_cache`. Mirrors + /// `Module::intervals`/`ProcessModule::intervals` for the PDB backend. + fn intervals(&mut self, source_cache: &SourceCache) -> Result>> { + let endian = if self.object.is_little_endian() { + RunTimeEndian::Little + } else { + RunTimeEndian::Big + }; + + let dwarf_sections = DwarfSections::load(|id| self.load_section(id)) + .map_err(|e| anyhow!("Failed to load DWARF sections: {e}"))?; + let dwarf = dwarf_sections.borrow(|section| EndianSlice::new(section, endian)); + + // Pre-parse every compilation unit up front, rather than one at a time as + // they're walked below, so that DW_AT_abstract_origin/DW_AT_specification + // references that cross compilation-unit boundaries (see `resolve_name`) can + // be resolved against *any* unit, not just whichever one is currently being + // walked. + let mut all_units = Vec::new(); + let mut unit_headers = dwarf.units(); + + while let Some(header) = unit_headers + .next() + .map_err(|e| anyhow!("Failed to read next DWARF unit header: {e}"))? + { + all_units.push( + dwarf + .unit(header) + .map_err(|e| anyhow!("Failed to parse DWARF unit: {e}"))?, + ); + } + + let mut symbols = Vec::new(); + + for unit in &all_units { + let unit_ref = unit.unit_ref(&dwarf); + + symbols.extend(self.unit_symbols(unit_ref, &all_units, source_cache)?); + } + + Ok(symbols + .into_iter() + .map(|s| (self.base + s.rva..self.base + s.rva + s.size, s).into()) + .collect()) + } +} + +// NOTE: There is intentionally no `#[cfg(test)] mod test` here. `[lib] test = false` +// in Cargo.toml disables the implicit unit-test harness for *this* (library) target, +// so `#[cfg(test)]` code in this file is never compiled by any `cargo test` +// invocation. The offline end-to-end test of `DwarfModule::intervals` (against a +// synthetic ELF+DWARF fixture built with WSL gcc) lives in `tests/dwarf_fixture.rs` +// instead, which is a separate cargo target/crate not affected by `test = false`. diff --git a/src/haps/mod.rs b/src/haps/mod.rs index 2b271f81..ddddeedd 100644 --- a/src/haps/mod.rs +++ b/src/haps/mod.rs @@ -3,7 +3,7 @@ //! Handlers for HAPs in the simulator -use std::time::SystemTime; +use std::{collections::HashSet, time::SystemTime}; use crate::{ arch::ArchitectureOperations, @@ -13,6 +13,7 @@ use crate::{ ManualStartInfo, Tsffs, }; use anyhow::{anyhow, bail, Result}; +use intervaltree::IntervalTree; use libafl::prelude::ExitKind; use simics::{ api::{ @@ -39,6 +40,73 @@ enum SnapshotRestoreMode { } impl Tsffs { + /// Collect UEFI/SMM source coverage info, if `uefi` and `symbolic_coverage` are + /// both set. Called once, at `HARNESS_START` (the same three call sites Windows + /// uses for its own initial collection), rather than on a recurring trigger -- + /// see `crate::uefi::collect_symbols`'s doc comment for why UEFI/SMM has no + /// CR3-write-equivalent refresh signal the way Windows does. + /// + /// The resulting interval tree is stored in `self.windows_os_info`, alongside + /// Windows's own per-processor symbol lookup trees, keyed the same way (by + /// processor number). `self.uefi` and `self.windows` are mutually exclusive in + /// practice (a target is either a Windows kernel or a UEFI/SMM BIOS, not both), + /// so this reuses the exact same storage and the tracer's existing OS-agnostic + /// coverage lookup (`src/tracer/mod.rs`, the `self.coverage_enabled && + /// self.symbolic_coverage` branch, which does not itself check `self.windows`) + /// rather than duplicating a second lookup path just for `uefi`. + fn collect_uefi_symbolic_coverage(&mut self, processor: *mut ConfObject) -> Result<()> { + if !(self.uefi && self.symbolic_coverage) { + return Ok(()); + } + + info!( + self.as_conf_object(), + "Collecting initial UEFI/SMM source coverage info" + ); + + let elements = crate::uefi::collect_symbols( + &self.uefi_tracker_object, + &self.uefi_debug_info_directory, + &self.source_file_cache, + )?; + + let mut filtered_ranges = HashSet::new(); + + // Deduplicate elements by their range, mirroring + // `WindowsOsInfo::collect`'s own deduplication. + let elements = elements + .into_iter() + .filter(|e| filtered_ranges.insert(e.range.clone())) + .collect::>(); + + // Populate elements into the coverage record set, mirroring + // `WindowsOsInfo::collect`'s own population of `user_debug_info.coverage`. + elements.iter().map(|e| &e.value).for_each(|si| { + if let Some(first) = si.lines.first() { + let record = self.coverage.get_or_insert_mut(&first.file_path); + record.add_function_if_not_exists( + first.start_line as usize, + si.lines.last().map(|l| l.end_line as usize), + &si.name, + ); + si.lines.iter().for_each(|l| { + (l.start_line..=l.end_line).for_each(|line| { + record.add_line_if_not_exists(line as usize); + }); + }); + } + }); + + let processor_nr = get_processor_number(processor)?; + + self.windows_os_info.symbol_lookup_trees.insert( + processor_nr, + elements.into_iter().collect::>(), + ); + + Ok(()) + } + fn on_simulation_stopped_magic_start(&mut self, magic_number: MagicNumber) -> Result<()> { if !self.have_initial_snapshot() { self.start_fuzzer_thread()?; @@ -86,6 +154,7 @@ impl Tsffs { &self.source_file_cache, )?; } + self.collect_uefi_symbolic_coverage(start_processor_raw)?; self.get_and_write_testcase()?; self.post_timeout_event()?; } @@ -283,6 +352,8 @@ impl Tsffs { )?; } + self.collect_uefi_symbolic_coverage(processor)?; + self.get_and_write_testcase()?; self.post_timeout_event()?; @@ -325,6 +396,8 @@ impl Tsffs { )?; } + self.collect_uefi_symbolic_coverage(processor)?; + self.post_timeout_event()?; } diff --git a/src/lib.rs b/src/lib.rs index 95775e96..3064b29b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,16 +89,36 @@ use typed_builder::TypedBuilder; use versions::{Requirement, Versioning}; pub(crate) mod arch; +// `pub` (not `pub(crate)`): needed so the offline DWARF fixture test in +// `tests/dwarf_fixture.rs` (a separate cargo target/crate, since `[lib] test = false` +// means unit tests can't live inside this crate -- see that file's module doc) can +// reach `DwarfModule`/`DebugInfoModule`/`SourceCache` at all. `os` and `traits` +// stay `pub(crate)`; `dwarf::mod.rs` re-exports just the debug-info types +// (`SymbolInfo`/`LineInfo`) and the `DebugInfoModule` trait the test needs, +// instead of widening all of `os` (Windows kernel/PDB internals) or `traits` +// (which also holds the unrelated, and itself not-fully-public, +// `TracerDisassembler` trait). +pub mod dwarf; pub(crate) mod fuzzer; pub(crate) mod haps; pub(crate) mod interfaces; pub(crate) mod log; pub(crate) mod magic; pub(crate) mod os; -pub(crate) mod source_cov; +pub mod source_cov; pub(crate) mod state; pub(crate) mod tracer; pub(crate) mod traits; +// `pub` (not `pub(crate)`): needed so the offline UEFI module discovery fixture +// test in `tests/uefi_module_discovery_fixture.rs` (UCOV-M2, milestone-scope steps +// 1-2 -- see that file's module doc) can reach `uefi::{parse_module_list, +// UefiOsInfo}` at all. This is a separate cargo target/crate (this crate's `[lib]` +// section sets `test = false`, so `#[cfg(test)]` code inside `src/` is never +// compiled by `cargo test`), so it only sees this crate's `pub` API -- the same +// reason the sibling DWARF milestone widened `dwarf`/`source_cov` similarly. +// `util` (which `uefi` itself depends on for `PathSuffixIndex`) stays +// `pub(crate)`, since the test doesn't need to reach it directly. +pub mod uefi; pub(crate) mod util; /// The class name used for all operations interfacing with SIMICS @@ -445,6 +465,30 @@ pub(crate) struct Tsffs { /// Directory in which source files are located. Source files do not need to be arranged in /// the same directory structure as the compiled source, and are looked up by hash. pub symbolic_coverage_directory: PathBuf, + #[class(attribute(optional, default = false))] + /// Whether UEFI/SMM is being run in the simulation. When set with + /// `symbolic_coverage`, TSFFS collects source coverage for UEFI/SMM modules at + /// `HARNESS_START` by querying `uefi_tracker_object`'s loaded module list and + /// resolving each module's DWARF debug info under `uefi_debug_info_directory`. + /// `debuginfo_source_directory` must also point at the real local source tree + /// (e.g. the EDK2 checkout) for any source *lines* to be resolved -- symbols + /// resolve independently of it, but every one of them will have zero lines + /// (and thus never contribute to the coverage report) without it, since DWARF + /// line entries are resolved against `debuginfo_source_directory`, not + /// `uefi_debug_info_directory` (which only locates each module's own `.debug` + /// file, not its original source). + pub uefi: bool, + #[class(attribute(optional, default = String::new()))] + /// The Simics object path of the UEFI/SMM module tracker to query for the loaded + /// module list (e.g. `board.software.tracker.tracker_obj`, queried via its + /// `->maps` attribute), used when `uefi` is set. Board-specific; there is no + /// default. + pub uefi_tracker_object: String, + #[class(attribute(optional, default = lookup_file("%simics%")?.join("uefi-debug-info")))] + /// Local build-output directory that UEFI/SMM modules' embedded build-machine + /// paths (reported by `uefi_tracker_object`) are resolved against, to locate + /// each module's local `.debug` DWARF sidecar file, used when `uefi` is set. + pub uefi_debug_info_directory: PathBuf, /// Handle for the core simulation stopped hap stop_hap_handle: HapHandle, @@ -1004,7 +1048,7 @@ impl Tsffs { } pub fn save_symbolic_coverage(&mut self) -> Result<()> { - if self.symbolic_coverage_directory.is_dir() { + if !self.symbolic_coverage_directory.is_dir() { create_dir_all(&self.symbolic_coverage_directory)?; } @@ -1014,13 +1058,32 @@ impl Tsffs { self.symbolic_coverage_directory.display() ); - self.coverage.to_html(&self.symbolic_coverage_directory)?; - - debug!( - self.as_conf_object(), - "Symbolic coverage saved to {}", - self.symbolic_coverage_directory.display() - ); + // `Records::to_html` seeds its output tree from the records themselves, so + // if no source line was ever recorded (e.g. a short run whose covered code + // never lands inside a symbolicated module -- has been observed for real + // with UEFI/SMM coverage, whose HARNESS_START may fire before every module + // is loaded), it never creates a graph node for `output_directory` at all, + // and its own root-node lookup fails with `NodeNotFound` on that exact + // path. That's an empty-coverage outcome, not a real error, so it's + // reported and skipped rather than propagated as one. + match self.coverage.to_html(&self.symbolic_coverage_directory) { + Ok(()) => { + debug!( + self.as_conf_object(), + "Symbolic coverage saved to {}", + self.symbolic_coverage_directory.display() + ); + } + Err(lcov2::error::Error::NodeNotFound { ref path }) + if *path == self.symbolic_coverage_directory => + { + debug!( + self.as_conf_object(), + "No symbolic coverage was recorded this run; skipping HTML report generation" + ); + } + Err(e) => return Err(e.into()), + } Ok(()) } diff --git a/src/os/windows/debug_info.rs b/src/os/windows/debug_info.rs index b0dc7d9e..8204276d 100644 --- a/src/os/windows/debug_info.rs +++ b/src/os/windows/debug_info.rs @@ -20,7 +20,7 @@ use windows_sys::Win32::System::{ SystemServices::{FILE_NOTIFY_FULL_INFORMATION, IMAGE_DOS_HEADER}, }; -use crate::{os::DebugInfoConfig, source_cov::SourceCache}; +use crate::{os::DebugInfoConfig, source_cov::SourceCache, traits::DebugInfoModule}; use super::{ pdb::{CvInfoPdb70, Export}, @@ -470,6 +470,15 @@ impl ProcessModule { } } +impl DebugInfoModule for ProcessModule { + /// Delegates to `ProcessModule::intervals` so PDB-backed process modules can be + /// used interchangeably with other `DebugInfoModule` implementations (e.g. + /// DWARF-backed modules). + fn intervals(&mut self, source_cache: &SourceCache) -> Result>> { + ProcessModule::intervals(self, source_cache) + } +} + #[derive(Debug)] /// A process pub struct Process { @@ -675,3 +684,12 @@ impl Module { .collect()) } } + +impl DebugInfoModule for Module { + /// Delegates to `Module::intervals` so PDB-backed kernel modules can be used + /// interchangeably with other `DebugInfoModule` implementations (e.g. DWARF-backed + /// modules). + fn intervals(&mut self, source_cache: &SourceCache) -> Result>> { + Module::intervals(self, source_cache) + } +} diff --git a/src/source_cov/mod.rs b/src/source_cov/mod.rs index 6bdcb87a..42a1aba3 100644 --- a/src/source_cov/mod.rs +++ b/src/source_cov/mod.rs @@ -9,13 +9,18 @@ use md5::compute; use pdb::{FileChecksum, FileInfo}; use sha1::{Digest, Sha1}; use sha2::Sha256; -use simics::{debug, get_object}; -use typed_path::{TypedComponent, TypedPath, UnixComponent, WindowsComponent}; use walkdir::WalkDir; +use crate::util::path_suffix_index::PathSuffixIndex; + #[derive(Debug, Clone, Default)] pub struct SourceCache { - prefix_lookup: HashMap, PathBuf>, + // Path-component-suffix lookup, e.g. matching a DWARF/PDB-recorded source path + // against a locally-checked-out file by its longest matching path suffix. + // Extracted into `PathSuffixIndex` (`crate::util::path_suffix_index`), which is + // also used by the UEFI module debug-info resolver (`crate::uefi`), so the two + // don't duplicate this logic. + suffix_index: PathSuffixIndex, md5_lookup: HashMap, PathBuf>, sha1_lookup: HashMap, PathBuf>, sha256_lookup: HashMap, PathBuf>, @@ -26,7 +31,7 @@ impl SourceCache { where P: AsRef, { - let mut prefix_lookup = HashMap::new(); + let mut suffix_index = PathSuffixIndex::new(); let mut md5_lookup = HashMap::new(); let mut sha1_lookup = HashMap::new(); let mut sha256_lookup = HashMap::new(); @@ -46,32 +51,11 @@ impl SourceCache { md5_lookup.insert(md5, path.clone()); sha1_lookup.insert(sha1, path.clone()); sha256_lookup.insert(sha256, path.clone()); - let mut components = path - .components() - .filter_map(|c| { - if let std::path::Component::Normal(c) = c { - Some(c.to_string_lossy().to_string()) - } else { - None - } - }) - .collect::>(); - - // Create a list of component lists starting from the full path, then the full path - // minus the first component, then the full path minus the first two components, etc. - // This is used to create a lookup table for the source files. - while !components.is_empty() { - prefix_lookup.insert(components.clone(), path.clone()); - components.remove(0); - } - } - - if let Ok(o) = get_object("tsffs") { - debug!(o, "Cached {} source files", file_paths.len()); + suffix_index.insert(path); } Ok(Self { - prefix_lookup, + suffix_index, md5_lookup, sha1_lookup, sha256_lookup, @@ -79,35 +63,7 @@ impl SourceCache { } pub fn lookup_file_name_components(&self, file_name: &str) -> Option<&Path> { - let mut file_name_components = TypedPath::derive(&file_name.to_string().to_string()) - .components() - .filter_map(|c| match c { - TypedComponent::Unix(u) => { - if let UnixComponent::Normal(c) = u { - String::from_utf8(c.to_vec()).ok() - } else { - None - } - } - TypedComponent::Windows(w) => { - if let WindowsComponent::Normal(c) = w { - String::from_utf8(c.to_vec()).ok() - } else { - None - } - } - }) - .collect::>(); - - while !file_name_components.is_empty() { - if let Some(file_path) = self.prefix_lookup.get(&file_name_components) { - return Some(file_path); - } - - file_name_components.remove(0); - } - - None + self.suffix_index.lookup_str(file_name) } pub fn lookup_pdb(&self, file_info: &FileInfo, file_name: &str) -> Result> { @@ -130,4 +86,25 @@ impl SourceCache { .or_else(|| self.lookup_file_name_components(file_name)), }) } + + /// Look up a source file referenced from a DWARF line number program, mirroring + /// `lookup_pdb`. DWARF5 line-number program headers may carry an optional + /// `DW_LNCT_MD5` checksum entry per file (`gimli`'s `FileEntry::md5`, valid only + /// when `LineProgramHeader::file_has_md5` returns `true`); when present, we try + /// that checksum against the same MD5 table populated by `SourceCache::new` + /// (which hashes every candidate source file with MD5/SHA1/SHA256 up front). + /// DWARF doesn't specify SHA1/SHA256 file checksums, so there's nothing to try + /// there. If there's no MD5 (DWARF <= 4, or a DWARF5 producer that omitted it, + /// e.g. some EDK2 GCC5 builds), we fall back to the same format-agnostic + /// path-suffix lookup PDB uses. + pub fn lookup_dwarf(&self, md5: Option<&[u8; 16]>, file_name: &str) -> Result> { + Ok(match md5 { + Some(m) => self + .md5_lookup + .get(m.as_slice()) + .map(|p| p.as_path()) + .or_else(|| self.lookup_file_name_components(file_name)), + None => self.lookup_file_name_components(file_name), + }) + } } diff --git a/src/traits/mod.rs b/src/traits/mod.rs index 020e2e8e..a66e64ad 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -1,8 +1,13 @@ // Copyright (C) 2024 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -use crate::tracer::{CmpExpr, CmpType}; +use crate::{ + os::windows::debug_info::SymbolInfo, + source_cov::SourceCache, + tracer::{CmpExpr, CmpType}, +}; use anyhow::Result; +use intervaltree::Element; /// Trait for disassemblers of various architectures to implement to permit branch /// and compare tracing @@ -16,3 +21,19 @@ pub trait TracerDisassembler { fn cmp(&self) -> Vec; fn cmp_type(&self) -> Vec; } + +/// Trait implemented by debug-info backends (e.g. Windows PDB, DWARF/ELF) which can +/// resolve the symbols and source lines of a loaded module into lookup intervals +/// keyed by absolute runtime address range (`base + rva .. base + rva + size`). +/// +/// This allows callers to build a single interval tree covering modules backed by +/// different debug info formats (PDB for Windows kernel/PE modules, DWARF for +/// UEFI/SMM ELF modules, ...) without caring which backend produced each module's +/// symbols. +pub trait DebugInfoModule { + /// Resolve this module's procedures/subprograms and their source lines into + /// interval-tree elements, using `source_cache` to map embedded source file + /// references (by checksum, falling back to path suffix matching) to files on + /// the local filesystem. + fn intervals(&mut self, source_cache: &SourceCache) -> Result>>; +} diff --git a/src/uefi/mod.rs b/src/uefi/mod.rs new file mode 100644 index 00000000..1b5c8dce --- /dev/null +++ b/src/uefi/mod.rs @@ -0,0 +1,578 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +//! UEFI module discovery (UCOV-M2, milestone-scope steps 1-2). +//! +//! # Background +//! +//! Unlike Windows (`crate::os::windows`), Simics has no native C interface for +//! UEFI module discovery -- there is no `osa_target_info` (checked directly +//! against the Simics 6/7 headers). The confirmed-real mechanism, found via a +//! live investigation (real Simics 6.0.189 session, a real +//! checkpoint past DXE dispatch, 68 real loaded UEFI modules), is the +//! `uefi_fw_tracker` component's underlying C object's `maps` attribute. This +//! supersedes an earlier design that queried the tracker's `list-modules` CLI +//! command instead: `list-modules` itself calls `basename()` on the underlying +//! data before returning it, so it only ever yields a bare filename, never a +//! full path -- `tracker_obj`'s `maps` attribute is the same underlying data +//! with the full path intact. +//! +//! `maps` is read via `simics::{get_object, get_attribute}` (`SIM_get_attribute` +//! on the object `get_object(tracker_object)` resolves to), not via Simics's CLI +//! arrow-attribute syntax (`run_command("{tracker_object}->maps")`). An earlier +//! revision of this module used the `run_command` string-command path; live +//! validation in a live test session (a real boot, `HARNESS_START` firing well into DXE +//! dispatch with ~30 real modules already loaded) confirmed that path returns a +//! stale/near-empty result (3 elements, all `AttrValueType::Invalid`) at a point +//! in boot where a direct attribute read of the exact same object returns the +//! real, fully-populated list (67 real rows) -- the live data was always there; +//! the CLI string-command round-trip was the bug. +//! +//! This module implements only the two pieces of that spec that are testable +//! completely offline, with no live Simics session and no real BIOS/UEFI image: +//! +//! 1. [`parse_module_list`]: parse the `AttrValue`/`AttrValueType` shape +//! `tracker_obj->maps` returns into `(name, base, size, embedded_path)` +//! tuples. +//! 2. [`UefiOsInfo::resolve`]: given those tuples and a local build-root +//! directory, resolve each module's real local debug-info path. +//! +//! # Why `AttrValueType`, not `AttrValue`, as the parser's input type +//! +//! `simics::AttrValue` is a `#[repr(C)]` wrapper around the C `attr_value_t` +//! union (see `simics::api::base::attr_value`). Reading a real, already-populated +//! `AttrValue` (e.g. one actually returned by `get_attribute`) is safe pure memory +//! access with no FFI call (`AttrValue::as_heterogeneous_list`/`as_heterogeneous_dict` +//! just walk `private_u.list`/`private_u.dict` pointers). But *constructing* an +//! owned `AttrValue::List`/`AttrValue::Dict` from scratch (e.g. `AttrValue::list(n)`, +//! or the `TryFrom>`/`TryFrom>` impls that a hand-built fake +//! fixture would need) allocates through `SIM_alloc_attr_list`/`SIM_alloc_attr_dict` +//! -- real FFI entry points into `libsimics-common.dll`. Exactly like the +//! `get_object("tsffs")` call removed from `SourceCache::new` (see +//! `src/source_cov/mod.rs` and `tests/dwarf_fixture.rs`'s module doc), calling any +//! `SIM_*` entry point with no live Simics session hard-aborts the process, not +//! just returns `Err`. `AttrValueType` (the plain Rust tagged-union enum +//! `Invalid | Nil | Unsigned(u64) | Signed(i64) | Bool(bool) | String(String) | +//! Float(..) | Object(*mut ConfObject) | Data(Box<[u8]>) | List(Vec) | +//! Dict(BTreeMap)`) has no such constructors -- its variants are built +//! with plain Rust syntax, no FFI at all -- so it is what this module's parser +//! takes, and what the offline tests construct fixtures as. At a real call site, +//! converting the real `AttrValue` returned by `get_attribute` into `AttrValueType` +//! via `.into()` (`impl From for AttrValueType`) is the safe, pure-read +//! conversion described above; this module never needs to go the other direction. +//! +//! # Confirmed shape of `tracker_obj->maps`' return value +//! +//! Unlike the superseded `list-modules`-based design (which had to *assume* a +//! shape, since it was never actually queried live), this shape is a confirmed +//! fact, captured from a real live tracker dump reached from Rust via +//! `get_attribute`, using the exact same FFI path this module documents above: +//! +//! - The top-level value is a `List` of rows. +//! - Each row is itself a positional `List` of exactly 7 elements (**not** a dict +//! keyed by column name, unlike the superseded design): +//! +//! ```text +//! [loaded_address, loaded_size, , adjusted_address, adjusted_size, , full_path_string] +//! ``` +//! +//! A real captured example row: +//! +//! ```text +//! [3744034816, 189184, True, 3744034816, 189184, True, +//! '/home/user/bios-x58i/project/workspace/Build/SimicsOpenBoardPkg/BoardX58Ich10/DEBUG_GCC/X64/MdeModulePkg/Core/Dxe/DxeMain/DEBUG/DxeCore.efi'] +//! ``` +//! +//! This module reads only 3 of the 7 elements: +//! - index 0 (`loaded_address`) -> `Unsigned`/`Signed`: the module's loaded/base +//! address. (`index 3`, `adjusted_address`, is a distinct post-relocation +//! address also present in the real data, but out of scope for this +//! milestone -- nothing downstream of this module currently consumes it.) +//! - index 1 (`loaded_size`) -> `Unsigned`/`Signed`: the module's size in bytes. +//! - index 6 (`full_path_string`) -> `String`, **or absent**: the module's +//! full embedded build-machine path. Some rows genuinely have no path at +//! all -- real "unknown"/unresolved modules that the tracker could not +//! identify (mirroring `module_load.py`'s own upstream guard for +//! `m['image'] is None`). Confirmed live against the real, +//! full 68-row `tracker_obj->maps` capture (not just the samples from the +//! initial investigation): exactly one real row (of 68) is genuinely +//! pathless, and its Python value is `None`, not an empty string -- which +//! `simics::AttrValueType::from(AttrValue)` (`is_nil()` checked first) +//! converts to `AttrValueType::Nil`, **not** `AttrValueType::String(String::new())`. +//! An earlier revision of this module assumed the latter (an empty +//! string) and hard-errored on the real `Nil` case; [`list_get_string_or_nil`] +//! now accepts both `Nil` and (defensively) an empty `String` as "no +//! path". Unlike the superseded design, there is no separate "name" field +//! at all: with this row shape, a module's short display name must be +//! *derived* from the full path via [`Path::file_name`] when a path is +//! present, e.g. `DxeCore.efi` from the example above. See +//! [`parse_module_row`] for how a missing path is named instead +//! (``). +//! - indices 2, 4, 5 (the two booleans and `adjusted_size`) are not read by +//! this module; they are out of scope for this milestone. +//! +//! A real, observed duplicate-name case -- two loaded instances of +//! `BootScriptExecutorDxe.efi`, at two different addresses, seen both via +//! `list-modules` and via `tracker_obj->maps` -- was re-examined under this +//! richer source and turned out to have the **identical** full path for both +//! instances (the same build loaded twice, not two different binaries). So for +//! that specific real case, path-suffix disambiguation is unnecessary -- any +//! single matching local file is correct for both addresses. This does *not* +//! prove disambiguation is unnecessary in general: two genuinely different +//! builds sharing a basename (e.g. two different EDK2 package subdirectories) +//! remains a real possibility this module still needs to handle correctly, since +//! that scenario is not disproven for all cases, just this one -- see +//! [`UefiOsInfo::resolve`]'s doc comment. + +use std::{ + fs::read, + path::{Path, PathBuf}, +}; + +use anyhow::{anyhow, bail, Result}; +use intervaltree::Element; +use object::File as ObjectFile; +use simics::{free_attribute, get_attribute, get_object, AttrValue, AttrValueType}; +use tracing::{debug, warn}; +use walkdir::WalkDir; + +use crate::{ + dwarf::{DebugInfoModule, DwarfModule, SymbolInfo}, + source_cov::SourceCache, + util::path_suffix_index::PathSuffixIndex, +}; + +/// Placeholder name used for a module whose row has no path at all (a real +/// "unknown"/unresolved module -- see the module doc comment's "Confirmed shape" +/// section). Matches the placeholder `list-modules` itself used for such rows in +/// the real capture that motivated this design. +pub const UNKNOWN_MODULE_NAME: &str = ""; + +/// Parse the `AttrValueType` shape `tracker_obj->maps` returns (see the module +/// doc comment for the confirmed shape) into `(name, base, size, embedded_path)` +/// tuples, where `name` is the bare filename extracted from `embedded_path`, or +/// [`UNKNOWN_MODULE_NAME`] when a row has no path. +/// +/// Skips any top-level `AttrValueType::Invalid` entry rather than erroring the +/// whole batch over it. Confirmed live (a real fuzzing run whose +/// `HARNESS_START` fires early in DXE dispatch, well before all modules are +/// loaded): `maps` can return a list containing `Invalid` entries -- a reserved +/// but not-yet-populated slot in the tracker's underlying storage -- alongside +/// well-formed 7-element rows for the modules actually loaded so far. This is +/// distinct from a genuinely pathless *module* row (still a well-formed 7-element +/// list, just with a `Nil` path at index 6 -- see [`parse_module_row`]), so it's +/// handled separately, before a row is assumed to be a `List` at all. +pub fn parse_module_list(value: &AttrValueType) -> Result> { + let AttrValueType::List(rows) = value else { + bail!( + "expected tracker_obj->maps result to be an AttrValueType::List, got {:?}", + value + ); + }; + + rows.iter() + .filter(|row| !matches!(row, AttrValueType::Invalid)) + .map(parse_module_row) + .collect() +} + +/// Parse a single row of the confirmed `tracker_obj->maps` shape: +/// `[loaded_address, loaded_size, , adjusted_address, adjusted_size, +/// , full_path_string]`. Only indices 0 (`base`), 1 (`size`), and 6 +/// (`embedded_path`) are read; see the module doc comment for why the others are +/// out of scope. +fn parse_module_row(row: &AttrValueType) -> Result<(String, u64, u64, PathBuf)> { + let AttrValueType::List(elements) = row else { + bail!( + "expected each tracker_obj->maps row to be an AttrValueType::List, got {:?}", + row + ); + }; + + let Ok([loaded_address, loaded_size, _, _adjusted_address, _adjusted_size, _, full_path]) = + <[AttrValueType; 7]>::try_from(elements.clone()) + else { + bail!( + "expected each tracker_obj->maps row to have exactly 7 elements, got {}: {:?}", + elements.len(), + row + ); + }; + + let base = list_get_unsigned(&loaded_address, 0)?; + let size = list_get_unsigned(&loaded_size, 1)?; + let embedded_path_str = list_get_string_or_nil(&full_path, 6)?; + + // A row with a genuinely unresolved module has no path at all -- confirmed + // live against the real, full 68-row capture to be represented as + // `AttrValueType::Nil` (Python `None`), not an empty string -- see the + // module doc comment's "Confirmed shape" section. `list_get_string_or_nil` + // also defensively accepts an empty string as "no path", in case some + // other tracker/board configuration ever produces one instead of `Nil`. + // Treat either as "no path" and fall back to a fixed placeholder name + // rather than deriving an empty/panic-inducing name from it. + let (name, embedded_path) = match embedded_path_str.filter(|s| !s.is_empty()) { + None => (UNKNOWN_MODULE_NAME.to_string(), PathBuf::new()), + Some(embedded_path_str) => { + let embedded_path = PathBuf::from(&embedded_path_str); + let name = embedded_path + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + .ok_or_else(|| { + anyhow!( + "embedded path {:?} in tracker_obj->maps row has no file name component", + embedded_path + ) + })?; + (name, embedded_path) + } + }; + + Ok((name, base, size, embedded_path)) +} + +/// Read a positional `tracker_obj->maps` row element expected to be an unsigned +/// (or non-negative signed) integer, e.g. `loaded_address`/`loaded_size`. +/// `index` is only used to produce a helpful error message. +fn list_get_unsigned(element: &AttrValueType, index: usize) -> Result { + match element { + AttrValueType::Unsigned(u) => Ok(*u), + AttrValueType::Signed(s) if *s >= 0 => Ok(*s as u64), + other => bail!( + "expected tracker_obj->maps row element {index} to be an unsigned integer, got {:?}", + other + ), + } +} + +/// Read a positional `tracker_obj->maps` row element expected to be either a +/// string or absent, i.e. `full_path_string`. Returns `Ok(None)` for a +/// genuinely pathless row -- confirmed live (see the module doc comment) to +/// arrive as `AttrValueType::Nil` (Python `None`), not an empty string, though +/// an empty string is also accepted defensively and treated the same as `Nil`. +/// `index` is only used to produce a helpful error message. +fn list_get_string_or_nil(element: &AttrValueType, index: usize) -> Result> { + match element { + AttrValueType::String(s) => Ok(Some(s.clone())), + AttrValueType::Nil => Ok(None), + other => bail!( + "expected tracker_obj->maps row element {index} to be a String or Nil, got {:?}", + other + ), + } +} + +/// UEFI/SMM module debug-info info, resolved from a `tracker_obj->maps` dump plus +/// a local build-root directory. +/// +/// Unlike `crate::os::windows::WindowsOsInfo`, which keys most of its state by +/// CPU index (`HashMap`) because Windows tracks per-CPU current +/// process/module state, UEFI/SMM has no such per-CPU context -- it's a single +/// flat address space/module list -- so this holds a flat `Vec` instead. +#[derive(Debug, Clone, Default)] +pub struct UefiOsInfo { + /// Resolved modules: `(name, base, resolved_local_debug_path)`. Feeding this + /// into the DWARF milestone's `DwarfModule::new(name, base, object)` (which + /// needs the `object::File` parsed from the path at `resolved_local_debug_path`) + /// is explicitly out of scope for this milestone. + pub modules: Vec<(String, u64, PathBuf)>, +} + +impl UefiOsInfo { + /// Resolve local debug-info paths for a parsed module list against a local + /// build-root directory. + /// + /// Under the superseded `list-modules`-based design, an embedded path was + /// assumed to be a rare bonus (`list-modules` itself only ever exposed a + /// bare basename, since it calls `basename()` internally), so path-suffix + /// matching was a secondary "if we ever get a path" capability and + /// bare-stem search was the primary path. `tracker_obj->maps` inverts that: + /// a full embedded path is the *common* case (every genuinely-identified + /// module has one; see the module doc comment), so path-suffix matching is + /// now the primary resolution path. + /// + /// For each module: + /// 1. If the module has no embedded path at all (a genuinely pathless row + /// -- a real "unknown"/unresolved module, not merely a basename-only + /// row), fail that module explicitly rather than guessing -- see + /// [`resolve_one`]. + /// 2. Otherwise, try matching the module's embedded path against a + /// [`PathSuffixIndex`] built over `build_root`, longest suffix first. + /// This disambiguates same-named modules whose embedded paths differ in + /// a parent directory that also exists locally (e.g. two different EDK2 + /// package subdirectories) -- the primary resolution path, and expected + /// to resolve the overwhelming majority of real modules outright, since + /// they carry a full embedded path. + /// 3. If that finds nothing (e.g. the embedded path's parent directories + /// don't exist locally under any matching name), fall back to a + /// bare-filename-stem search (`rglob`-equivalent walk) under + /// `build_root`. + /// 4. If, after both, more than one candidate remains ambiguous, log a + /// warning and take the first (sorted, for determinism) candidate -- + /// "fail open", the spec's own explicit decision, rather than erroring out + /// or dropping the module. In practice this fires rarely now: the one + /// real observed duplicate-name case investigated (two + /// `BootScriptExecutorDxe.efi` instances) turned out to share an + /// identical full path (the same build loaded twice), which step 2 + /// resolves outright with no ambiguity at all. + pub fn resolve

(modules: &[(String, u64, u64, PathBuf)], build_root: P) -> Result + where + P: AsRef, + { + let build_root = build_root.as_ref(); + // `PathSuffixIndex::build_from_dir` does not hash file contents (unlike + // `SourceCache::new`), which is the whole point of factoring it out of + // `SourceCache` -- see `src/util/path_suffix_index.rs`'s module doc. + let index = PathSuffixIndex::build_from_dir(build_root)?; + + let mut resolved = Vec::with_capacity(modules.len()); + + for (name, base, _size, embedded_path) in modules { + // Skip (with a warning), rather than fail the entire batch over, + // any single module that can't be resolved. Confirmed live + // (a real boot): a real 67-module tracker_obj->maps + // capture always has at least one genuinely pathless "" + // module (see resolve_one's doc comment) -- letting that one + // module's error abort the whole call would discard source + // coverage for the other 66 real, resolvable modules too. + match resolve_one(&index, build_root, name, embedded_path) { + Ok(local_path) => resolved.push((name.clone(), *base, local_path)), + Err(e) => { + warn!("skipping unresolvable module {name:?}: {e}"); + } + } + } + + Ok(Self { modules: resolved }) + } +} + +/// Resolve a single module's local debug-info path. See +/// [`UefiOsInfo::resolve`]'s doc comment for the algorithm. +fn resolve_one( + index: &PathSuffixIndex, + build_root: &Path, + name: &str, + embedded_path: &Path, +) -> Result { + if embedded_path.as_os_str().is_empty() { + // A genuinely pathless row -- a real "unknown"/unresolved module (see + // `parse_module_row`/`UNKNOWN_MODULE_NAME`), not merely a basename-only + // row (that case can't arise from `tracker_obj->maps`: every row that + // has a path at all has a *full* path, never just a basename). There is + // no embedded path to suffix-match against, and no real file name to + // bare-stem-search by either -- `name` here is just the + // `UNKNOWN_MODULE_NAME` placeholder, not a real file name, so searching + // for it would either find nothing or silently match an unrelated local + // file that happens to share that placeholder name. Fail this module + // explicitly instead. + bail!( + "module {name:?} has no embedded path (unknown/unresolved module); cannot resolve \ + local debug info" + ); + } + + // Primary resolution path (see `UefiOsInfo::resolve`'s doc comment for why + // this now comes first): match the module's full embedded path against a + // `PathSuffixIndex` built over `build_root`, longest suffix first. + if let Some(local_path) = index.lookup_str_unambiguous(&embedded_path.to_string_lossy()) { + debug!( + "resolved module {name:?} via path-suffix match: {embedded_path:?} -> {local_path:?}" + ); + return Ok(local_path.to_path_buf()); + } + + // Fall back to a bare-filename-stem search, since the suffix index found no + // match at all (e.g. the embedded path's parent directories don't exist + // locally under any name that matches). This is now specifically a + // fallback for that case, not the primary path -- see `UefiOsInfo::resolve`'s + // doc comment. + let stem = embedded_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(name); + + let mut candidates = find_by_stem(build_root, stem)?; + // Sort for deterministic "take the first" behavior below. + candidates.sort(); + + match candidates.len() { + 0 => bail!( + "no local debug info found for module {:?} (embedded path {:?}, build root {:?})", + name, + embedded_path, + build_root + ), + 1 => { + debug!( + "resolved module {name:?} via bare-stem fallback: {embedded_path:?} -> {:?}", + candidates[0] + ); + Ok(candidates.remove(0)) + } + n => { + // Fail open: log and take the first (sorted) match rather than + // erroring out or dropping the module -- this is the spec's own + // explicit decision, matching the `warn!`/`debug!` logging style + // already used for similar disambiguation situations in + // `crate::os::windows` (see e.g. `src/os/windows/structs.rs`'s + // module-lookup logging). Unlike those call sites, this uses the + // plain `tracing` crate rather than `simics::warn!`/`simics::debug!`: + // the latter require a live `ConfObject` (e.g. + // `get_object("tsffs")?`) and call real `SIM_*` FFI entry points, + // which -- exactly like the bug fixed in `SourceCache::new` -- hard- + // abort the process with no live Simics session, which is + // unconditionally true for this milestone's offline scope. + warn!( + "ambiguous local debug info for module {name:?}: {n} candidates matched stem \ + {stem:?} with no unique path-suffix match (embedded path {embedded_path:?}); \ + taking the first candidate (fail open): {:?}", + candidates[0] + ); + Ok(candidates.remove(0)) + } + } +} + +/// Find every local file under `root` whose file stem (file name without its +/// final extension) matches `stem`. +fn find_by_stem(root: &Path, stem: &str) -> Result> { + Ok(WalkDir::new(root) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_file()) + .filter(|entry| entry.path().file_stem().and_then(|s| s.to_str()) == Some(stem)) + .map(|entry| entry.path().to_path_buf()) + .collect()) +} + +/// Query `tracker_object`'s `->maps` attribute (see the module doc comment for the +/// confirmed row shape), resolve each discovered module's local debug info against +/// `build_root`, and load each resolved module's DWARF debug info into interval-tree +/// elements -- the milestone-3 glue between this module's discovery (UCOV-M2) and +/// `crate::dwarf::DwarfModule` (UCOV-M1), previously left explicitly out of scope by +/// both (see this module's and `crate::dwarf`'s doc comments). +/// +/// UEFI/SMM has no CR3-equivalent per-process address-space switch to key a refresh +/// off (unlike `crate::os::windows::WindowsOsInfo::collect`, re-run on every CR3 +/// write): all tracked modules are loaded by the time `HARNESS_START` fires (DXE +/// dispatch completes before the harness/boot-menu stage), and TSFFS repeatedly +/// restores one snapshot afterward rather than switching address spaces. So this is +/// meant to be called exactly once, at `HARNESS_START`, not on a recurring trigger. +pub fn collect_symbols

( + tracker_object: &str, + build_root: P, + source_cache: &SourceCache, +) -> Result>> +where + P: AsRef, +{ + // Read `maps` via `SIM_get_attribute` (`get_object` + `get_attribute`), not + // `run_command("{tracker_object}->maps")`. The CLI string-command path was + // confirmed live (a real boot) to return a near-empty/stale + // result (3 elements, all `Invalid`) at a point in boot where a direct + // attribute read of the exact same object/attribute already returns the + // real, fully-populated list (67 real rows) -- the live data is there: + // `run_command`'s string-command round-trip was the actual bug, not a + // tracker-timing issue. + let tracker_conf_object = get_object(tracker_object)?; + let maps = get_attribute(tracker_conf_object, "maps")?; + + // `AttrValueType::from(AttrValue)` (equivalently, plain `.into()`) recurses + // into nested lists via `AttrValue::as_list::`, which -- despite + // the crate's own "Rust vectors cannot be heterogeneous" comment suggesting + // otherwise -- requires every element of a list to share the same + // `private_kind` before converting any of them, silently returning `None` + // (and thus `AttrValueType::Invalid`) for the whole list otherwise. 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 turned every real row into `AttrValueType::Invalid`, confirmed + // live: a real 67-row `maps` converted this way parsed as 0 module + // rows, no errors, no warnings, just quietly wrong. `as_heterogeneous_list` + // has no such check, so extract each row as a raw `AttrValue` first (via + // `as_list::`, an identity conversion -- fine, since the *outer* + // list is genuinely homogeneous), then convert each row with + // `as_heterogeneous_list`, which is exactly what a 7-element heterogeneous + // row needs. + let raw_rows: Vec = maps + .as_list() + .ok_or_else(|| anyhow!("expected tracker_obj->maps to be an AttrValue list"))?; + + let value = AttrValueType::List( + raw_rows + .iter() + .map(|row| { + row.as_heterogeneous_list() + .map(AttrValueType::List) + .ok_or_else(|| anyhow!("expected each tracker_obj->maps row to be a list")) + }) + .collect::>>()?, + ); + + free_attribute(maps)?; + + let rows = parse_module_list(&value)?; + let resolved = UefiOsInfo::resolve(&rows, build_root)?; + + let mut elements = Vec::new(); + + for (name, base, local_path) in &resolved.modules { + // EDK2 GCC5 builds place a module's stripped `.efi` PE image and its + // unstripped ELF+DWARF `.debug` sidecar side by side in the same build + // output directory (see `crate::dwarf`'s module doc comment). `local_path` + // here is the local mirror of the module's *embedded* (`.efi`) path + // resolved by `UefiOsInfo::resolve` (confirmed by + // `tests/uefi_module_discovery_fixture.rs`, which resolves against a + // locally-mirrored `.efi` file, not a `.debug` one), so the sidecar this + // milestone actually needs is simply that path with its extension swapped. + let debug_path = local_path.with_extension("debug"); + + let bytes = match read(&debug_path) { + Ok(bytes) => bytes, + Err(e) => { + if let Ok(o) = get_object("tsffs") { + simics::warn!( + o, + "No DWARF debug info for UEFI module {name:?} (expected \ + {debug_path:?}, resolved from embedded path via \ + {local_path:?}): {e}" + ); + } + continue; + } + }; + + let object_file = match ObjectFile::parse(bytes.as_slice()) { + Ok(object_file) => object_file, + Err(e) => { + if let Ok(o) = get_object("tsffs") { + simics::warn!( + o, + "Failed to parse DWARF debug info for UEFI module {name:?} \ + at {debug_path:?}: {e}" + ); + } + continue; + } + }; + + let mut module = DwarfModule::new(name.clone(), *base, object_file); + + match module.intervals(source_cache) { + Ok(module_elements) => elements.extend(module_elements), + Err(e) => { + if let Ok(o) = get_object("tsffs") { + simics::warn!( + o, + "Failed to resolve source coverage intervals for UEFI \ + module {name:?} at {debug_path:?}: {e}" + ); + } + } + } + } + + Ok(elements) +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 8cd191d6..72aab541 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -5,6 +5,8 @@ use anyhow::Result; use simics::api::{get_attribute, get_object}; use simics::FromAttrValueList; +pub mod path_suffix_index; + #[derive(Debug, Clone, FromAttrValueList)] pub(crate) struct MicroCheckpointInfo { #[allow(unused)] diff --git a/src/util/path_suffix_index.rs b/src/util/path_suffix_index.rs new file mode 100644 index 00000000..3b36687a --- /dev/null +++ b/src/util/path_suffix_index.rs @@ -0,0 +1,206 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +//! A small, standalone "path-suffix index" primitive. +//! +//! Extracted out of `crate::source_cov::SourceCache`, which originally built and +//! queried exactly this structure (its `prefix_lookup` field and +//! `lookup_file_name_components` method) inline, coupled to `SourceCache::new`'s +//! per-file MD5/SHA1/SHA256 content hashing. The UEFI module discovery milestone +//! (UCOV-M2) needs the same "resolve an incoming path by longest matching path- +//! component suffix against a local directory tree" behavior over `.debug`/`.efi` +//! binaries, which don't need (and shouldn't pay the cost of) content hashing. +//! Factoring this out lets both `SourceCache` and the UEFI resolver +//! (`crate::uefi::resolve_debug_info`) share the same logic without either paying +//! for the other's unrelated work. +//! +//! # How it works +//! +//! Every local file path under a root directory is broken into its "normal" path +//! components (i.e. excluding roots, drive prefixes, `.`/`..`). For a path with +//! components `[a, b, c, d]`, every suffix is indexed: `[a, b, c, d]`, `[b, c, d]`, +//! `[c, d]`, `[d]`. Looking up an incoming path tries the same suffixes, longest +//! first, against that index -- so a fully-qualified incoming path that shares a +//! long tail with exactly one local file (e.g. `.../PkgA/.../DEBUG/Foo.efi` vs +//! `.../PkgB/.../DEBUG/Foo.efi`, when only one of `PkgA`/`PkgB` also exists as a +//! local directory name) disambiguates correctly, while a bare filename still +//! falls back to matching on just `[d]` if nothing longer matches. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use anyhow::Result; +use typed_path::{TypedComponent, TypedPath, UnixComponent, WindowsComponent}; +use walkdir::WalkDir; + +/// An index of local file paths, keyed by every suffix of their path components, +/// supporting "longest matching suffix" lookups. +/// +/// Each suffix key maps to *every* local path sharing that suffix (not just the +/// most-recently-inserted one), so callers can tell a genuinely unique match from +/// an ambiguous one -- see [`PathSuffixIndex::lookup_components_unambiguous`]. +#[derive(Debug, Clone, Default)] +pub struct PathSuffixIndex { + suffixes: HashMap, Vec>, +} + +impl PathSuffixIndex { + /// Construct an empty index. Use [`PathSuffixIndex::insert`] to populate it + /// incrementally (e.g. from a caller's own directory walk that is already doing + /// other per-file work, like `SourceCache::new`'s content hashing), or + /// [`PathSuffixIndex::build_from_dir`] to walk a directory and populate it in + /// one call with no other per-file work. + pub fn new() -> Self { + Self::default() + } + + /// Walk `root` and index every file found under it. This does not read file + /// contents at all (unlike `SourceCache::new`), so it's cheap to use over + /// directories of large binaries (`.debug`/`.efi`) that don't need hashing. + pub fn build_from_dir

(root: P) -> Result + where + P: AsRef, + { + let mut index = Self::new(); + + for entry in WalkDir::new(root) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_file()) + { + index.insert(entry.path()); + } + + Ok(index) + } + + /// Index a single local file path under every suffix of its normal path + /// components. + pub fn insert(&mut self, path: &Path) { + let mut components = Self::normal_components_of_path(path); + + // Insert the full component list, then progressively drop the first + // (leftmost/outermost) component and insert the remainder, down to just the + // file name. This means every suffix of the path is a key, and the longest + // key that matches an incoming path is the most specific match. + while !components.is_empty() { + self.suffixes + .entry(components.clone()) + .or_default() + .push(path.to_path_buf()); + components.remove(0); + } + } + + /// Find the candidates (every local path sharing that exact suffix) for the + /// longest matching suffix of `components`, trying progressively-shortened + /// suffixes (longest first, i.e. dropping leading components one at a time) + /// until some suffix length has at least one candidate. + fn lookup_candidates(&self, components: &[String]) -> Option<&[PathBuf]> { + let mut components = components.to_vec(); + + while !components.is_empty() { + if let Some(candidates) = self.suffixes.get(&components) { + return Some(candidates); + } + + components.remove(0); + } + + None + } + + /// Best-effort lookup: returns *some* local path matching the longest matching + /// suffix, without regard for whether other local paths also share that exact + /// suffix (in which case one is picked arbitrarily, but deterministically for a + /// given index). This matches the pre-refactor behavior of `SourceCache`'s own + /// inline lookup, which used a plain last-insert-wins + /// `HashMap, PathBuf>` and never detected ambiguity; source file + /// basename collisions across a source tree are assumed rare enough not to need + /// explicit disambiguation for that caller. Use + /// [`PathSuffixIndex::lookup_components_unambiguous`] instead when ambiguity + /// must be detected rather than silently resolved. + pub fn lookup_components(&self, components: &[String]) -> Option<&Path> { + self.lookup_candidates(components) + .and_then(|candidates| candidates.first()) + .map(|p| p.as_path()) + } + + /// Convenience wrapper over [`PathSuffixIndex::lookup_components`] that accepts + /// an incoming path as a plain string, which may use either Unix or Windows + /// path separators (e.g. an embedded build-machine path recorded on a different + /// OS than this one is running on) -- exactly the case `SourceCache` originally + /// handled via `typed_path`. + pub fn lookup_str(&self, path_str: &str) -> Option<&Path> { + self.lookup_components(&Self::normal_components_of_str(path_str)) + } + + /// Strict lookup: like [`PathSuffixIndex::lookup_components`], but a match is + /// only returned if the longest matching suffix is *unambiguous* (exactly one + /// local path shares it). An ambiguous suffix stops the search immediately + /// (returns `None`) rather than falling through to check a shorter suffix -- + /// a shorter suffix's candidate set is always a superset of a longer suffix's + /// (every path sharing the longer suffix also shares every shorter suffix of + /// it), so a shorter suffix can never be less ambiguous. + /// + /// This is what the UEFI module discovery resolver + /// (`crate::uefi::resolve_one`) uses: it needs to know when suffix-matching + /// genuinely couldn't disambiguate two same-named modules, so it can fall back + /// to a different strategy (bare-stem search) instead of silently guessing. + pub fn lookup_components_unambiguous(&self, components: &[String]) -> Option<&Path> { + match self.lookup_candidates(components) { + Some([single]) => Some(single.as_path()), + _ => None, + } + } + + /// String-accepting convenience wrapper over + /// [`PathSuffixIndex::lookup_components_unambiguous`], analogous to + /// [`PathSuffixIndex::lookup_str`]. + pub fn lookup_str_unambiguous(&self, path_str: &str) -> Option<&Path> { + self.lookup_components_unambiguous(&Self::normal_components_of_str(path_str)) + } + + /// Decompose a local, real `Path` into its normal ("real name") components as + /// strings, dropping roots/prefixes/`.`/`..`. + fn normal_components_of_path(path: &Path) -> Vec { + path.components() + .filter_map(|c| { + if let std::path::Component::Normal(c) = c { + Some(c.to_string_lossy().to_string()) + } else { + None + } + }) + .collect() + } + + /// Decompose a path-like string, which may be Unix- or Windows-style, into its + /// normal components as strings. Uses `typed_path` since incoming embedded + /// paths may have been recorded on a different OS than this one, so plain + /// `std::path::Path` component parsing (which assumes the host OS's separator + /// conventions) is not reliable for them. + fn normal_components_of_str(path_str: &str) -> Vec { + TypedPath::derive(&path_str.to_string()) + .components() + .filter_map(|c| match c { + TypedComponent::Unix(u) => { + if let UnixComponent::Normal(c) = u { + String::from_utf8(c.to_vec()).ok() + } else { + None + } + } + TypedComponent::Windows(w) => { + if let WindowsComponent::Normal(c) = w { + String::from_utf8(c.to_vec()).ok() + } else { + None + } + } + }) + .collect() + } +} diff --git a/tests/dwarf_fixture.rs b/tests/dwarf_fixture.rs new file mode 100644 index 00000000..6f2b0776 --- /dev/null +++ b/tests/dwarf_fixture.rs @@ -0,0 +1,232 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end, offline test of the DWARF/ELF debug-info backend +//! (`tsffs::dwarf::DwarfModule`, Milestone 1 of the DWARF source-coverage spec) +//! against a synthetic fixture, entirely without a live Simics session. +//! +//! This lives under `tests/` (an integration test / separate cargo crate) rather +//! than as a `#[cfg(test)]` module inside `src/dwarf/mod.rs` because this crate's +//! `[lib]` section sets `test = false`, which disables the implicit unit-test +//! harness for the library target specifically; `#[cfg(test)]` code inside `src/` +//! is therefore never compiled by any `cargo test` invocation for this crate. +//! Integration tests under `tests/` are a separate cargo target and are not +//! affected by that setting -- confirmed by first landing a trivial +//! `assert_eq!(2 + 2, 4)` test here and observing `cargo test` actually attempt to +//! build and run it (it got as far as the link step, which fails on this dev +//! machine for a pre-existing, environmental reason unrelated to this test or to +//! phase 1's code -- see the module-level `NOTE` below). +//! +//! Because `tests/` integration tests are a separate crate, they only see this +//! crate's `pub` API. Before this change, `dwarf`, `traits`, and `source_cov` were +//! all `pub(crate)` (nothing in this crate had any public API at all), which would +//! have made this test impossible to write without a redesign. The minimal fix +//! (see `src/lib.rs` and `src/dwarf/mod.rs`) widens only `dwarf` and `source_cov` +//! to `pub`; `os` (Windows kernel/PDB internals) and `traits` (which also holds the +//! unrelated `TracerDisassembler` trait) stay `pub(crate)`, with `dwarf::mod.rs` +//! instead re-exporting just `SymbolInfo`/`LineInfo`/`DebugInfoModule` as +//! `tsffs::dwarf::{SymbolInfo, LineInfo, DebugInfoModule}`. +//! +//! # Fixture +//! +//! `tests/fixtures/dwarf/X509CertVerify.c` is a small, synthetic C file (not real +//! EDK2/UEFI source) compiled and linked with real gcc, producing +//! `tests/fixtures/dwarf/X509CertVerify.debug`: a genuine ELF+DWARF binary, +//! analogous to a real EDK2 GCC5-built UEFI module's per-module `.debug` sidecar +//! file. Exact commands used to produce it: +//! +//! ```text +//! gcc -g -O0 -ffreestanding -fno-stack-protector -fno-builtin \ +//! -c X509CertVerify.c -o X509CertVerify.o +//! ld -o X509CertVerify.debug X509CertVerify.o --entry=X509VerifyCert \ +//! --section-start=.text=0x240 +//! ``` +//! +//! `--section-start=.text=0x240` gives the linked ELF a non-zero link-time `.text` +//! VMA (`0x240`), matching the real EDK2 GCC5 convention where DWARF addresses are +//! relative to a small non-zero link base, not `0`. This is exactly the "base + +//! link-time addr" arithmetic `DwarfModule::intervals` performs (see the doc +//! comment at the top of `src/dwarf/mod.rs`). +//! +//! Ground truth below was confirmed against the checked-in fixture with: +//! +//! ```text +//! objdump -h X509CertVerify.debug # .text: VMA 0000000000000240 +//! nm --print-size X509CertVerify.debug # 000000000000028c 000000000000004f T X509VerifyCert +//! readelf --debug-dump=decodedline X509CertVerify.debug +//! ``` +//! +//! which reported (edited to the rows falling inside `X509VerifyCert`'s +//! `[0x28c, 0x2db)` range): +//! +//! ```text +//! X509CertVerify.c 49 0x28c x +//! X509CertVerify.c 50 0x2a4 x +//! X509CertVerify.c 52 0x2bb x +//! X509CertVerify.c 53 0x2c2 x +//! X509CertVerify.c 56 0x2c9 x +//! X509CertVerify.c 56 0x2cf x +//! X509CertVerify.c 58 0x2d5 x +//! X509CertVerify.c 59 0x2d9 x +//! ``` +//! +//! (Lines 37-45 in the same decoded table belong to the other function in the +//! fixture, `HashCertificateBytes`, which shares the compilation unit/line +//! program but falls outside `X509VerifyCert`'s address range and so is +//! correctly excluded by `DwarfModule::intervals`.) +//! +//! # NOTE: no live Simics session was used to validate this +//! +//! The DWARF source-coverage spec's Milestone 1 description originally envisioned +//! cross-checking against a real BIOS `.debug` module through a live Simics +//! session (`sym-source`/`sym-function`) via the Simics TCF debugger. No such +//! session (and no real EDK2-built `.debug` file) is available in this offline +//! environment, so this test instead validates the parsing chain (`object` + +//! `gimli` + this crate's DIE/line-program walk) against a synthetic fixture's own +//! known ground truth, confirmed independently via `objdump`/`nm`/`readelf` above. +//! Cross-checking against a real BIOS module through an actual Simics session +//! remains open, to be done later by whoever has Simics and a real `.debug` file. +//! +//! Separately: linking this crate with the default `x86_64-pc-windows-msvc` host +//! target fails (`link.exe` exit code 1107, "invalid or corrupt file") because +//! `simics-build-utils` emits a `cargo:rustc-link-lib=dylib:+verbatim=...dll` +//! directive that only MinGW's `ld` understands, not MSVC's `link.exe`. Building +//! and testing with the GNU host target instead (`cargo test --target +//! x86_64-pc-windows-gnu --test dwarf_fixture`, MinGW-w64 `gcc`/`ld` on `PATH`, +//! `SIMICS_BASE` pointed at a local Simics install) links and runs this test +//! successfully, matching the CI `build_windows` job in `.github/workflows/ci.yml`. +//! At runtime, the Simics package's own `win64/bin` directory (containing +//! `libsimics-common.dll`/`libvtutils.dll`) must also be on `PATH`. +//! +//! `SourceCache::new` originally also called into a Simics FFI object lookup +//! (`get_object("tsffs")`) purely to emit a debug log line, guarded with +//! `if let Ok(o) = ...` on the assumption that a missing live Simics session would +//! just produce an `Err`. In practice, calling any `SIM_*` API entry point with no +//! Simics kernel initialized (as here, a plain `.exe` linking directly against +//! `libsimics-common.dll`) hard-aborts the process from inside the DLL itself, +//! before the FFI call can return `Err` -- so that debug log line has been removed +//! from `SourceCache::new` to make it actually usable offline, as this test +//! requires. + +use std::{fs::read, path::PathBuf}; + +use object::File as ObjectFile; +use tsffs::{ + dwarf::{DebugInfoModule, DwarfModule, SymbolInfo}, + source_cov::SourceCache, +}; + +/// The fixture's link-time `.text` VMA, as passed to `ld --section-start` and +/// confirmed with `objdump -h` (see the module doc comment above). +const TEXT_VMA: u64 = 0x240; + +/// `X509VerifyCert`'s link-time start address and size, confirmed with +/// `nm --print-size` against the checked-in fixture (see the module doc comment +/// above): `000000000000028c 000000000000004f T X509VerifyCert`. +const X509_VERIFY_CERT_ADDR: u64 = 0x28c; +const X509_VERIFY_CERT_SIZE: u64 = 0x4f; + +/// A hardcoded fake runtime base address standing in for a real EDK2 `ImageBase`, +/// per Milestone 1's "single module, fake inputs, offline unit test" scope -- +/// there is no live Simics session in this environment to supply a real one. +/// Deliberately not page-aligned/round, to make sure the test isn't inadvertently +/// tolerant of a base-address bug that only manifests for non-trivial bases (e.g. +/// an accidental OR instead of ADD, which is invisible when the low bits of the +/// link-time address don't collide with the base -- picking a base whose low +/// nibble is non-zero, like the real link-time address, guards against that). +const FAKE_IMAGE_BASE: u64 = 0x0007_ffff_1234_0000; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("dwarf") +} + +#[test] +fn dwarf_module_intervals_resolves_synthetic_cert_verify() -> anyhow::Result<()> { + let fixture_dir = fixture_dir(); + let debug_path = fixture_dir.join("X509CertVerify.debug"); + let bytes = read(&debug_path)?; + + let object = ObjectFile::parse(bytes.as_slice())?; + let mut module = DwarfModule::new("X509CertVerify.efi".to_string(), FAKE_IMAGE_BASE, object); + + // `SourceCache` walks `fixture_dir` so `X509CertVerify.c` (the fixture's own + // checked-in source) can be resolved as a local path for the line info below. + // `SourceCache::new` calls into a guarded (`if let Ok(...)`) Simics FFI object + // lookup purely for a debug log line, so it degrades gracefully with no live + // Simics session -- see `src/source_cov/mod.rs`. + let source_cache = SourceCache::new(&fixture_dir)?; + + let intervals = module.intervals(&source_cache)?; + + let handler = intervals + .iter() + .find(|element| element.value.name == "X509VerifyCert") + .unwrap_or_else(|| { + panic!( + "no SymbolInfo named \"X509VerifyCert\" in {:?}", + intervals + .iter() + .map(|e| e.value.name.as_str()) + .collect::>() + ) + }); + + // Address range: base + link-time addr, exactly the arithmetic documented at + // the top of `src/dwarf/mod.rs` -- this is the crux of Milestone 1. + let expected_start = FAKE_IMAGE_BASE + X509_VERIFY_CERT_ADDR; + let expected_end = expected_start + X509_VERIFY_CERT_SIZE; + assert_eq!(handler.range.start, expected_start); + assert_eq!(handler.range.end, expected_end); + + let symbol: &SymbolInfo = &handler.value; + assert_eq!(symbol.name, "X509VerifyCert"); + assert_eq!(symbol.module, "X509CertVerify.efi"); + assert_eq!(symbol.base, FAKE_IMAGE_BASE); + assert_eq!(symbol.rva, X509_VERIFY_CERT_ADDR); + assert_eq!(symbol.size, X509_VERIFY_CERT_SIZE); + assert!( + TEXT_VMA <= symbol.rva, + "sanity check: the function must start at or after the fixture's .text VMA" + ); + + // Line numbers: ground truth from `readelf --debug-dump=decodedline` (see the + // module doc comment above), restricted to rows inside X509VerifyCert's + // [0x28c, 0x2db) range. `HashCertificateBytes`'s rows (lines 37-45) share the + // same compilation unit/line program but must NOT show up here. + let mut lines: Vec<(u64, u32)> = symbol + .lines + .iter() + .map(|line| (line.rva, line.start_line)) + .collect(); + lines.sort_by_key(|&(rva, _)| rva); + + assert_eq!( + lines, + vec![ + (0x28c, 49), + (0x2a4, 50), + (0x2bb, 52), + (0x2c2, 53), + (0x2c9, 56), + (0x2cf, 56), + (0x2d5, 58), + (0x2d9, 59), + ] + ); + + // Every resolved line must have found the fixture's checked-in source file + // (proving `SourceCache`/`resolve_file_path`'s DWARF-embedded-name lookup path + // works, not just the address/line arithmetic). + for line in &symbol.lines { + assert_eq!( + line.file_path.file_name().and_then(|n| n.to_str()), + Some("X509CertVerify.c") + ); + assert_eq!(line.start_line, line.end_line); + } + + Ok(()) +} diff --git a/tests/fixtures/dwarf/X509CertVerify.c b/tests/fixtures/dwarf/X509CertVerify.c new file mode 100644 index 00000000..d4dcd9aa --- /dev/null +++ b/tests/fixtures/dwarf/X509CertVerify.c @@ -0,0 +1,59 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +// Synthetic stand-in for an EDK2 DXE-library function. +// +// This is NOT real EDK2/UEFI source. It exists purely to give gcc something +// to compile with `-g` so the resulting ELF's DWARF debug info can be used +// as an offline test fixture for `DwarfModule::intervals()` (see +// ../../../src/dwarf/mod.rs). The name/shape (`X509VerifyCert` in a file +// named `X509CertVerify.c`) mirrors the real, public EDK2 `BaseCryptLib` +// certificate-verification API this project's own edk2-uefi tutorial +// harnesses (see docs/src/tutorials/edk2-uefi/writing-the-application.md), +// giving the fixture a realistic, non-zero link-time `.text` VMA without +// requiring a full EDK2/Docker BIOS build. +// +// Regenerate the compiled fixture with: +// +// gcc -g -O0 -ffreestanding -fno-stack-protector -fno-builtin \ +// -c X509CertVerify.c -o X509CertVerify.o +// ld -o X509CertVerify.debug X509CertVerify.o --entry=X509VerifyCert \ +// --section-start=.text=0x240 +// +// Ground truth for the test (recorded here so it stays traceable if the +// toolchain changes and someone regenerates the fixture): +// - `.text` link-time VMA: 0x240 (asked for explicitly via +// `--section-start`; confirmed actual with `objdump -h`) +// - `X509VerifyCert` link-time address/size: read from `nm`/`objdump` +// against the checked-in `X509CertVerify.debug`, see the doc-comment on +// the test in `../../../src/dwarf/mod.rs`. + +typedef unsigned long UINTN; +typedef unsigned char UINT8; + +// Sums the bytes of a certificate. Standing in for whatever real ASN.1/DER +// parsing a certificate-verification routine would do to caller-supplied +// certificate data before checking it against a trust anchor. +static UINTN HashCertificateBytes(const UINT8 *Cert, UINTN CertSize) { + UINTN Checksum = 0; + + for (UINTN Index = 0; Index < CertSize; Index++) { + Checksum += Cert[Index]; + } + + return Checksum; +} + +// Synthetic analog of a real EDK2 certificate-verification routine, e.g. +// BaseCryptLib's X509VerifyCert. +UINTN X509VerifyCert(const UINT8 *Cert, UINTN CertSize, UINT8 *Data) { + UINTN Checksum = HashCertificateBytes(Cert, CertSize); + + if (Checksum == 0) { + return 0; + } + + Data[0] = (UINT8)(Checksum & 0xFF); + + return Checksum; +} diff --git a/tests/fixtures/dwarf/X509CertVerify.debug b/tests/fixtures/dwarf/X509CertVerify.debug new file mode 100644 index 00000000..c6603d07 Binary files /dev/null and b/tests/fixtures/dwarf/X509CertVerify.debug differ diff --git a/tests/uefi_module_discovery_fixture.rs b/tests/uefi_module_discovery_fixture.rs new file mode 100644 index 00000000..5f1ba61c --- /dev/null +++ b/tests/uefi_module_discovery_fixture.rs @@ -0,0 +1,596 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end, offline test of UEFI module discovery's milestone-scope steps 1-2 +//! (`tsffs::uefi::{parse_module_list, UefiOsInfo}`, UCOV-M2) against a synthetic +//! fixture, entirely without a live Simics session -- mirroring +//! `tests/dwarf_fixture.rs`'s pattern on the sibling DWARF milestone branch +//! (`feat/dwarf-source-coverage-ucov-m1`). +//! +//! This lives under `tests/` (an integration test / separate cargo crate) rather +//! than as a `#[cfg(test)]` module inside `src/uefi/mod.rs` for the same reason as +//! that file: this crate's `[lib]` section sets `test = false`, which disables the +//! implicit unit-test harness for the library target, so `#[cfg(test)]` code +//! inside `src/` is never compiled by `cargo test` for this crate. Integration +//! tests under `tests/` are a separate cargo target and unaffected by that +//! setting. Being a separate crate also means this file only sees `tsffs`'s `pub` +//! API -- `src/lib.rs` widens `uefi` to `pub` (from `pub(crate)`) for exactly this +//! reason, same as `dwarf`/`source_cov` on the DWARF branch. +//! +//! # No live Simics session was used, but the row shape itself is real +//! +//! There is no BIOS image, boot, or live Simics session available in this offline +//! environment, so this test still can't drive a real `run_command` call -- it +//! tests `parse_module_list` against a *hand-constructed* fake `AttrValueType` +//! rather than one actually returned by Simics. But unlike the superseded +//! `list-modules`-based design (whose row shape was an unconfirmed assumption), +//! the 7-element row shape used below is not a guess: it matches a real, live +//! `tracker_obj->maps` dump captured in a live test session (real Simics +//! 6.0.189 session, real checkpoint past DXE dispatch, 68 real loaded UEFI +//! modules) -- see `src/uefi/mod.rs`'s module doc comment ("Confirmed shape of +//! `tracker_obj->maps`' return value") for the exact shape and a real captured +//! example row. +//! +//! # Fixture +//! +//! The fixture models 6 rows: +//! +//! - `PeiCore.efi`: one unique module with a full embedded path, resolved via +//! primary path-suffix matching. +//! - `BootScriptExecutorDxe.efi`: the real observed duplicate-name case +//! (re-investigated under `tracker_obj->maps`) -- two loaded instances, two +//! distinct base addresses, but the **identical** embedded path for both (the +//! same build loaded twice, not two different binaries). Confirms this +//! resolves both addresses to the same correct local file with **no** +//! ambiguity warning, since a real matching path makes it not actually +//! ambiguous. +//! - `AcpiVTD.efi`: a **fabricated** genuinely-different-path duplicate (two +//! different, fabricated `PkgA`/`PkgB` package subdirectories, same basename) +//! -- proves path-suffix matching still correctly disambiguates *real* +//! ambiguity when it exists, which the `BootScriptExecutorDxe.efi` case above +//! does not exercise (its two instances share one path, so a resolver that +//! ignored the path entirely would pass that case by accident). +//! - An unknown/unresolved module with no embedded path at all +//! (`AttrValueType::Nil`, matching the real capture -- see +//! `fixture_attr_value`'s doc comment), confirming `parse_module_list` names +//! it [`tsffs::uefi::UNKNOWN_MODULE_NAME`] without panicking or misparsing, +//! and that `UefiOsInfo::resolve` fails that one module gracefully (a clear +//! `Err`, not a panic) rather than guessing. + +use std::{ + collections::HashMap, + fs::{create_dir_all, write}, + io, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use anyhow::Result; +use simics::AttrValueType; +use tempfile::tempdir; +use tracing_subscriber::fmt::MakeWriter; +use tsffs::uefi::{parse_module_list, UefiOsInfo, UNKNOWN_MODULE_NAME}; + +/// One row of the fixture, in the confirmed real `tracker_obj->maps` shape: +/// `[loaded_address, loaded_size, , adjusted_address, adjusted_size, +/// , full_path_string]`. `embedded_path` is `None` to model a genuinely +/// pathless ("unknown module") row. +struct FixtureRow { + base: u64, + size: u64, + embedded_path: Option, +} + +/// The fixture's common embedded build-machine path prefix, matching the real +/// structure observed in a live tracker dump (see `src/uefi/mod.rs`'s +/// module doc comment for the real captured example this mirrors). +const PREFIX: &str = "/home/user/bios-x58i/project/workspace/Build/SimicsOpenBoardPkg/BoardX58Ich10/DEBUG_GCC/X64"; + +/// Build the fixture's 6 rows: +/// - one unique module (`PeiCore.efi`) +/// - the real observed duplicate-name case (`BootScriptExecutorDxe.efi`), both +/// instances sharing one identical embedded path +/// - a fabricated genuinely-different-path duplicate (`AcpiVTD.efi`, under +/// fabricated `PkgA`/`PkgB` subdirectories) +/// - one genuinely pathless ("unknown module") row +fn fixture_rows() -> Vec { + vec![ + FixtureRow { + base: 0x0000_0000_0082_0000, + size: 0x9000, + embedded_path: Some(format!( + "{PREFIX}/MdeModulePkg/Core/Pei/PeiMain/DEBUG/PeiCore.efi" + )), + }, + // Real observed duplicate-name case: two loaded instances, two base + // addresses, but the identical embedded path for both. + FixtureRow { + base: 0x0000_0000_00d0_0000, + size: 0x1_0000, + embedded_path: Some(format!( + "{PREFIX}/MdeModulePkg/Universal/Variable/RuntimeDxe/BootScriptExecutorDxe/DEBUG/BootScriptExecutorDxe.efi" + )), + }, + FixtureRow { + base: 0x0000_0000_00e0_0000, + size: 0x1_0100, + embedded_path: Some(format!( + "{PREFIX}/MdeModulePkg/Universal/Variable/RuntimeDxe/BootScriptExecutorDxe/DEBUG/BootScriptExecutorDxe.efi" + )), + }, + // Fabricated genuinely-different-path duplicate: same basename, two + // different package subdirectories, to prove suffix-matching still + // disambiguates real ambiguity when it exists. + FixtureRow { + base: 0x0000_0000_0700_0000, + size: 0x4000, + embedded_path: Some(format!("{PREFIX}/PkgA/Feature/AcpiVTD/DEBUG/AcpiVTD.efi")), + }, + FixtureRow { + base: 0x0000_0000_0710_0000, + size: 0x4200, + embedded_path: Some(format!("{PREFIX}/PkgB/Feature/AcpiVTD/DEBUG/AcpiVTD.efi")), + }, + // Genuinely pathless row: a real "unknown"/unresolved module. + FixtureRow { + base: 0x0000_0000_0900_0000, + size: 0x1000, + embedded_path: None, + }, + ] +} + +/// Build the fake `AttrValueType` shape `tracker_obj->maps` is confirmed (see +/// `src/uefi/mod.rs`'s module doc comment) to return for a set of fixture rows: a +/// `List` of 7-element `List`s, `[loaded_address, loaded_size, , +/// adjusted_address, adjusted_size, , full_path_string]`. `adjusted_address` +/// is fabricated equal to `loaded_address` and `adjusted_size` equal to +/// `loaded_size`, and both booleans fabricated `true`, since this module doesn't +/// read those fields at all (see the module doc comment). A `None` +/// `embedded_path` becomes `AttrValueType::Nil`, not `AttrValueType::String(String::new())` +/// -- validated live against the real, full 68-row capture: the one +/// genuinely pathless row's Python value is `None`, which converts to +/// `AttrValueType::Nil` via `simics::AttrValueType::from(AttrValue)`'s `is_nil()` +/// check. An earlier revision of this fixture used an empty string instead, +/// which the real capture showed does not match what Simics actually returns. +fn fixture_attr_value(rows: &[FixtureRow]) -> AttrValueType { + AttrValueType::List( + rows.iter() + .map(|row| { + AttrValueType::List(vec![ + AttrValueType::Unsigned(row.base), + AttrValueType::Unsigned(row.size), + AttrValueType::Bool(true), + AttrValueType::Unsigned(row.base), + AttrValueType::Unsigned(row.size), + AttrValueType::Bool(true), + match &row.embedded_path { + Some(path) => AttrValueType::String(path.clone()), + None => AttrValueType::Nil, + }, + ]) + }) + .collect(), + ) +} + +#[test] +fn parses_synthetic_tracker_maps_including_unknown_module() -> Result<()> { + let rows = fixture_rows(); + let value = fixture_attr_value(&rows); + + let parsed = parse_module_list(&value)?; + assert_eq!(parsed.len(), rows.len()); + + for (row, (name, base, size, embedded_path)) in rows.iter().zip(parsed.iter()) { + assert_eq!(*base, row.base); + assert_eq!(*size, row.size); + + match &row.embedded_path { + Some(path) => { + let expected_name = PathBuf::from(path) + .file_name() + .expect("fixture embedded path has a file name") + .to_str() + .expect("fixture file name is valid UTF-8") + .to_string(); + assert_eq!(name, &expected_name); + assert_eq!(embedded_path, &PathBuf::from(path)); + } + None => { + // Graceful naming/fallback for a genuinely pathless row -- not a + // crash, not a silent misparse (e.g. an empty name or a panic). + assert_eq!(name, UNKNOWN_MODULE_NAME); + assert_eq!(embedded_path, &PathBuf::new()); + } + } + } + + // The real observed duplicate-name case: both instances present, distinct + // base addresses, but the identical embedded path (not accidentally + // collapsed/deduplicated, and not given divergent paths). + let boot_script_matches: Vec<_> = parsed + .iter() + .filter(|(name, ..)| name == "BootScriptExecutorDxe.efi") + .collect(); + assert_eq!(boot_script_matches.len(), 2); + assert_ne!(boot_script_matches[0].1, boot_script_matches[1].1); + assert_eq!(boot_script_matches[0].3, boot_script_matches[1].3); + + // The fabricated genuinely-different-path duplicate: both instances present, + // distinct base addresses, and distinct embedded paths. + let acpi_matches: Vec<_> = parsed + .iter() + .filter(|(name, ..)| name == "AcpiVTD.efi") + .collect(); + assert_eq!(acpi_matches.len(), 2); + assert_ne!(acpi_matches[0].1, acpi_matches[1].1); + assert_ne!(acpi_matches[0].3, acpi_matches[1].3); + + Ok(()) +} + +#[test] +fn resolves_identical_path_duplicate_with_no_ambiguity_warning() -> Result<()> { + let tmp = tempdir()?; + let root = tmp.path(); + + let rows = fixture_rows(); + let modules = parse_module_list(&fixture_attr_value(&rows))? + .into_iter() + .filter(|(name, ..)| name == "BootScriptExecutorDxe.efi") + .collect::>(); + assert_eq!(modules.len(), 2); + + // Mirror the shared embedded path locally, from "DEBUG_GCC/" onward, as a + // single real local file. + let shared_embedded_path = rows + .iter() + .find(|row| { + row.embedded_path + .as_deref() + .map(|p| p.contains("BootScriptExecutorDxe")) + .unwrap_or(false) + }) + .and_then(|row| row.embedded_path.as_deref()) + .expect("fixture has a BootScriptExecutorDxe.efi row with an embedded path"); + let suffix = shared_embedded_path + .rsplit_once("DEBUG_GCC/") + .expect("fixture embedded path contains the DEBUG_GCC/ prefix marker") + .1; + let local_path = root.join(suffix); + create_dir_all( + local_path + .parent() + .expect("local fixture path has a parent directory"), + )?; + write(&local_path, b"contents of BootScriptExecutorDxe.efi")?; + + let capturing_writer = CapturingWriter::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(capturing_writer.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::TRACE) + .finish(); + + let info = + tracing::subscriber::with_default(subscriber, || UefiOsInfo::resolve(&modules, root))?; + assert_eq!(info.modules.len(), 2); + + // Both instances resolve to the exact same real local file: not ambiguous + // once a real distinguishing (here, shared) path is available. + for (name, _base, resolved_path) in &info.modules { + assert_eq!(name, "BootScriptExecutorDxe.efi"); + assert_eq!( + resolved_path, &local_path, + "both BootScriptExecutorDxe.efi instances must resolve to the shared local file" + ); + } + + let log_output = String::from_utf8( + capturing_writer + .0 + .lock() + .expect("capturing writer mutex not poisoned") + .clone(), + )?; + assert!( + !log_output.to_lowercase().contains("ambiguous"), + "resolving the identical-path duplicate must not warn about ambiguity, got: {log_output:?}" + ); + + Ok(()) +} + +#[test] +fn resolves_different_path_duplicate_via_path_suffix_disambiguation() -> Result<()> { + let tmp = tempdir()?; + let root = tmp.path(); + + let rows = fixture_rows(); + let modules = parse_module_list(&fixture_attr_value(&rows))? + .into_iter() + .filter(|(name, ..)| name == "AcpiVTD.efi") + .collect::>(); + assert_eq!(modules.len(), 2); + + // Mirror each relevant fixture row's embedded path locally, from + // "DEBUG_GCC/" onward (i.e. the "PkgA/..." / "PkgB/..." part), as a real + // local file with content unique to that row -- used below to positively + // confirm which exact local file each module resolved to, not just "a" + // file. + let mut expected_local_paths: HashMap = HashMap::new(); + for row in rows.iter().filter(|row| { + row.embedded_path + .as_deref() + .map(|p| p.contains("AcpiVTD")) + .unwrap_or(false) + }) { + let embedded_path = row + .embedded_path + .as_deref() + .expect("filtered to rows with an embedded path"); + let suffix = embedded_path + .rsplit_once("DEBUG_GCC/") + .expect("fixture embedded path contains the DEBUG_GCC/ prefix marker") + .1; + let local_path = root.join(suffix); + create_dir_all( + local_path + .parent() + .expect("local fixture path has a parent directory"), + )?; + write(&local_path, format!("contents of {suffix}"))?; + expected_local_paths.insert(embedded_path.to_string(), local_path); + } + + let info = UefiOsInfo::resolve(&modules, root)?; + assert_eq!(info.modules.len(), 2); + + for (name, base, resolved_path) in &info.modules { + let row = rows + .iter() + .find(|row| row.base == *base) + .expect("resolved module base matches a fixture row"); + let embedded_path = row + .embedded_path + .as_deref() + .expect("fixture row has an embedded path"); + let expected = expected_local_paths + .get(embedded_path) + .expect("fixture row has a corresponding local fixture file"); + + assert_eq!( + resolved_path, expected, + "module {name} (base {base:#x}) resolved to the wrong local file; \ + path-suffix disambiguation failed" + ); + } + + // The actual point of this test, not a vacuous pass: the two AcpiVTD.efi + // entries must resolve to two *distinct* local files -- a resolver that just + // grabbed "any" file matching the bare name would pass the per-module checks + // above only by accident, but could not pass this. + let resolved: Vec<&PathBuf> = info.modules.iter().map(|(_, _, path)| path).collect(); + assert_eq!(resolved.len(), 2); + assert_ne!( + resolved[0], resolved[1], + "the two AcpiVTD.efi modules must resolve to distinct local files" + ); + + Ok(()) +} + +#[test] +fn falls_back_to_stem_match_and_warns_on_ambiguity_when_suffix_match_fails() -> Result<()> { + let tmp = tempdir()?; + let root = tmp.path(); + + // Use a module with a real embedded path (not the pathless row) whose parent + // directories deliberately don't exist locally at all, so path-suffix + // matching finds nothing and both instances fall through to the bare-stem + // fallback. + let rows = vec![ + FixtureRow { + base: 0x0000_0000_0740_0000, + size: 0x5000, + embedded_path: Some(format!( + "{PREFIX}/PkgA/Bus/Pci/SataControllerDxe/DEBUG/SataController.efi" + )), + }, + FixtureRow { + base: 0x0000_0000_0750_0000, + size: 0x5100, + embedded_path: Some(format!( + "{PREFIX}/PkgB/Bus/Pci/SataControllerDxe/DEBUG/SataController.efi" + )), + }, + ]; + let modules = parse_module_list(&fixture_attr_value(&rows))?; + assert_eq!(modules.len(), 2); + + // Deliberately unrelated local directory layouts: neither shares any path + // component with the fixture's ".../PkgA|PkgB/Bus/Pci/SataControllerDxe/ + // DEBUG/" embedded-path tail beyond the bare file name itself. Path-suffix + // matching therefore finds the bare file name "SataController.efi" as a + // candidate suffix, but with *two* different local files sharing it -- an + // ambiguous, not unique, match -- so per + // `PathSuffixIndex::lookup_components_unambiguous`'s contract it must return + // no match at all, forcing both modules through the bare-stem fallback path. + let path_1 = root.join("unrelated_layout_one").join("SataController.efi"); + let path_2 = root.join("unrelated_layout_two").join("SataController.efi"); + create_dir_all(path_1.parent().expect("has parent"))?; + create_dir_all(path_2.parent().expect("has parent"))?; + write(&path_1, b"layout one")?; + write(&path_2, b"layout two")?; + + let capturing_writer = CapturingWriter::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(capturing_writer.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::TRACE) + .finish(); + + let info = tracing::subscriber::with_default(subscriber, || { + UefiOsInfo::resolve(&modules, root) + })?; + assert_eq!(info.modules.len(), 2); + + // Fail-open (the spec's own explicit decision): both modules still resolve -- + // not an error, not a dropped module -- to the first candidate in sorted + // order. Both modules share the exact same ambiguous candidate set (the same + // two unrelated local files), so both must fail open to the exact same + // resolved path. + let mut sorted_candidates = [path_1.clone(), path_2.clone()]; + sorted_candidates.sort(); + let expected_fail_open_path = sorted_candidates[0].clone(); + + for (name, _base, resolved_path) in &info.modules { + assert_eq!(name, "SataController.efi"); + assert_eq!( + resolved_path, &expected_fail_open_path, + "expected fail-open fallback to deterministically pick the first \ + (sorted) ambiguous candidate" + ); + } + + let log_output = String::from_utf8( + capturing_writer + .0 + .lock() + .expect("capturing writer mutex not poisoned") + .clone(), + )?; + + assert!( + log_output.contains("WARN") && log_output.to_lowercase().contains("ambiguous"), + "expected a WARN-level log message about ambiguous debug info resolution, got: {log_output:?}" + ); + + Ok(()) +} + +#[test] +fn resolve_skips_gracefully_not_panics_for_pathless_unknown_module() -> Result<()> { + let tmp = tempdir()?; + let root = tmp.path(); + + let rows = fixture_rows(); + let modules = parse_module_list(&fixture_attr_value(&rows))? + .into_iter() + .filter(|(name, ..)| name == UNKNOWN_MODULE_NAME) + .collect::>(); + assert_eq!(modules.len(), 1); + + // Resolving a genuinely pathless ("unknown module") row must not panic or + // abort the batch (a real `tracker_obj->maps` capture always has at least + // one such row -- see UefiOsInfo::resolve's doc comment) -- it's skipped + // with a warning, leaving an empty (not missing) result. + let info = UefiOsInfo::resolve(&modules, root)?; + assert!( + info.modules.is_empty(), + "an unknown/pathless module must be skipped, not resolved to a bogus path" + ); + + Ok(()) +} + +#[test] +fn resolve_skips_only_the_unresolvable_module_in_a_mixed_batch() -> Result<()> { + // The real-world case this guards: a real tracker_obj->maps capture is a + // mix of resolvable and genuinely pathless modules (confirmed live on + // a live test session: 66 resolvable real modules plus 1 pathless "" one, in + // a single 67-row capture). One unresolvable module must not discard + // source coverage for every other resolvable module in the same batch. + let tmp = tempdir()?; + let root = tmp.path(); + + let rows = fixture_rows(); + let modules = parse_module_list(&fixture_attr_value(&rows))?; + let resolvable_count = modules + .iter() + .filter(|(name, ..)| name != UNKNOWN_MODULE_NAME) + .count(); + + for (name, _base, _size, embedded_path) in &modules { + if name == UNKNOWN_MODULE_NAME { + continue; + } + let suffix = embedded_path + .to_string_lossy() + .rsplit_once("DEBUG_GCC/") + .expect("fixture embedded path contains the DEBUG_GCC/ prefix marker") + .1 + .to_string(); + let local_path = root.join(suffix); + create_dir_all( + local_path + .parent() + .expect("local fixture path has a parent directory"), + )?; + write(&local_path, b"contents")?; + } + + let info = UefiOsInfo::resolve(&modules, root)?; + assert_eq!( + info.modules.len(), + resolvable_count, + "every resolvable module in the batch must still resolve despite the one unresolvable module" + ); + + Ok(()) +} + +#[test] +fn skips_invalid_top_level_entries_without_erroring() -> Result<()> { + // Confirmed live (a real fuzzing run whose `HARNESS_START` + // fired early in DXE dispatch): `tracker_obj->maps` can return a list + // containing `AttrValueType::Invalid` entries -- reserved but not-yet- + // populated slots -- alongside well-formed 7-element module rows. This must + // not error the whole batch; those entries should simply be skipped. + let rows = fixture_rows(); + let mut value = fixture_attr_value(&rows); + + let AttrValueType::List(ref mut top_level) = value else { + panic!("fixture_attr_value did not return an AttrValueType::List"); + }; + top_level.insert(0, AttrValueType::Invalid); + top_level.push(AttrValueType::Invalid); + + let parsed = parse_module_list(&value)?; + assert_eq!( + parsed.len(), + rows.len(), + "Invalid top-level entries must be skipped, not counted as modules or cause an error" + ); + + Ok(()) +} + +/// A `tracing_subscriber::fmt::MakeWriter` that captures formatted log output into +/// a shared in-memory buffer, so tests can assert on it directly instead of only +/// inferring the warning fired from behavior. +#[derive(Clone, Default)] +struct CapturingWriter(Arc>>); + +impl io::Write for CapturingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0 + .lock() + .expect("capturing writer mutex not poisoned") + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for CapturingWriter { + type Writer = Self; + + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +}