Skip to main content

anchor_cli/debugger/
source.rs

1//! DWARF-backed `PC → (file, line)` resolver for SBF ELFs.
2//!
3//! Built on [`addr2line::Loader`] so we get DWARF-5 / split-DWARF / supplementary
4//! object support for free. The resolver is best-effort: if the ELF has no
5//! DWARF (stripped deploys, release builds without
6//! `CARGO_PROFILE_RELEASE_DEBUG=2`, programs built by third parties) we return
7//! `None` and the TUI source pane falls back to a "no source available" notice
8//! — the rest of the stepper is unaffected.
9//!
10//! SBF maps PCs to byte addresses as `text_addr + pc * INSN_SIZE`. LLVM emits
11//! standard DWARF line tuples on those byte addresses so `addr2line` works
12//! once we do that translation.
13
14use {
15    super::model::SrcLoc,
16    addr2line::Loader,
17    object::{Object, ObjectSection},
18    std::{
19        collections::BTreeSet,
20        path::{Path, PathBuf},
21    },
22};
23
24const INSN_SIZE: u64 = 8;
25
26/// Per-ELF source resolver. Cheap to query (one interval-tree lookup).
27pub struct SourceResolver {
28    inner: Option<Inner>,
29}
30
31struct Inner {
32    loader: Loader,
33    text_addr: u64,
34    text_size: u64,
35}
36
37impl SourceResolver {
38    /// Builds a resolver by re-reading the ELF from disk. Returns an empty
39    /// resolver when parsing fails or no text section is present.
40    pub fn from_elf_path(path: &Path) -> Self {
41        Self {
42            inner: build(path).ok(),
43        }
44    }
45
46    /// Resolves an SBPF program counter to a `(file, line)` pair, or `None`
47    /// when DWARF is unavailable or the PC has no line entry.
48    pub fn resolve(&self, pc: u64) -> Option<SrcLoc> {
49        let inner = self.inner.as_ref()?;
50        let vaddr = inner.text_addr.checked_add(pc.checked_mul(INSN_SIZE)?)?;
51        let loc = inner.loader.find_location(vaddr).ok().flatten()?;
52        Some(SrcLoc {
53            file: PathBuf::from(loc.file?),
54            line: loc.line?,
55        })
56    }
57
58    /// Resolves an SBPF program counter to its full DWARF inlining chain,
59    /// innermost-first (the deepest inlined body) out to the physical
60    /// caller. Returns an empty vec when DWARF is unavailable.
61    ///
62    /// `resolve` (via `find_location`) returns whichever line the DWARF
63    /// line program emitted at the PC — usually innermost, but aggressive
64    /// inlining can shove the entry back to an outer callsite. For
65    /// coverage, attributing a PC to *every* frame in the chain credits
66    /// the tiny `#[inline(always)]` wrappers (`Box<T>::load`,
67    /// `Sysvar::load`, `AccountLoader::next*`, etc.) that would otherwise
68    /// show 0% despite running on every transaction, matching the
69    /// behavior of `llvm-cov show` over expansion regions.
70    pub fn resolve_frames(&self, pc: u64) -> Vec<SrcLoc> {
71        let Some(inner) = self.inner.as_ref() else {
72            return Vec::<SrcLoc>::new();
73        };
74        let Some(vaddr) = inner
75            .text_addr
76            .checked_add(match pc.checked_mul(INSN_SIZE) {
77                Some(v) => v,
78                None => return Vec::<SrcLoc>::new(),
79            })
80        else {
81            return Vec::<SrcLoc>::new();
82        };
83        let mut out: Vec<SrcLoc> = Vec::new();
84        let Ok(mut frames) = inner.loader.find_frames(vaddr) else {
85            return out;
86        };
87        while let Ok(Some(frame)) = frames.next() {
88            if let Some(loc) = frame.location {
89                if let (Some(file), Some(line)) = (loc.file, loc.line) {
90                    out.push(SrcLoc {
91                        file: PathBuf::from(file),
92                        line,
93                    });
94                }
95            }
96        }
97        out
98    }
99
100    /// Enumerates every source line with a DWARF line-table row inside the
101    /// program's text section. Coverage uses this to emit zero-hit LCOV `DA`
102    /// records for executable lines that never appeared in the trace.
103    pub fn executable_lines(&self) -> Vec<SrcLoc> {
104        let Some(inner) = self.inner.as_ref() else {
105            return Vec::new();
106        };
107        let Some(text_end) = inner.text_addr.checked_add(inner.text_size) else {
108            return Vec::new();
109        };
110        let Ok(iter) = inner.loader.find_location_range(inner.text_addr, text_end) else {
111            return Vec::new();
112        };
113
114        let mut lines = BTreeSet::new();
115        for (_, _, loc) in iter {
116            if let (Some(file), Some(line)) = (loc.file, loc.line) {
117                if line != 0 {
118                    lines.insert((PathBuf::from(file), line));
119                }
120            }
121        }
122
123        lines
124            .into_iter()
125            .map(|(file, line)| SrcLoc { file, line })
126            .collect()
127    }
128
129    /// `true` when no DWARF context was built — the TUI uses this to render
130    /// a single "no source info" notice instead of per-step errors.
131    pub fn is_empty(&self) -> bool {
132        self.inner.is_none()
133    }
134}
135
136/// Prefix the Solana `platform-tools` build bakes into DWARF paths for
137/// stdlib files.
138#[cfg(target_os = "macos")]
139pub const CI_PLATFORM_TOOLS_PREFIX: &str =
140    "/Users/runner/work/platform-tools/platform-tools/out/rust/library/";
141#[cfg(target_os = "linux")]
142pub const CI_PLATFORM_TOOLS_PREFIX: &str =
143    "/home/runner/work/platform-tools/platform-tools/out/rust/library/";
144#[cfg(not(any(target_os = "macos", target_os = "linux")))]
145pub const CI_PLATFORM_TOOLS_PREFIX: &str = compile_error!("Current platform is not supported");
146
147/// Locate every `platform-tools/rust/lib/rustlib/src/rust/library/` tree
148/// under `~/.cache/solana/` and return them newest-version-first. Empty
149/// vec if the solana toolchain isn't installed via `agave-install`.
150///
151/// Callers pair these with [`CI_PLATFORM_TOOLS_PREFIX`] so stdlib frames
152/// emitted against the CI build path resolve to the local source tree.
153pub fn discover_platform_tools_stdlib_roots() -> Vec<PathBuf> {
154    let Some(home) = dirs::home_dir() else {
155        return Vec::new();
156    };
157    let base = home.join(".cache/solana");
158    let Ok(entries) = std::fs::read_dir(&base) else {
159        return Vec::new();
160    };
161    let mut versions: Vec<(String, PathBuf)> = entries
162        .flatten()
163        .filter_map(|e| {
164            let name = e.file_name().to_str()?.to_owned();
165            let candidate = e
166                .path()
167                .join("platform-tools/rust/lib/rustlib/src/rust/library");
168            candidate.is_dir().then_some((name, candidate))
169        })
170        .collect();
171    // Lexical sort on version strings like `v1.41`, `v1.52` — good enough
172    // for numeric-minor ordering up to v1.99; beyond that we'd want proper
173    // semver parsing.
174    versions.sort_by(|a, b| b.0.cmp(&a.0));
175    versions.into_iter().map(|(_, p)| p).collect()
176}
177
178fn build(path: &Path) -> anyhow::Result<Inner> {
179    // Load once to read `.text` address. This parse is cheap (metadata only).
180    let bytes = std::fs::read(path)?;
181    let file = object::File::parse(&*bytes)?;
182    let text = file
183        .sections()
184        .find(|s| s.name().ok() == Some(".text"))
185        .or_else(|| {
186            file.sections()
187                .find(|s| s.kind() == object::SectionKind::Text)
188        })
189        .ok_or_else(|| anyhow::anyhow!("no .text section"))?;
190    let text_addr = text.address();
191    let text_size = text.size();
192
193    let loader = Loader::new(path).map_err(|e| anyhow::anyhow!("load DWARF: {e}"))?;
194    Ok(Inner {
195        loader,
196        text_addr,
197        text_size,
198    })
199}