Skip to main content

codehelion_artifact/
pe.rs

1//! PE and COFF implementation of the codehelion artifact backend boundary.
2//!
3//! The backend reads bytes through the safe `object` API and never maps or
4//! executes the inspected artifact. PDB source locations are intentionally a
5//! separate correlation input: this parser establishes only facts present in
6//! the PE or COFF container itself.
7
8use std::collections::{BTreeSet, HashMap};
9use std::io::Cursor;
10
11use crate::native::{
12    collect_sections, collect_text_symbols, collect_undefined_imports, symbol_fingerprint,
13};
14use crate::x86::X86_NORMALIZATION_VERSION;
15use crate::{
16    ArtifactBackend, ArtifactCapabilities, ArtifactError, ArtifactFingerprint, ArtifactFormat,
17    ArtifactIr,
18};
19use object::Object;
20use pdb::{FallibleIterator, PDB};
21
22#[cfg(test)]
23use crate::ArtifactImportKind;
24
25/// Parser backend for PE images and COFF objects.
26#[derive(Debug, Default, Clone, Copy)]
27pub struct PeCoffBackend;
28
29/// Version of the shared x86 instruction-shape normalization representation.
30pub const PE_COFF_NORMALIZATION_VERSION: &str = X86_NORMALIZATION_VERSION;
31
32impl ArtifactBackend for PeCoffBackend {
33    fn format(&self) -> ArtifactFormat {
34        ArtifactFormat::PeCoff
35    }
36
37    fn detects(&self, bytes: &[u8]) -> bool {
38        // `object` verifies the complete container. This inexpensive check is
39        // only dispatch evidence, so either a COFF file header or PE DOS magic
40        // is sufficient here.
41        bytes.starts_with(b"MZ") || is_coff_machine(bytes)
42    }
43
44    fn parse(&self, bytes: &[u8]) -> Result<ArtifactIr, ArtifactError> {
45        self.parse_with_pdb(bytes, None)
46    }
47
48    fn capabilities(&self) -> ArtifactCapabilities {
49        ArtifactCapabilities {
50            symbols: true,
51            call_graph: false,
52            source_mapping: false,
53            debug_info_unreadable: false,
54            normalized_duplicates: false,
55            independent_data_segments: false,
56            relocations: true,
57            data_segments: true,
58        }
59    }
60}
61
62impl PeCoffBackend {
63    /// Parse a PE or COFF artifact with an optional, already-read PDB.
64    ///
65    /// A PDB is used only for a PE image that carries matching `CodeView` GUID
66    /// and age metadata. COFF objects have no corresponding image identity, so
67    /// a supplied PDB is rejected rather than guessed at.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error when the input is another format, malformed, or the
72    /// optional PDB does not match the PE image exactly enough to be evidence.
73    pub fn parse_with_pdb(
74        &self,
75        bytes: &[u8],
76        pdb_bytes: Option<&[u8]>,
77    ) -> Result<ArtifactIr, ArtifactError> {
78        if !self.detects(bytes) {
79            return Err(ArtifactError::WrongFormat {
80                expected: ArtifactFormat::PeCoff,
81            });
82        }
83        let file = object::File::parse(bytes).map_err(|error| malformed(error.to_string()))?;
84        if !matches!(
85            file.format(),
86            object::BinaryFormat::Coff | object::BinaryFormat::Pe
87        ) {
88            return Err(ArtifactError::WrongFormat {
89                expected: ArtifactFormat::PeCoff,
90            });
91        }
92        let mut ir = ArtifactIr::empty(ArtifactFormat::PeCoff, bytes);
93        collect_sections(&file, &mut ir).map_err(|error| malformed(error.to_string()))?;
94        collect_undefined_imports(file.symbols(), &mut ir);
95        let named = collect_symbols(&file, &mut ir)?;
96        // An inferred region is offered to the PDB join like any other. A
97        // linked image is the case where the debug information is all there is
98        // to go on, so leaving the region out of the join would discard the
99        // only evidence for exactly the input that needs it.
100        let symbol_ranges = if ir.symbols.is_empty() {
101            infer_text_regions(&file, &mut ir)?
102        } else {
103            named
104        };
105        if let Some(pdb_bytes) = pdb_bytes {
106            collect_pdb_frames(&file, pdb_bytes, &symbol_ranges, &mut ir)?;
107        }
108        ir.capabilities = ArtifactCapabilities {
109            symbols: !ir.symbols.is_empty(),
110            call_graph: false,
111            source_mapping: !ir.source_mappings.is_empty(),
112            debug_info_unreadable: false,
113            normalized_duplicates: crate::x86::supports_normalized_duplicates(file.architecture()),
114            independent_data_segments: false,
115            relocations: !ir.relocations.is_empty(),
116            data_segments: !ir.data_segments.is_empty(),
117        };
118        Ok(ir)
119    }
120}
121
122/// Put a PDB address into the same space the image's symbols are read in.
123///
124/// A PDB records a relative address, while the symbols and sections of a
125/// linked image are read at the address they will be loaded at. Comparing the
126/// two without saying which is which puts every line record below the first
127/// symbol, so nothing joins and the debug information silently counts for
128/// nothing. A relocatable object has no load address and this adds zero.
129fn symbol_address_of(file: &object::File<'_>, relative_address: u32) -> u64 {
130    use object::Object as _;
131
132    file.relative_address_base()
133        .saturating_add(u64::from(relative_address))
134}
135
136/// One parser-local symbol address range used to join PDB RVAs to stable IDs.
137#[derive(Debug, Clone, Copy)]
138struct SymbolRange {
139    fingerprint: ArtifactFingerprint,
140    start: u64,
141    end: u64,
142}
143
144fn is_coff_machine(bytes: &[u8]) -> bool {
145    matches!(
146        bytes.get(..2),
147        Some([0x4c, 0x01] | [0x64, 0x86 | 0xaa] | [0xaa, 0x64])
148    )
149}
150
151fn collect_symbols(
152    file: &object::File<'_>,
153    ir: &mut ArtifactIr,
154) -> Result<Vec<SymbolRange>, ArtifactError> {
155    collect_text_symbols(file, ir)
156        .map(|ranges| {
157            ranges
158                .into_iter()
159                .map(|range| SymbolRange {
160                    fingerprint: range.fingerprint,
161                    start: range.address,
162                    end: range.address.saturating_add(range.size),
163                })
164                .collect()
165        })
166        .map_err(|error| malformed(error.to_string()))
167}
168
169/// Preserve executable code in a PE/COFF image that has no COFF symbols.
170///
171/// Linked release images commonly omit the symbol table. One explicitly
172/// inferred region per text section says that code was observed while making
173/// clear that no function boundary was available.
174fn infer_text_regions(
175    file: &object::File<'_>,
176    ir: &mut ArtifactIr,
177) -> Result<Vec<SymbolRange>, ArtifactError> {
178    let ranges = crate::native::infer_text_regions(file, ir, |section, normalized, data| {
179        symbol_fingerprint(None, section, normalized, data)
180    })
181    .map_err(|error| malformed(error.to_string()))?;
182    Ok(ranges
183        .into_iter()
184        .map(|(fingerprint, address, size)| SymbolRange {
185            fingerprint,
186            start: address,
187            end: address.saturating_add(size),
188        })
189        .collect())
190}
191
192/// Attach PDB line records to symbol identities after checking `CodeView` identity.
193///
194/// PDB RVAs and image symbol addresses are used only during this join. The
195/// stored graph keeps stable symbol fingerprints and source locations, never a
196/// PE address, section number, or PDB stream index as identity.
197#[allow(
198    clippy::too_many_lines,
199    reason = "the identity check, fallible PDB iteration, and stable-ID attachment form one safety boundary"
200)]
201fn collect_pdb_frames(
202    file: &object::File<'_>,
203    pdb_bytes: &[u8],
204    symbol_ranges: &[SymbolRange],
205    ir: &mut ArtifactIr,
206) -> Result<(), ArtifactError> {
207    if file.format() != object::BinaryFormat::Pe {
208        return Err(malformed(
209            "a PDB can only describe a PE image, not a COFF object".to_owned(),
210        ));
211    }
212    let image_pdb = file
213        .pdb_info()
214        .map_err(|error| malformed(error.to_string()))?
215        .ok_or_else(|| malformed("PE image has no CodeView PDB identity".to_owned()))?;
216    let mut pdb =
217        PDB::open(Cursor::new(pdb_bytes)).map_err(|error| malformed(error.to_string()))?;
218    let pdb_info = pdb
219        .pdb_information()
220        .map_err(|error| malformed(error.to_string()))?;
221    if !pdb_identity_matches(
222        pdb_info.guid.to_bytes_le(),
223        pdb_info.age,
224        image_pdb.guid(),
225        image_pdb.age(),
226    ) {
227        return Err(malformed(
228            "PDB GUID or age does not match the PE image".to_owned(),
229        ));
230    }
231    let address_map = pdb
232        .address_map()
233        .map_err(|error| malformed(error.to_string()))?;
234    let string_table = pdb
235        .string_table()
236        .map_err(|error| malformed(error.to_string()))?;
237    let debug_information = pdb
238        .debug_information()
239        .map_err(|error| malformed(error.to_string()))?;
240    let mut modules = debug_information
241        .modules()
242        .map_err(|error| malformed(error.to_string()))?;
243    let mut frames = Vec::new();
244    while let Some(module) = modules
245        .next()
246        .map_err(|error| malformed(error.to_string()))?
247    {
248        let Some(module_info) = pdb
249            .module_info(&module)
250            .map_err(|error| malformed(error.to_string()))?
251        else {
252            continue;
253        };
254        let Ok(program) = module_info.line_program() else {
255            continue;
256        };
257        let mut lines = program.lines();
258        while let Some(line) = lines.next().map_err(|error| malformed(error.to_string()))? {
259            let Some(rva) = line.offset.to_rva(&address_map) else {
260                continue;
261            };
262            let Ok(file_info) = program.get_file_info(line.file_index) else {
263                continue;
264            };
265            let Ok(source) = file_info.name.to_string_lossy(&string_table) else {
266                continue;
267            };
268            frames.push((
269                symbol_address_of(file, rva.0),
270                crate::ArtifactInlineFrame {
271                    evidence_kind: crate::ArtifactSourceLocationEvidenceKind::Pdb,
272                    source: source.into_owned(),
273                    line: Some(line.line_start),
274                    column: line.column_start.filter(|column| *column != 0),
275                },
276            ));
277        }
278    }
279    frames.sort_by_key(|(address, _)| *address);
280    let symbol_rows: HashMap<_, _> = ir
281        .symbols
282        .iter()
283        .enumerate()
284        .map(|(index, symbol)| (symbol.fingerprint, index))
285        .collect();
286    for range in symbol_ranges {
287        let frame_start = frames.partition_point(|(address, _)| *address < range.start);
288        let mut symbol_frames: Vec<_> = frames[frame_start..]
289            .iter()
290            .take_while(|(address, _)| *address < range.end)
291            .map(|(_, frame)| frame.clone())
292            .collect();
293        symbol_frames.sort_by(|left, right| {
294            (&left.source, left.line, left.column).cmp(&(&right.source, right.line, right.column))
295        });
296        symbol_frames.dedup();
297        if symbol_frames.is_empty() {
298            continue;
299        }
300        if let Some(index) = symbol_rows.get(&range.fingerprint) {
301            ir.symbols[*index].inline_stack = symbol_frames;
302        }
303    }
304    ir.source_mappings = ir
305        .symbols
306        .iter()
307        .flat_map(|symbol| symbol.inline_stack.iter().map(|frame| frame.source.clone()))
308        .collect::<BTreeSet<_>>()
309        .into_iter()
310        .map(|uri| crate::ArtifactSourceMapping { uri })
311        .collect();
312    Ok(())
313}
314
315/// Determine whether a PDB identity can describe the PE image identity.
316///
317/// A linker can rewrite a PDB without relinking its PE image. Therefore the
318/// matching GUID must be exact while the PDB age may be newer than the age
319/// recorded in the PE image.
320fn pdb_identity_matches(
321    pdb_guid: [u8; 16],
322    pdb_age: u32,
323    image_guid: [u8; 16],
324    image_age: u32,
325) -> bool {
326    pdb_guid == image_guid && pdb_age >= image_age
327}
328
329const fn malformed(message: String) -> ArtifactError {
330    ArtifactError::Malformed {
331        format: ArtifactFormat::PeCoff,
332        message,
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    #![allow(clippy::expect_used, clippy::unwrap_used)]
339
340    use super::*;
341    use object::write::{Object as WriteObject, StandardSection, Symbol, SymbolSection};
342    use object::{Architecture, BinaryFormat, Endianness, SymbolFlags, SymbolKind, SymbolScope};
343    use proptest::prelude::*;
344
345    fn coff_fixture() -> Vec<u8> {
346        let mut object =
347            WriteObject::new(BinaryFormat::Coff, Architecture::X86_64, Endianness::Little);
348        let text = object.section_id(StandardSection::Text);
349        let offset = object.append_section_data(text, &[0x90, 0xc3], 1);
350        object.add_symbol(Symbol {
351            name: b"render".to_vec(),
352            value: offset,
353            size: 2,
354            kind: SymbolKind::Text,
355            scope: SymbolScope::Dynamic,
356            weak: false,
357            section: SymbolSection::Section(text),
358            flags: SymbolFlags::None,
359        });
360        object.write().expect("write COFF fixture")
361    }
362
363    fn coff_fixture_without_symbols() -> Vec<u8> {
364        let mut object =
365            WriteObject::new(BinaryFormat::Coff, Architecture::X86_64, Endianness::Little);
366        let text = object.section_id(StandardSection::Text);
367        object.append_section_data(text, &[0x90, 0xc3], 1);
368        object.write().expect("write symbol-free COFF fixture")
369    }
370
371    fn coff_zero_sized_alias_fixture() -> Vec<u8> {
372        let mut object =
373            WriteObject::new(BinaryFormat::Coff, Architecture::X86_64, Endianness::Little);
374        let text = object.section_id(StandardSection::Text);
375        let offset = object.append_section_data(text, &[0x90, 0xc3], 1);
376        for (name, size) in [(b"implementation".as_slice(), 2), (b"alias", 0)] {
377            object.add_symbol(Symbol {
378                name: name.to_vec(),
379                value: offset,
380                size,
381                kind: SymbolKind::Text,
382                scope: SymbolScope::Dynamic,
383                weak: false,
384                section: SymbolSection::Section(text),
385                flags: SymbolFlags::None,
386            });
387        }
388        object.write().expect("write zero-sized COFF alias fixture")
389    }
390
391    fn coff_undefined_import_fixture() -> Vec<u8> {
392        let mut object =
393            WriteObject::new(BinaryFormat::Coff, Architecture::X86_64, Endianness::Little);
394        object.add_symbol(Symbol {
395            name: b"external_call".to_vec(),
396            value: 0,
397            size: 0,
398            kind: SymbolKind::Text,
399            scope: SymbolScope::Dynamic,
400            weak: false,
401            section: SymbolSection::Undefined,
402            flags: SymbolFlags::None,
403        });
404        object.write().expect("write COFF undefined import fixture")
405    }
406
407    #[test]
408    fn sorted_pdb_line_addresses_are_sliced_to_each_symbol_range() {
409        let frames = [(2, "before"), (10, "start"), (12, "inside"), (15, "after")];
410        let start = frames.partition_point(|(address, _)| *address < 10);
411        let matched: Vec<_> = frames[start..]
412            .iter()
413            .take_while(|(address, _)| *address < 15)
414            .map(|(_, label)| *label)
415            .collect();
416
417        assert_eq!(matched, ["start", "inside"]);
418    }
419
420    #[test]
421    fn parses_a_coff_function_without_executing_it() {
422        let ir = PeCoffBackend
423            .parse(&coff_fixture())
424            .expect("parse COFF fixture");
425        assert_eq!(ir.format, ArtifactFormat::PeCoff);
426        assert_eq!(ir.symbols.len(), 1, "{ir:#?}");
427        assert!(ir.capabilities.symbols);
428        assert_eq!(ir.symbols[0].name.as_deref(), Some("render"));
429        assert_eq!(ir.symbols[0].code, vec![0x90, 0xc3]);
430        assert_eq!(
431            ir.symbols[0]
432                .normalized
433                .as_ref()
434                .map(|value| value.version.as_str()),
435            Some(PE_COFF_NORMALIZATION_VERSION)
436        );
437    }
438
439    /// An inferred region is something the debug information can be joined to.
440    ///
441    /// A linked image usually has no symbol table, so this region is the only
442    /// thing a PDB's line records have to attach to. Handing back its address
443    /// range is what makes that possible; dropping it leaves the image read
444    /// as code nobody can say anything about.
445    #[test]
446    fn an_inferred_text_region_can_be_joined_to_debug_information() {
447        let bytes = coff_fixture_without_symbols();
448        let file = object::File::parse(bytes.as_slice()).expect("parse symbol-free COFF fixture");
449        let mut ir = ArtifactIr::empty(ArtifactFormat::PeCoff, &bytes);
450        let ranges = infer_text_regions(&file, &mut ir).expect("infer the text region");
451
452        assert_eq!(ranges.len(), ir.symbols.len());
453        assert_eq!(ranges[0].fingerprint, ir.symbols[0].fingerprint);
454        assert!(ranges[0].end > ranges[0].start);
455    }
456
457    /// A relocatable object is read where it sits, so nothing is added.
458    #[test]
459    fn a_relative_address_without_a_load_address_is_itself() {
460        let bytes = coff_fixture();
461        let file = object::File::parse(bytes.as_slice()).expect("parse COFF fixture");
462
463        assert_eq!(symbol_address_of(&file, 0x20), 0x20);
464    }
465
466    #[test]
467    fn symbol_free_coff_keeps_an_explicitly_inferred_text_region() {
468        let ir = PeCoffBackend
469            .parse(&coff_fixture_without_symbols())
470            .expect("parse symbol-free COFF fixture");
471        assert_eq!(ir.symbols.len(), 1, "{ir:#?}");
472        assert!(ir.capabilities.symbols);
473        assert!(ir.symbols[0].size_inferred);
474        assert_eq!(ir.symbols[0].name, None);
475        assert_eq!(ir.symbols[0].code, vec![0x90, 0xc3]);
476    }
477
478    #[test]
479    fn zero_sized_coff_alias_is_retained_without_claiming_implementation_bytes() {
480        let ir = PeCoffBackend
481            .parse(&coff_zero_sized_alias_fixture())
482            .expect("parse zero-sized COFF alias fixture");
483        let alias = ir
484            .symbols
485            .iter()
486            .find(|symbol| symbol.name.as_deref() == Some("alias"))
487            .expect("alias record");
488        let implementation = ir
489            .symbols
490            .iter()
491            .find(|symbol| symbol.name.as_deref() == Some("implementation"))
492            .expect("implementation record");
493        assert!(alias.size_inferred);
494        assert_eq!(alias.size, 0);
495        assert!(alias.code.is_empty());
496        assert_eq!(implementation.code, vec![0x90, 0xc3]);
497    }
498
499    #[test]
500    fn records_undefined_coff_symbols_as_imports() {
501        let ir = PeCoffBackend
502            .parse(&coff_undefined_import_fixture())
503            .expect("parse COFF undefined import fixture");
504        assert_eq!(ir.imports.len(), 1, "{ir:#?}");
505        assert_eq!(ir.imports[0].name.as_deref(), Some("external_call"));
506        assert_eq!(ir.imports[0].kind, ArtifactImportKind::Function);
507    }
508
509    #[test]
510    fn other_bytes_do_not_claim_the_backend() {
511        assert!(!PeCoffBackend.detects(b"not an object"));
512        assert!(matches!(
513            PeCoffBackend.parse(b"not an object"),
514            Err(ArtifactError::WrongFormat { .. })
515        ));
516    }
517
518    #[test]
519    fn a_pdb_is_not_guessed_for_a_coff_object() {
520        let error = PeCoffBackend
521            .parse_with_pdb(&coff_fixture(), Some(b"not a pdb"))
522            .expect_err("COFF objects cannot have an external PDB companion");
523        assert!(error.to_string().contains("COFF object"));
524    }
525
526    #[test]
527    fn pdb_identity_requires_guid_and_accepts_a_newer_age() {
528        assert!(pdb_identity_matches([1; 16], 3, [1; 16], 3));
529        assert!(pdb_identity_matches([1; 16], 4, [1; 16], 3));
530        assert!(!pdb_identity_matches([2; 16], 4, [1; 16], 3));
531        assert!(!pdb_identity_matches([1; 16], 2, [1; 16], 3));
532    }
533
534    proptest::proptest! {
535        #[test]
536        fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
537            let _ = PeCoffBackend.parse(&bytes);
538        }
539    }
540}