anchor_coverage/
lib.rs

1use addr2line::Loader;
2use anyhow::{Result, anyhow};
3use byteorder::{LittleEndian, ReadBytesExt};
4use cargo_metadata::MetadataCommand;
5use std::{
6    collections::BTreeMap,
7    env::var_os,
8    fs::{File, OpenOptions, metadata},
9    io::Write,
10    path::{Path, PathBuf},
11};
12
13mod insn;
14use insn::Insn;
15
16mod start_address;
17use start_address::start_address;
18
19mod util;
20use util::{StripCurrentDir, files_with_extension};
21
22mod vaddr;
23use vaddr::Vaddr;
24
25#[cfg(test)]
26mod tests;
27
28#[derive(Clone, Debug, Default, Eq, PartialEq)]
29struct Entry<'a> {
30    file: &'a str,
31    line: u32,
32}
33
34struct Dwarf {
35    path: PathBuf,
36    start_address: u64,
37    #[allow(dead_code, reason = "`vaddr` points into `loader`")]
38    loader: &'static Loader,
39    vaddr_entry_map: BTreeMap<u64, Entry<'static>>,
40}
41
42enum Outcome {
43    Lcov(PathBuf),
44    ClosestMatch(PathBuf),
45}
46
47type Vaddrs = Vec<u64>;
48
49type VaddrEntryMap<'a> = BTreeMap<u64, Entry<'a>>;
50
51#[allow(dead_code)]
52#[derive(Debug)]
53struct ClosestMatch<'a, 'b> {
54    pcs_path: &'a Path,
55    debug_path: &'b Path,
56    mismatch: Mismatch,
57}
58
59#[allow(dead_code)]
60#[derive(Clone, Copy, Debug, Default)]
61struct Mismatch {
62    index: usize,
63    vaddr: Vaddr,
64    expected: Insn,
65    actual: Insn,
66}
67
68type FileLineCountMap<'a> = BTreeMap<&'a str, BTreeMap<u32, usize>>;
69
70pub fn run(sbf_trace_dir: impl AsRef<Path>, debug: bool) -> Result<()> {
71    let mut lcov_paths = Vec::new();
72    let mut closest_match_paths = Vec::new();
73
74    let debug_paths = debug_paths()?;
75
76    let dwarfs = debug_paths
77        .into_iter()
78        .map(|path| build_dwarf(&path))
79        .collect::<Result<Vec<_>>>()?;
80
81    if dwarfs.is_empty() {
82        eprintln!("Found no debug files");
83        return Ok(());
84    }
85
86    if debug {
87        for dwarf in dwarfs {
88            dump_vaddr_entry_map(dwarf.vaddr_entry_map);
89        }
90        return Ok(());
91    }
92
93    let pcs_paths = files_with_extension(&sbf_trace_dir, "pcs")?;
94
95    for pcs_path in &pcs_paths {
96        match process_pcs_path(&dwarfs, pcs_path)? {
97            Outcome::Lcov(lcov_path) => {
98                lcov_paths.push(lcov_path.strip_current_dir().to_path_buf());
99            }
100            Outcome::ClosestMatch(closest_match_path) => {
101                closest_match_paths.push(closest_match_path.strip_current_dir().to_path_buf());
102            }
103        }
104    }
105
106    eprintln!(
107        "
108Processed {} of {} program counter files
109
110Lcov files written: {lcov_paths:#?}
111
112Closest match files written: {closest_match_paths:#?}
113
114If you are done generating lcov files, try running:
115
116    genhtml --output-directory coverage {}/*.lcov && open coverage/index.html
117",
118        lcov_paths.len(),
119        pcs_paths.len(),
120        sbf_trace_dir.as_ref().strip_current_dir().display()
121    );
122
123    Ok(())
124}
125
126fn debug_paths() -> Result<Vec<PathBuf>> {
127    let metadata = MetadataCommand::new().no_deps().exec()?;
128    let target_directory = metadata.target_directory;
129    files_with_extension(target_directory.join("deploy"), "debug")
130}
131
132fn build_dwarf(debug_path: &Path) -> Result<Dwarf> {
133    let start_address = start_address(debug_path)?;
134
135    let loader = Loader::new(debug_path).map_err(|error| {
136        anyhow!(
137            "failed to build loader for {}: {}",
138            debug_path.display(),
139            error.to_string()
140        )
141    })?;
142
143    let loader = Box::leak(Box::new(loader));
144
145    let vaddr_entry_map = build_vaddr_entry_map(loader, debug_path)?;
146
147    Ok(Dwarf {
148        path: debug_path.to_path_buf(),
149        start_address,
150        loader,
151        vaddr_entry_map,
152    })
153}
154
155fn process_pcs_path(dwarfs: &[Dwarf], pcs_path: &Path) -> Result<Outcome> {
156    eprintln!();
157    eprintln!(
158        "Program counters file: {}",
159        pcs_path.strip_current_dir().display()
160    );
161
162    let mut vaddrs = read_vaddrs(pcs_path)?;
163
164    eprintln!("Program counters read: {}", vaddrs.len());
165
166    let (dwarf, mismatch) = find_applicable_dwarf(dwarfs, pcs_path, &mut vaddrs)?;
167
168    if let Some(mismatch) = mismatch {
169        return write_closest_match(pcs_path, dwarf, mismatch).map(Outcome::ClosestMatch);
170    }
171
172    eprintln!(
173        "Applicable dwarf: {}",
174        dwarf.path.strip_current_dir().display()
175    );
176
177    assert!(
178        vaddrs
179            .first()
180            .is_some_and(|&vaddr| vaddr == dwarf.start_address)
181    );
182
183    // smoelius: If a sequence of program counters refer to the same file and line, treat them as
184    // one hit to that file and line.
185    vaddrs.dedup_by_key::<_, Option<&Entry>>(|vaddr| dwarf.vaddr_entry_map.get(vaddr));
186
187    // smoelius: A `vaddr` could not have an entry because its file does not exist. Keep only those
188    // `vaddr`s that have entries.
189    let vaddrs = vaddrs
190        .into_iter()
191        .filter(|vaddr| dwarf.vaddr_entry_map.contains_key(vaddr))
192        .collect::<Vec<_>>();
193
194    eprintln!("Line hits: {}", vaddrs.len());
195
196    let file_line_count_map = build_file_line_count_map(&dwarf.vaddr_entry_map, vaddrs);
197
198    write_lcov_file(pcs_path, file_line_count_map).map(Outcome::Lcov)
199}
200
201static CARGO_HOME: std::sync::LazyLock<PathBuf> = std::sync::LazyLock::new(|| {
202    if let Some(cargo_home) = var_os("CARGO_HOME") {
203        PathBuf::from(cargo_home)
204    } else {
205        #[allow(deprecated)]
206        #[cfg_attr(
207            dylint_lib = "inconsistent_qualification",
208            allow(inconsistent_qualification)
209        )]
210        std::env::home_dir().unwrap().join(".cargo")
211    }
212});
213
214fn build_vaddr_entry_map<'a>(loader: &'a Loader, debug_path: &Path) -> Result<VaddrEntryMap<'a>> {
215    let mut vaddr_entry_map = VaddrEntryMap::new();
216    let metadata = metadata(debug_path)?;
217    for vaddr in (0..metadata.len()).step_by(size_of::<u64>()) {
218        let location = loader.find_location(vaddr).map_err(|error| {
219            anyhow!(
220                "failed to find location for address 0x{vaddr:x}: {}",
221                error.to_string()
222            )
223        })?;
224        let Some(location) = location else {
225            continue;
226        };
227        let Some(file) = location.file else {
228            continue;
229        };
230        // smoelius: Ignore files that do not exist.
231        if !Path::new(file).try_exists()? {
232            continue;
233        }
234        if !include_cargo() && file.starts_with(CARGO_HOME.to_string_lossy().as_ref()) {
235            continue;
236        }
237        let Some(line) = location.line else {
238            continue;
239        };
240        // smoelius: Even though we ignore columns, fetch them should we ever want to act on them.
241        let Some(_column) = location.column else {
242            continue;
243        };
244        let entry = vaddr_entry_map.entry(vaddr).or_default();
245        entry.file = file;
246        entry.line = line;
247    }
248    Ok(vaddr_entry_map)
249}
250
251fn dump_vaddr_entry_map(vaddr_entry_map: BTreeMap<u64, Entry<'_>>) {
252    let mut prev = String::new();
253    for (vaddr, Entry { file, line }) in vaddr_entry_map {
254        let curr = format!("{file}:{line}");
255        if prev != curr {
256            eprintln!("0x{vaddr:x}: {curr}");
257            prev = curr;
258        }
259    }
260}
261
262fn read_vaddrs(pcs_path: &Path) -> Result<Vaddrs> {
263    let mut vaddrs = Vaddrs::new();
264    let mut pcs_file = File::open(pcs_path)?;
265    while let Ok(pc) = pcs_file.read_u64::<LittleEndian>() {
266        let vaddr = pc << 3;
267        vaddrs.push(vaddr);
268    }
269    Ok(vaddrs)
270}
271
272fn find_applicable_dwarf<'a>(
273    dwarfs: &'a [Dwarf],
274    pcs_path: &Path,
275    vaddrs: &mut [u64],
276) -> Result<(&'a Dwarf, Option<Mismatch>)> {
277    let dwarf_mismatches = collect_dwarf_mismatches(dwarfs, pcs_path, vaddrs)?;
278
279    if let Some((dwarf, _)) = dwarf_mismatches
280        .iter()
281        .find(|(_, mismatch)| mismatch.is_none())
282    {
283        let vaddr_first = *vaddrs.first().unwrap();
284
285        assert!(dwarf.start_address >= vaddr_first);
286
287        let shift = dwarf.start_address - vaddr_first;
288
289        // smoelius: Make the shift "permanent".
290        vaddrs.iter_mut().for_each(|vaddr| *vaddr += shift);
291
292        return Ok((dwarf, None));
293    }
294
295    Ok(dwarf_mismatches
296        .into_iter()
297        .max_by_key(|(_, mismatch)| mismatch.as_ref().unwrap().index)
298        .unwrap())
299}
300
301fn collect_dwarf_mismatches<'a>(
302    dwarfs: &'a [Dwarf],
303    pcs_path: &Path,
304    vaddrs: &[u64],
305) -> Result<Vec<(&'a Dwarf, Option<Mismatch>)>> {
306    dwarfs
307        .iter()
308        .map(|dwarf| {
309            let mismatch = dwarf_mismatch(vaddrs, dwarf, pcs_path)?;
310            Ok((dwarf, mismatch))
311        })
312        .collect()
313}
314
315fn dwarf_mismatch(vaddrs: &[u64], dwarf: &Dwarf, pcs_path: &Path) -> Result<Option<Mismatch>> {
316    use std::io::{Seek, SeekFrom};
317
318    let Some(&vaddr_first) = vaddrs.first() else {
319        return Ok(Some(Mismatch::default()));
320    };
321
322    if dwarf.start_address < vaddr_first {
323        return Ok(Some(Mismatch::default()));
324    }
325
326    // smoelius: `start_address` is both an offset into the ELF file and a virtual address. The
327    // current virtual addresses are offsets from the start of the text section. The current virtual
328    // addresses must be shifted so that the first matches the start address.
329    let shift = dwarf.start_address - vaddr_first;
330
331    let mut so_file = File::open(dwarf.path.with_extension("so"))?;
332    let mut insns_file = File::open(pcs_path.with_extension("insns"))?;
333
334    for (index, &vaddr) in vaddrs.iter().enumerate() {
335        let vaddr = vaddr + shift;
336
337        so_file.seek(SeekFrom::Start(vaddr))?;
338        let expected = so_file.read_u64::<LittleEndian>()?;
339
340        let actual = insns_file.read_u64::<LittleEndian>()?;
341
342        // smoelius: 0x85 is a function call. That they would be patched and differ is not
343        // surprising.
344        if expected & 0xff == 0x85 {
345            continue;
346        }
347
348        if expected != actual {
349            return Ok(Some(Mismatch {
350                index,
351                vaddr: Vaddr::from(vaddr),
352                expected: Insn::from(expected),
353                actual: Insn::from(actual),
354            }));
355        }
356    }
357
358    Ok(None)
359}
360
361fn write_closest_match(pcs_path: &Path, dwarf: &Dwarf, mismatch: Mismatch) -> Result<PathBuf> {
362    let closest_match_path = pcs_path.with_extension("closest_match");
363    let mut file = OpenOptions::new()
364        .create(true)
365        .truncate(true)
366        .write(true)
367        .open(&closest_match_path)?;
368    writeln!(
369        file,
370        "{:#?}",
371        ClosestMatch {
372            pcs_path,
373            debug_path: &dwarf.path,
374            mismatch
375        }
376    )?;
377    Ok(closest_match_path)
378}
379
380fn build_file_line_count_map<'a>(
381    vaddr_entry_map: &BTreeMap<u64, Entry<'a>>,
382    vaddrs: Vaddrs,
383) -> FileLineCountMap<'a> {
384    let mut file_line_count_map = FileLineCountMap::new();
385    for Entry { file, line } in vaddr_entry_map.values() {
386        let line_count_map = file_line_count_map.entry(file).or_default();
387        line_count_map.insert(*line, 0);
388    }
389
390    for vaddr in vaddrs {
391        // smoelius: A `vaddr` could not have an entry because its file does not exist.
392        let Some(entry) = vaddr_entry_map.get(&vaddr) else {
393            continue;
394        };
395        let line_count_map = file_line_count_map.get_mut(entry.file).unwrap();
396        let count = line_count_map.get_mut(&entry.line).unwrap();
397        *count += 1;
398    }
399
400    file_line_count_map
401}
402
403fn write_lcov_file(pcs_path: &Path, file_line_count_map: FileLineCountMap<'_>) -> Result<PathBuf> {
404    let lcov_path = Path::new(pcs_path).with_extension("lcov");
405
406    let mut file = OpenOptions::new()
407        .create(true)
408        .truncate(true)
409        .write(true)
410        .open(&lcov_path)?;
411
412    for (source_file, line_count_map) in file_line_count_map {
413        // smoelius: Stripping `current_dir` from `source_file` has not effect on what's displayed.
414        writeln!(file, "SF:{source_file}")?;
415        for (line, count) in line_count_map {
416            writeln!(file, "DA:{line},{count}")?;
417        }
418        writeln!(file, "end_of_record")?;
419    }
420
421    Ok(lcov_path)
422}
423
424fn include_cargo() -> bool {
425    var_os("INCLUDE_CARGO").is_some()
426}