Skip to main content

codehelion_artifact/
dwarf.rs

1//! Format-neutral DWARF source-location extraction for artifact backends.
2
3use std::collections::{BTreeSet, HashMap};
4use std::hash::BuildHasher;
5
6use gimli::{DwarfSections, EndianSlice, Reader, RunTimeEndian};
7use object::{Endianness, Object, ObjectKind, ObjectSection};
8
9use crate::{ArtifactFingerprint, ArtifactInlineFrame, ArtifactIr, ArtifactSourceMapping};
10
11const MAX_DWARF_DEBUG_BYTES: u64 = 64 * 1024 * 1024;
12
13/// Attach optional DWARF locations to parser-established symbol identities.
14///
15/// Malformed or absent debug metadata deliberately degrades to no mappings.
16/// Addresses are used only for the local join and are never stored as IDs.
17#[allow(
18    clippy::too_many_lines,
19    reason = "DWARF collection and the local address-to-symbol join form one parser boundary"
20)]
21pub fn attach_dwarf_frames<S: BuildHasher>(
22    file: &object::File<'_>,
23    symbol_addresses: &HashMap<ArtifactFingerprint, (u64, u64), S>,
24    ir: &mut ArtifactIr,
25) {
26    if !supports_address_join(file.kind()) {
27        return;
28    }
29    let endian = match file.endianness() {
30        Endianness::Little => RunTimeEndian::Little,
31        Endianness::Big => RunTimeEndian::Big,
32    };
33    let mut remaining_debug_bytes = MAX_DWARF_DEBUG_BYTES;
34    let mut debug_info_unreadable = false;
35    let Ok(sections) = DwarfSections::load(|id| {
36        let Some(section) = debug_section(file, id.name()) else {
37            return Ok::<_, gimli::Error>(Vec::new());
38        };
39        let Ok(data) = section.compressed_data() else {
40            debug_info_unreadable = true;
41            return Ok(Vec::new());
42        };
43        if data.uncompressed_size > remaining_debug_bytes {
44            debug_info_unreadable = true;
45            return Ok(Vec::new());
46        }
47        let Ok(data) = data.decompress() else {
48            debug_info_unreadable = true;
49            return Ok(Vec::new());
50        };
51        remaining_debug_bytes -= data.len() as u64;
52        Ok(data.into_owned())
53    }) else {
54        return;
55    };
56    ir.capabilities.debug_info_unreadable |= debug_info_unreadable;
57    let dwarf = sections.borrow(|section| EndianSlice::new(section, endian));
58    let mut frames = Vec::new();
59    let mut line_records = Vec::new();
60    let mut line_paths = DwarfPathInterner::default();
61    let mut units = dwarf.units();
62    loop {
63        let header = match units.next() {
64            Ok(Some(header)) => header,
65            Ok(None) => break,
66            Err(_) => {
67                ir.capabilities.debug_info_unreadable = true;
68                break;
69            }
70        };
71        let Ok(unit) = dwarf.unit(header) else {
72            ir.capabilities.debug_info_unreadable = true;
73            continue;
74        };
75        let mut entries = unit.entries();
76        let mut depth = 0isize;
77        loop {
78            let entry = match entries.next_dfs() {
79                Ok(Some(entry)) => entry,
80                Ok(None) => break,
81                Err(_) => {
82                    ir.capabilities.debug_info_unreadable = true;
83                    break;
84                }
85            };
86            let (delta, entry) = entry;
87            depth += delta;
88            if !matches!(
89                entry.tag(),
90                gimli::DW_TAG_subprogram | gimli::DW_TAG_inlined_subroutine
91            ) {
92                continue;
93            }
94            let Some(frame) = source_frame(&dwarf, &unit, entry) else {
95                continue;
96            };
97            let Ok(mut ranges) = dwarf.die_ranges(&unit, entry) else {
98                ir.capabilities.debug_info_unreadable = true;
99                continue;
100            };
101            loop {
102                let range = match ranges.next() {
103                    Ok(Some(range)) => range,
104                    Ok(None) => break,
105                    Err(_) => {
106                        ir.capabilities.debug_info_unreadable = true;
107                        break;
108                    }
109                };
110                if range.begin < range.end {
111                    frames.push(DwarfFrame {
112                        begin: range.begin,
113                        end: range.end,
114                        depth,
115                        frame: frame.clone(),
116                    });
117                }
118            }
119        }
120        line_records.extend(line_frames(&dwarf, &unit, &mut line_paths));
121    }
122    if frames.is_empty() && line_records.is_empty() {
123        return;
124    }
125    frames.sort_by_key(|frame| frame.begin);
126    line_records.sort_by_key(|frame| frame.address);
127    let mut symbols: Vec<_> = symbol_addresses
128        .iter()
129        .map(|(fingerprint, (address, size))| (*fingerprint, *address, *size))
130        .collect();
131    symbols.sort_by_key(|(_, address, _)| *address);
132    let frame_matches = frames_at_symbol_addresses(&frames, &symbols);
133    let symbol_rows: HashMap<_, _> = ir
134        .symbols
135        .iter()
136        .enumerate()
137        .map(|(index, symbol)| (symbol.fingerprint, index))
138        .collect();
139    let mut source_paths = BTreeSet::new();
140    for ((fingerprint, address, size), frame_indexes) in symbols.into_iter().zip(frame_matches) {
141        let mut matching: Vec<_> = frame_indexes
142            .into_iter()
143            .map(|index| (frames[index].depth, frames[index].frame.clone()))
144            .collect();
145        let symbol_end = address.saturating_add(size);
146        let line_start = line_records.partition_point(|candidate| candidate.address < address);
147        matching.extend(
148            line_records
149                .get(line_start..)
150                .into_iter()
151                .flatten()
152                .take_while(|candidate| candidate.address < symbol_end)
153                .map(|candidate| (isize::MAX, candidate.inline_frame(&line_paths))),
154        );
155        matching.sort_by(|left, right| {
156            (&left.1.source, left.1.line, left.1.column, left.0).cmp(&(
157                &right.1.source,
158                right.1.line,
159                right.1.column,
160                right.0,
161            ))
162        });
163        matching.dedup_by(|left, right| left.1 == right.1);
164        if matching.is_empty() {
165            continue;
166        }
167        if let Some(index) = symbol_rows.get(&fingerprint) {
168            let symbol = &mut ir.symbols[*index];
169            symbol.inline_stack = matching.into_iter().map(|(_, frame)| frame).collect();
170            source_paths.extend(symbol.inline_stack.iter().map(|frame| frame.source.clone()));
171        }
172    }
173    ir.source_mappings.extend(
174        source_paths
175            .into_iter()
176            .map(|uri| ArtifactSourceMapping { uri }),
177    );
178}
179
180const fn supports_address_join(kind: ObjectKind) -> bool {
181    !matches!(kind, ObjectKind::Relocatable)
182}
183
184fn frames_at_symbol_addresses(
185    frames: &[DwarfFrame],
186    symbols: &[(ArtifactFingerprint, u64, u64)],
187) -> Vec<Vec<usize>> {
188    let mut active = BTreeSet::new();
189    let mut next = 0;
190    symbols
191        .iter()
192        .map(|(_, address, _)| {
193            while next < frames.len() && frames[next].begin <= *address {
194                active.insert((frames[next].end, next));
195                next += 1;
196            }
197            while active.first().is_some_and(|(end, _)| *end <= *address) {
198                let _ = active.pop_first();
199            }
200            active.iter().map(|(_, index)| *index).collect()
201        })
202        .collect()
203}
204
205fn debug_section<'data, 'file>(
206    file: &'file object::File<'data>,
207    name: &str,
208) -> Option<object::Section<'data, 'file>> {
209    file.section_by_name(name).or_else(|| {
210        let macho_name: String = format!("__{}", name.trim_start_matches('.'))
211            .chars()
212            .take(16)
213            .collect();
214        file.section_by_name(&macho_name)
215    })
216}
217
218#[derive(Debug, Clone)]
219struct DwarfFrame {
220    begin: u64,
221    end: u64,
222    depth: isize,
223    frame: ArtifactInlineFrame,
224}
225
226#[derive(Debug, Clone)]
227struct DwarfLineFrame {
228    address: u64,
229    source: u32,
230    line: u32,
231    /// Zero represents DWARF's left-edge position without a numeric column.
232    column: u32,
233}
234
235impl DwarfLineFrame {
236    fn inline_frame(&self, paths: &DwarfPathInterner) -> ArtifactInlineFrame {
237        ArtifactInlineFrame {
238            evidence_kind: crate::ArtifactSourceLocationEvidenceKind::Dwarf,
239            source: paths.get(self.source).to_owned(),
240            line: Some(self.line),
241            column: (self.column != 0).then_some(self.column),
242        }
243    }
244}
245
246/// Deduplicate resolved source paths while retaining compact line records.
247#[derive(Debug, Default)]
248struct DwarfPathInterner {
249    indexes: HashMap<String, usize>,
250    values: Vec<String>,
251}
252
253impl DwarfPathInterner {
254    fn intern(&mut self, path: String) -> Option<u32> {
255        if let Some(index) = self.indexes.get(&path) {
256            return u32::try_from(*index).ok();
257        }
258        let index = self.values.len();
259        let index = u32::try_from(index).ok()?;
260        self.values.push(path.clone());
261        self.indexes.insert(path, index as usize);
262        Some(index)
263    }
264
265    fn get(&self, index: u32) -> &str {
266        self.values
267            .get(index as usize)
268            .map_or("<invalid-dwarf-path>", String::as_str)
269    }
270}
271
272fn line_frames<R: Reader>(
273    dwarf: &gimli::Dwarf<R>,
274    unit: &gimli::Unit<R>,
275    paths: &mut DwarfPathInterner,
276) -> Vec<DwarfLineFrame> {
277    let Some(program) = unit.line_program.clone() else {
278        return Vec::new();
279    };
280    let compilation_directory = unit.comp_dir.as_ref().and_then(reader_string);
281    let mut rows = program.rows();
282    let mut frames = Vec::new();
283    while let Ok(Some((header, row))) = rows.next_row() {
284        if row.end_sequence() {
285            continue;
286        }
287        let Some(line) = row.line().and_then(|value| u32::try_from(value.get()).ok()) else {
288            continue;
289        };
290        let Some(file) = row.file(header) else {
291            continue;
292        };
293        let Some(source) = dwarf
294            .attr_string(unit, file.path_name())
295            .ok()
296            .and_then(|value| reader_string(&value))
297        else {
298            continue;
299        };
300        let directory = file
301            .directory(header)
302            .and_then(|value| dwarf.attr_string(unit, value).ok())
303            .and_then(|value| reader_string(&value));
304        let column = match row.column() {
305            gimli::ColumnType::LeftEdge => None,
306            gimli::ColumnType::Column(value) => u32::try_from(value.get()).ok(),
307        };
308        let Some(source) = paths.intern(resolve_source_path(
309            &source,
310            directory.as_deref(),
311            compilation_directory.as_deref(),
312        )) else {
313            continue;
314        };
315        frames.push(DwarfLineFrame {
316            address: row.address(),
317            source,
318            line,
319            column: column.unwrap_or(0),
320        });
321    }
322    frames
323}
324
325fn source_frame<R: Reader>(
326    dwarf: &gimli::Dwarf<R>,
327    unit: &gimli::Unit<R>,
328    entry: &gimli::DebuggingInformationEntry<'_, '_, R>,
329) -> Option<ArtifactInlineFrame> {
330    let attributes = if entry.tag() == gimli::DW_TAG_inlined_subroutine {
331        (
332            gimli::DW_AT_call_file,
333            gimli::DW_AT_call_line,
334            gimli::DW_AT_call_column,
335        )
336    } else {
337        (
338            gimli::DW_AT_decl_file,
339            gimli::DW_AT_decl_line,
340            gimli::DW_AT_decl_column,
341        )
342    };
343    let file_index = entry
344        .attr_value(attributes.0)
345        .ok()
346        .flatten()
347        .and_then(|value| value.udata_value())?;
348    let line_program = unit.line_program.as_ref()?;
349    let file = line_program.header().file(file_index)?;
350    let source = dwarf
351        .attr_string(unit, file.path_name())
352        .ok()
353        .and_then(|value| reader_string(&value))?;
354    let directory = file
355        .directory(line_program.header())
356        .and_then(|value| dwarf.attr_string(unit, value).ok())
357        .and_then(|value| reader_string(&value));
358    let compilation_directory = unit.comp_dir.as_ref().and_then(reader_string);
359    let line = entry
360        .attr_value(attributes.1)
361        .ok()
362        .flatten()
363        .and_then(|value| value.udata_value())
364        .and_then(|value| u32::try_from(value).ok());
365    let column = entry
366        .attr_value(attributes.2)
367        .ok()
368        .flatten()
369        .and_then(|value| value.udata_value())
370        .and_then(|value| u32::try_from(value).ok());
371    Some(ArtifactInlineFrame {
372        evidence_kind: crate::ArtifactSourceLocationEvidenceKind::Dwarf,
373        source: resolve_source_path(
374            &source,
375            directory.as_deref(),
376            compilation_directory.as_deref(),
377        ),
378        line,
379        column,
380    })
381}
382
383fn reader_string<R: Reader>(value: &R) -> Option<String> {
384    value
385        .to_string_lossy()
386        .ok()
387        .map(std::borrow::Cow::into_owned)
388}
389
390/// Resolve a DWARF path as metadata, without opening the declared source file.
391#[must_use]
392pub fn resolve_source_path(
393    file: &str,
394    directory: Option<&str>,
395    compilation_directory: Option<&str>,
396) -> String {
397    fn rooted(path: &str) -> bool {
398        path.starts_with('/')
399    }
400    fn under(base: &str, path: &str) -> String {
401        format!("{}/{path}", base.trim_end_matches('/'))
402    }
403    if rooted(file) {
404        return file.to_string();
405    }
406    let base = match directory {
407        Some(directory) if rooted(directory) => Some(directory.to_string()),
408        Some(directory) => compilation_directory.map(|root| under(root, directory)),
409        None => compilation_directory.map(ToString::to_string),
410    };
411    base.map_or_else(|| file.to_string(), |base| under(&base, file))
412}
413
414#[cfg(test)]
415mod tests {
416    use super::{
417        DwarfFrame, DwarfLineFrame, DwarfPathInterner, debug_section, frames_at_symbol_addresses,
418        resolve_source_path, supports_address_join,
419    };
420    use crate::{ArtifactFingerprint, ArtifactInlineFrame, ArtifactSourceLocationEvidenceKind};
421    use object::ObjectKind;
422
423    fn frame(begin: u64, end: u64) -> DwarfFrame {
424        DwarfFrame {
425            begin,
426            end,
427            depth: 0,
428            frame: ArtifactInlineFrame {
429                evidence_kind: ArtifactSourceLocationEvidenceKind::Dwarf,
430                source: "fixture.rs".to_owned(),
431                line: Some(1),
432                column: None,
433            },
434        }
435    }
436
437    #[test]
438    fn frame_join_advances_a_sweep_index_without_rescanning_all_frames() {
439        let frames = [frame(10, 30), frame(20, 25), frame(40, 50)];
440        let symbols = [
441            (ArtifactFingerprint::from_content("test", b"first"), 12, 4),
442            (ArtifactFingerprint::from_content("test", b"second"), 22, 2),
443            (ArtifactFingerprint::from_content("test", b"third"), 45, 3),
444        ];
445        assert_eq!(
446            frames_at_symbol_addresses(&frames, &symbols),
447            vec![vec![0], vec![1, 0], vec![2]]
448        );
449    }
450
451    #[test]
452    fn relocatable_objects_reject_address_only_dwarf_joins() {
453        assert!(!supports_address_join(ObjectKind::Relocatable));
454        assert!(supports_address_join(ObjectKind::Executable));
455        assert!(supports_address_join(ObjectKind::Dynamic));
456    }
457
458    #[test]
459    fn relative_paths_keep_their_declared_directory_context_without_reading_source() {
460        assert_eq!(
461            resolve_source_path("src/main.cpp", None, Some("/work/tree")),
462            "/work/tree/src/main.cpp"
463        );
464        assert_eq!(
465            resolve_source_path("header.hpp", Some("include"), Some("/work/tree")),
466            "/work/tree/include/header.hpp"
467        );
468        assert_eq!(
469            resolve_source_path("entry.cpp", Some("/other/build"), Some("/work/tree")),
470            "/other/build/entry.cpp"
471        );
472        assert_eq!(
473            resolve_source_path("/outside/entry.cpp", Some("include"), Some("/work/tree")),
474            "/outside/entry.cpp"
475        );
476        assert_eq!(
477            resolve_source_path("src/main.cpp", None, Some("/work/tree/")),
478            "/work/tree/src/main.cpp"
479        );
480        assert_eq!(
481            resolve_source_path("header.hpp", Some("include/"), Some("/work/tree/")),
482            "/work/tree/include/header.hpp"
483        );
484    }
485
486    #[test]
487    #[allow(clippy::unwrap_used)]
488    fn line_records_intern_repeated_paths_until_they_are_attached_to_symbols() {
489        let mut paths = DwarfPathInterner::default();
490        let first = paths.intern("/work/src/lib.rs".to_owned()).unwrap();
491        let second = paths.intern("/work/src/lib.rs".to_owned()).unwrap();
492        let frame = DwarfLineFrame {
493            address: 12,
494            source: first,
495            line: 5,
496            column: 0,
497        };
498
499        assert_eq!(first, second);
500        assert_eq!(paths.values.len(), 1);
501        assert_eq!(frame.inline_frame(&paths).source, "/work/src/lib.rs");
502    }
503
504    #[test]
505    #[allow(clippy::unwrap_used)]
506    fn compressed_legacy_debug_sections_are_found_and_decompressed() {
507        use std::io::Write;
508
509        use flate2::{Compression, write::ZlibEncoder};
510        use object::write::{Object as WriteObject, StandardSegment};
511        use object::{Architecture, BinaryFormat, Endianness, ObjectSection, SectionKind};
512
513        let payload = b"compressed dwarf fixture";
514        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
515        encoder.write_all(payload).unwrap();
516        let compressed = encoder.finish().unwrap();
517        let mut section_data = b"ZLIB".to_vec();
518        section_data.extend_from_slice(&(payload.len() as u64).to_be_bytes());
519        section_data.extend_from_slice(&compressed);
520
521        let mut writer =
522            WriteObject::new(BinaryFormat::Elf, Architecture::X86_64, Endianness::Little);
523        let section = writer.add_section(
524            writer.segment_name(StandardSegment::Debug).to_vec(),
525            b".zdebug_info".to_vec(),
526            SectionKind::Debug,
527        );
528        writer.append_section_data(section, &section_data, 1);
529        let bytes = writer.write().unwrap();
530        let file = object::File::parse(bytes.as_slice()).unwrap();
531        let section = debug_section(&file, ".debug_info").unwrap();
532
533        assert_eq!(section.uncompressed_data().unwrap().as_ref(), payload);
534    }
535}