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::path::{Path, PathBuf},
19};
20
21const INSN_SIZE: u64 = 8;
22
23/// Per-ELF source resolver. Cheap to query (one interval-tree lookup).
24pub struct SourceResolver {
25 inner: Option<Inner>,
26}
27
28struct Inner {
29 loader: Loader,
30 text_addr: u64,
31}
32
33impl SourceResolver {
34 /// Builds a resolver by re-reading the ELF from disk. Returns an empty
35 /// resolver when parsing fails or no text section is present.
36 pub fn from_elf_path(path: &Path) -> Self {
37 Self {
38 inner: build(path).ok(),
39 }
40 }
41
42 /// Resolves an SBPF program counter to a `(file, line)` pair, or `None`
43 /// when DWARF is unavailable or the PC has no line entry.
44 pub fn resolve(&self, pc: u64) -> Option<SrcLoc> {
45 let inner = self.inner.as_ref()?;
46 let vaddr = inner.text_addr.checked_add(pc.checked_mul(INSN_SIZE)?)?;
47 let loc = inner.loader.find_location(vaddr).ok().flatten()?;
48 Some(SrcLoc {
49 file: PathBuf::from(loc.file?),
50 line: loc.line?,
51 })
52 }
53
54 /// Resolves an SBPF program counter to its full DWARF inlining chain,
55 /// innermost-first (the deepest inlined body) out to the physical
56 /// caller. Returns an empty vec when DWARF is unavailable.
57 ///
58 /// `resolve` (via `find_location`) returns whichever line the DWARF
59 /// line program emitted at the PC — usually innermost, but aggressive
60 /// inlining can shove the entry back to an outer callsite. For
61 /// coverage, attributing a PC to *every* frame in the chain credits
62 /// the tiny `#[inline(always)]` wrappers (`Box<T>::load`,
63 /// `Sysvar::load`, `AccountLoader::next*`, etc.) that would otherwise
64 /// show 0% despite running on every transaction, matching the
65 /// behavior of `llvm-cov show` over expansion regions.
66 pub fn resolve_frames(&self, pc: u64) -> Vec<SrcLoc> {
67 let Some(inner) = self.inner.as_ref() else {
68 return Vec::<SrcLoc>::new();
69 };
70 let Some(vaddr) = inner
71 .text_addr
72 .checked_add(match pc.checked_mul(INSN_SIZE) {
73 Some(v) => v,
74 None => return Vec::<SrcLoc>::new(),
75 })
76 else {
77 return Vec::<SrcLoc>::new();
78 };
79 let mut out: Vec<SrcLoc> = Vec::new();
80 let Ok(mut frames) = inner.loader.find_frames(vaddr) else {
81 return out;
82 };
83 while let Ok(Some(frame)) = frames.next() {
84 if let Some(loc) = frame.location {
85 if let (Some(file), Some(line)) = (loc.file, loc.line) {
86 out.push(SrcLoc {
87 file: PathBuf::from(file),
88 line,
89 });
90 }
91 }
92 }
93 out
94 }
95
96 /// `true` when no DWARF context was built — the TUI uses this to render
97 /// a single "no source info" notice instead of per-step errors.
98 pub fn is_empty(&self) -> bool {
99 self.inner.is_none()
100 }
101}
102
103/// Prefix the Solana `platform-tools` build bakes into DWARF paths for
104/// stdlib files.
105#[cfg(target_os = "macos")]
106pub const CI_PLATFORM_TOOLS_PREFIX: &str =
107 "/Users/runner/work/platform-tools/platform-tools/out/rust/library/";
108#[cfg(target_os = "linux")]
109pub const CI_PLATFORM_TOOLS_PREFIX: &str =
110 "/home/runner/work/platform-tools/platform-tools/out/rust/library/";
111#[cfg(not(any(target_os = "macos", target_os = "linux")))]
112pub const CI_PLATFORM_TOOLS_PREFIX: &str = compile_error!("Current platform is not supported");
113
114/// Locate every `platform-tools/rust/lib/rustlib/src/rust/library/` tree
115/// under `~/.cache/solana/` and return them newest-version-first. Empty
116/// vec if the solana toolchain isn't installed via `agave-install`.
117///
118/// Callers pair these with [`CI_PLATFORM_TOOLS_PREFIX`] so stdlib frames
119/// emitted against the CI build path resolve to the local source tree.
120pub fn discover_platform_tools_stdlib_roots() -> Vec<PathBuf> {
121 let Some(home) = dirs::home_dir() else {
122 return Vec::new();
123 };
124 let base = home.join(".cache/solana");
125 let Ok(entries) = std::fs::read_dir(&base) else {
126 return Vec::new();
127 };
128 let mut versions: Vec<(String, PathBuf)> = entries
129 .flatten()
130 .filter_map(|e| {
131 let name = e.file_name().to_str()?.to_owned();
132 let candidate = e
133 .path()
134 .join("platform-tools/rust/lib/rustlib/src/rust/library");
135 candidate.is_dir().then_some((name, candidate))
136 })
137 .collect();
138 // Lexical sort on version strings like `v1.41`, `v1.52` — good enough
139 // for numeric-minor ordering up to v1.99; beyond that we'd want proper
140 // semver parsing.
141 versions.sort_by(|a, b| b.0.cmp(&a.0));
142 versions.into_iter().map(|(_, p)| p).collect()
143}
144
145fn build(path: &Path) -> anyhow::Result<Inner> {
146 // Load once to read `.text` address. This parse is cheap (metadata only).
147 let bytes = std::fs::read(path)?;
148 let file = object::File::parse(&*bytes)?;
149 let text = file
150 .sections()
151 .find(|s| s.name().ok() == Some(".text"))
152 .or_else(|| {
153 file.sections()
154 .find(|s| s.kind() == object::SectionKind::Text)
155 })
156 .ok_or_else(|| anyhow::anyhow!("no .text section"))?;
157 let text_addr = text.address();
158
159 let loader = Loader::new(path).map_err(|e| anyhow::anyhow!("load DWARF: {e}"))?;
160 Ok(Inner { loader, text_addr })
161}