anchor_cli/debugger/loose.rs
1//! "Loose" mode for `anchor debugger` — runs in any cargo workspace
2//! without requiring an `Anchor.toml`.
3//!
4//! Anchor projects ship an `Anchor.toml` that maps program names to
5//! deployed pubkeys and pins the `cargo test` invocation. Bench /
6//! research workspaces are plain cargo workspaces that happen to use
7//! `anchor-lang-v2` as a library — forcing them to author an Anchor.toml
8//! just to use the debugger would be friction for no payoff.
9//!
10//! This module covers everything the Anchor.toml-driven path provides:
11//!
12//! - **Workspace root** — walk up from cwd looking for the nearest
13//! `Cargo.toml` declaring `[workspace]`.
14//! - **Current package** — read `<cwd>/Cargo.toml` for the `[package] name`.
15//! - **Test invocation** — `cargo test --features profile -p <pkg>` from
16//! the workspace root.
17//! - **Program → ELF map** — pair `target/deploy/*.so` with the matching
18//! `*-keypair.json` to recover the deployed pubkey.
19//!
20//! Sanity checks fire as early as possible so the user gets actionable
21//! errors instead of opaque "no traces" messages later.
22
23use {
24 anyhow::{anyhow, Context, Result},
25 serde::Deserialize,
26 solana_keypair::read_keypair_file,
27 solana_pubkey::Pubkey,
28 solana_signer::Signer,
29 std::{
30 collections::BTreeMap,
31 path::{Path, PathBuf},
32 process::Command,
33 str::FromStr,
34 },
35};
36
37/// Discovered cargo workspace context.
38pub struct LooseWorkspace {
39 /// Directory containing the `[workspace]` Cargo.toml.
40 pub root: PathBuf,
41 /// Directory we were invoked from. May or may not be a member crate
42 /// — the user might run from the workspace root itself.
43 pub cwd: PathBuf,
44 /// `[package] name` from `<cwd>/Cargo.toml`, when the cwd is a crate.
45 /// `None` means the user ran from a non-crate dir (e.g. workspace root
46 /// with no top-level package); we'll skip the `-p <pkg>` filter.
47 pub current_package: Option<String>,
48}
49
50#[derive(Deserialize)]
51struct CargoToml {
52 #[serde(default)]
53 package: Option<PackageSection>,
54 #[serde(default)]
55 workspace: Option<WorkspaceSection>,
56 #[serde(default)]
57 features: BTreeMap<String, Vec<String>>,
58 #[serde(default)]
59 #[serde(rename = "dev-dependencies")]
60 dev_dependencies: BTreeMap<String, toml::Value>,
61}
62
63#[derive(Deserialize)]
64struct PackageSection {
65 name: String,
66}
67
68#[derive(Deserialize)]
69struct WorkspaceSection {}
70
71impl LooseWorkspace {
72 /// Discover the workspace context starting from `cwd`. Errors only on
73 /// the unrecoverable case (no enclosing cargo workspace at all).
74 pub fn discover(cwd: PathBuf) -> Result<Self> {
75 let root = find_workspace_root(&cwd).ok_or_else(|| {
76 anyhow!(
77 "no cargo workspace found at or above {} — `anchor debugger` needs either an \
78 Anchor.toml or a cargo `[workspace]` Cargo.toml",
79 cwd.display()
80 )
81 })?;
82
83 // Best-effort: read the cwd's Cargo.toml to learn the package name.
84 // Failure is non-fatal (the cwd might be the workspace root itself
85 // or a bare directory) — we just skip the `-p <pkg>` filter.
86 let current_package = read_cargo_toml(&cwd.join("Cargo.toml"))
87 .ok()
88 .and_then(|m| m.package.map(|p| p.name));
89
90 Ok(Self {
91 root,
92 cwd,
93 current_package,
94 })
95 }
96
97 /// Return the dir to invoke `cargo` from. We prefer the current
98 /// package's dir when present so its `[package.metadata.*]` settings
99 /// take effect; otherwise fall back to the workspace root.
100 pub fn cargo_invocation_dir(&self) -> &Path {
101 if self.current_package.is_some() {
102 &self.cwd
103 } else {
104 &self.root
105 }
106 }
107
108 /// Sanity-check the cwd's Cargo.toml exposes a `profile` feature that
109 /// activates `anchor-v2-testing/profile`. Returns the discovered
110 /// feature name (almost always `"profile"`) or a hard error if we
111 /// can't find anything that would trigger trace writing.
112 ///
113 /// Two acceptable shapes:
114 /// 1. `profile = ["anchor-v2-testing/profile"]` — the convention.
115 /// 2. Any feature that contains `"anchor-v2-testing/profile"` — for
116 /// workspaces that name it differently (e.g. `tracing`).
117 pub fn detect_profile_feature(&self) -> Result<String> {
118 let pkg_manifest = self.cwd.join("Cargo.toml");
119 let manifest = read_cargo_toml(&pkg_manifest)
120 .with_context(|| format!("read {}", pkg_manifest.display()))?;
121
122 // Fast path: the conventional name.
123 let convention = "profile";
124 if manifest
125 .features
126 .get(convention)
127 .is_some_and(|v| v.iter().any(|s| s == "anchor-v2-testing/profile"))
128 {
129 return Ok(convention.to_owned());
130 }
131
132 // Slow path: any feature that propagates to anchor-v2-testing/profile.
133 for (name, deps) in &manifest.features {
134 if deps.iter().any(|s| s == "anchor-v2-testing/profile") {
135 return Ok(name.clone());
136 }
137 }
138
139 Err(anyhow!(
140 "no cargo feature in {pkg} forwards to `anchor-v2-testing/profile`.\n\nAdd this to \
141 {manifest_path}:\n [features]\n profile = [\"anchor-v2-testing/profile\"]\n\nTests \
142 don't need any cfg gates — `anchor_v2_testing::svm()` is\n`LiteSVM::new()` by \
143 default and switches to the trace-recording\nvariant automatically when this feature \
144 is on.",
145 pkg = self
146 .current_package
147 .as_deref()
148 .unwrap_or("the current crate"),
149 manifest_path = pkg_manifest.display(),
150 ))
151 }
152
153 /// Sanity-check that `anchor-v2-testing` is actually a dev-dependency.
154 /// Without it, `--features profile` would fail with an opaque cargo
155 /// error; we'd rather flag this up front.
156 pub fn check_dev_dep(&self) -> Result<()> {
157 let pkg_manifest = self.cwd.join("Cargo.toml");
158 let manifest = read_cargo_toml(&pkg_manifest)
159 .with_context(|| format!("read {}", pkg_manifest.display()))?;
160 if !manifest.dev_dependencies.contains_key("anchor-v2-testing") {
161 return Err(anyhow!(
162 "{} doesn't list `anchor-v2-testing` as a dev-dependency.\nAdd it under \
163 [dev-dependencies] before running `anchor debugger`.",
164 pkg_manifest.display()
165 ));
166 }
167 Ok(())
168 }
169}
170
171/// Walk up from `start` to the nearest `Cargo.toml` declaring a
172/// `[workspace]` table. Returns the directory containing it.
173///
174/// Falls back to the start dir's `Cargo.toml` if it has `[workspace]`,
175/// otherwise keeps walking. `None` means we hit the filesystem root with
176/// no match — caller treats that as a hard error.
177fn find_workspace_root(start: &Path) -> Option<PathBuf> {
178 let mut cur: PathBuf = start.to_path_buf();
179 loop {
180 let manifest = cur.join("Cargo.toml");
181 if manifest.is_file() {
182 if let Ok(parsed) = read_cargo_toml(&manifest) {
183 if parsed.workspace.is_some() {
184 return Some(cur);
185 }
186 }
187 }
188 if !cur.pop() {
189 return None;
190 }
191 }
192}
193
194fn read_cargo_toml(path: &Path) -> Result<CargoToml> {
195 let contents =
196 std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
197 toml::from_str(&contents).with_context(|| format!("parse {}", path.display()))
198}
199
200/// Walk the workspace's SBF build artifacts and return a base58
201/// program-id → `.so` path map. Two location families are considered:
202///
203/// 1. **`target/deploy/`** — `cargo-build-sbf`'s default output. Always
204/// preferred when the same `<lib>.so` exists in both locations,
205/// because this is the post-link form solana-sbpf can parse.
206/// 2. **`target/sbpf-solana-solana/release/`** — the cargo target dir
207/// when SBF builds are driven directly (e.g. bench workspaces). Used
208/// as a fallback only.
209///
210/// For each chosen `.so`, **every** pubkey we can associate with it is
211/// added as a key:
212///
213/// - The pubkey from the sibling `<lib>-keypair.json` (if present).
214/// - The pubkey from `declare_id!("...")` in the crate's source (if the
215/// crate uses anchor's `declare_id!` macro).
216///
217/// Mapping all known pubkeys to the same `.so` matters because
218/// `cargo-build-sbf` generates a *fresh* random keypair on first build
219/// — `declare_id!` (the runtime id used by `Program::id()` and seen in
220/// traces) won't match the keypair file unless the user runs
221/// `anchor keys sync`. Without dual-mapping, the trace's pubkey
222/// resolves through the unstripped fallback path and ELF parse fails.
223///
224/// Defensive throughout: missing dirs are not errors (just skipped),
225/// 0-byte artifacts are skipped, malformed keypairs / declare_id
226/// literals are skipped. Empty result is legal — the caller surfaces a
227/// clear "build first?" message rather than failing here.
228pub fn discover_programs(
229 workspace_root: &Path,
230 current_package: Option<&str>,
231) -> Result<BTreeMap<String, PathBuf>> {
232 // Pre-cache the lib_name → pubkey map from `declare_id!` source scan.
233 let lib_to_declare_id = scan_declare_ids(workspace_root);
234
235 // Collect candidate (lib_name → preferred .so path). `target/deploy/`
236 // always wins because it's the only form solana-sbpf can parse;
237 // `target/sbpf-solana-solana/release/` is a fallback for workspaces
238 // that haven't run `cargo build-sbf`.
239 let mut lib_to_so: BTreeMap<String, PathBuf> = BTreeMap::new();
240
241 let deploy_dir = workspace_root.join("target").join("deploy");
242 if deploy_dir.is_dir() {
243 collect_so_paths(&deploy_dir, &mut lib_to_so);
244 }
245 let sbf_release = workspace_root
246 .join("target")
247 .join("sbpf-solana-solana")
248 .join("release");
249 if sbf_release.is_dir() {
250 // Only fill gaps deploy/ didn't cover.
251 collect_so_paths_if_missing(&sbf_release, &mut lib_to_so);
252 }
253
254 // Build the final pubkey → .so map. For each chosen .so we associate
255 // all known pubkeys (keypair file in deploy/ + declare_id! in source).
256 let mut out: BTreeMap<String, PathBuf> = BTreeMap::new();
257 for (lib_name, so_path) in &lib_to_so {
258 let mut pubkeys: Vec<String> = Vec::with_capacity(2);
259
260 // Source declare_id! — almost always what the runtime sees.
261 if let Some(pk) = lib_to_declare_id.get(lib_name) {
262 pubkeys.push(pk.clone());
263 }
264
265 // Sibling keypair pubkey — present when cargo-build-sbf wrote
266 // here, may differ from declare_id! when keys aren't synced.
267 let keypair_path = deploy_dir.join(format!("{lib_name}-keypair.json"));
268 if keypair_path.is_file() {
269 if let Ok(kp) = read_keypair_file(&keypair_path) {
270 pubkeys.push(kp.pubkey().to_string());
271 }
272 }
273
274 // Filename-as-pubkey: the .so stem itself parses as a valid
275 // 32-byte base58 pubkey. This is how `solana program dump <pk>`
276 // names its output — lets blackbox binaries (mainnet dumps, no
277 // source, no keypair) drop straight into `target/deploy/` with
278 // no scaffolding. Stricter than a length + alphabet check
279 // because `Pubkey::from_str` also enforces the 32-byte decode.
280 if pubkeys.is_empty() {
281 if let Ok(pk) = Pubkey::from_str(lib_name) {
282 pubkeys.push(pk.to_string());
283 }
284 }
285
286 if pubkeys.is_empty() {
287 // No discoverable id — skip rather than poison the map.
288 continue;
289 }
290 // The current package (the crate the user invoked the debugger
291 // from) always wins when multiple libs share the same pubkey.
292 // This handles the common bench layout where v1 and v2 both
293 // declare the same program id for apples-to-apples comparison.
294 let is_current = current_package
295 .map(|pkg| lib_name.replace('-', "_") == pkg.replace('-', "_"))
296 .unwrap_or(false);
297 for pk in pubkeys {
298 if is_current {
299 out.insert(pk, so_path.clone());
300 } else {
301 out.entry(pk).or_insert_with(|| so_path.clone());
302 }
303 }
304 }
305 Ok(out)
306}
307
308/// Insert each `.so` in `dir` keyed by its `file_stem` (lib name).
309/// Replaces any prior entry — used for the preferred location pass.
310fn collect_so_paths(dir: &Path, out: &mut BTreeMap<String, PathBuf>) {
311 let Ok(entries) = std::fs::read_dir(dir) else {
312 return;
313 };
314 for entry in entries.flatten() {
315 let path = entry.path();
316 if path.extension().and_then(|s| s.to_str()) != Some("so") {
317 continue;
318 }
319 if path.metadata().map(|m| m.len() == 0).unwrap_or(true) {
320 continue;
321 }
322 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
323 continue;
324 };
325 out.insert(stem.to_owned(), path);
326 }
327}
328
329/// Same as [`collect_so_paths`] but only fills entries that aren't
330/// already present — used for the fallback location pass.
331fn collect_so_paths_if_missing(dir: &Path, out: &mut BTreeMap<String, PathBuf>) {
332 let Ok(entries) = std::fs::read_dir(dir) else {
333 return;
334 };
335 for entry in entries.flatten() {
336 let path = entry.path();
337 if path.extension().and_then(|s| s.to_str()) != Some("so") {
338 continue;
339 }
340 if path.metadata().map(|m| m.len() == 0).unwrap_or(true) {
341 continue;
342 }
343 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
344 continue;
345 };
346 out.entry(stem.to_owned()).or_insert(path);
347 }
348}
349
350/// Walk every `lib.rs` / `main.rs` under the workspace looking for
351/// `declare_id!("...")`. For each match, pair the pubkey with the lib
352/// name declared in the enclosing crate's `Cargo.toml` (or the package
353/// name with `-` → `_` if `[lib] name` is omitted) so callers can match
354/// by `.so` file stem.
355///
356/// Best-effort: read failures, parse failures, and crates without a
357/// `declare_id!` are silently skipped. The map is small (typically ≤ ~20
358/// programs in a bench workspace) so even a full traversal is cheap.
359fn scan_declare_ids(workspace_root: &Path) -> BTreeMap<String, String> {
360 let mut out = BTreeMap::new();
361 walk_declare_ids(workspace_root, &mut out, 0);
362 out
363}
364
365fn walk_declare_ids(dir: &Path, out: &mut BTreeMap<String, String>, depth: u8) {
366 // Hard cap on recursion. Workspaces with members 6 levels deep are
367 // exotic; this keeps us out of pathological symlink loops.
368 if depth > 8 {
369 return;
370 }
371 let Ok(entries) = std::fs::read_dir(dir) else {
372 return;
373 };
374 for entry in entries.flatten() {
375 let path = entry.path();
376 if path.is_dir() {
377 // Skip well-known build / vendor dirs that can never contain
378 // a workspace member.
379 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
380 continue;
381 };
382 if matches!(name, "target" | "node_modules" | ".git") || name.starts_with('.') {
383 continue;
384 }
385 walk_declare_ids(&path, out, depth + 1);
386 } else if path.file_name() == Some(std::ffi::OsStr::new("lib.rs"))
387 || path.file_name() == Some(std::ffi::OsStr::new("main.rs"))
388 {
389 if let Some((lib_name, pubkey)) = extract_id_pair(&path) {
390 out.entry(lib_name).or_insert(pubkey);
391 }
392 }
393 }
394}
395
396/// Pull `declare_id!("...")` from the source file and the lib name from
397/// the nearest enclosing `Cargo.toml`. Returns `None` if either is
398/// missing or malformed.
399fn extract_id_pair(src_file: &Path) -> Option<(String, String)> {
400 let contents = std::fs::read_to_string(src_file).ok()?;
401 let pubkey = find_declare_id(&contents)?;
402
403 // Walk up to the nearest Cargo.toml (within this crate's tree).
404 let mut cur = src_file.parent()?;
405 loop {
406 let manifest = cur.join("Cargo.toml");
407 if manifest.is_file() {
408 let Ok(parsed) = read_cargo_toml(&manifest) else {
409 return None;
410 };
411 // Prefer [lib] name; fall back to package.name with kebab → snake.
412 let lib_name = lib_name_from_manifest(&manifest)
413 .or_else(|| parsed.package.map(|p| p.name.replace('-', "_")))?;
414 return Some((lib_name, pubkey));
415 }
416 cur = cur.parent()?;
417 }
418}
419
420/// `[lib] name` is in a separate raw-toml lookup because we don't want to
421/// bake every Cargo.toml shape into the strongly-typed `CargoToml`
422/// struct — the schema is fluid.
423fn lib_name_from_manifest(manifest: &Path) -> Option<String> {
424 let s = std::fs::read_to_string(manifest).ok()?;
425 let v: toml::Value = toml::from_str(&s).ok()?;
426 v.get("lib")?.get("name")?.as_str().map(str::to_owned)
427}
428
429/// Find the first `declare_id!("...")` literal in `src`. Tolerant of
430/// whitespace and the alternate `declare_id! ( ... )` paren style.
431fn find_declare_id(src: &str) -> Option<String> {
432 let idx = src.find("declare_id!")?;
433 let after = &src[idx + "declare_id!".len()..];
434 let after = after.trim_start_matches(|c: char| c.is_whitespace());
435 // Accept `(` or `[` or `{` as the macro delimiter; we only need the
436 // first quoted string after.
437 let after = after.trim_start_matches(['(', '[', '{']);
438 let quote = after.find('"')?;
439 let body = &after[quote + 1..];
440 let end = body.find('"')?;
441 let id = &body[..end];
442 // Pubkey sanity: base58 alphabet, 32-44 chars.
443 if id.len() < 32 || id.len() > 44 {
444 return None;
445 }
446 if !id
447 .bytes()
448 .all(|b| b.is_ascii_alphanumeric() && b != b'0' && b != b'O' && b != b'I' && b != b'l')
449 {
450 return None;
451 }
452 Some(id.to_owned())
453}
454
455/// Run `cargo build-sbf -p <pkg>` from the workspace root. This produces
456/// the post-linked `.so` + sibling keypair under `target/deploy/` that
457/// solana-sbpf can parse — the raw `cargo build --target sbpf-solana-solana`
458/// artifact in `target/sbpf-solana-solana/release/` is missing relocation
459/// metadata our debugger needs.
460///
461/// Skipping this step is the most common cause of "the debugger sees the
462/// program but the disasm pane is empty". We surface that explicitly via
463/// the `target/deploy/` check so users know what to fix.
464/// Run `cargo test --features <feature> -p <pkg>` from `cwd`, with the
465/// profile-mode env vars set the same way the Anchor.toml flow sets them.
466///
467/// Inherits stdio so the user sees test output exactly as they would with
468/// a direct `cargo test`. Returns an error on non-zero exit so we don't
469/// drop into the TUI on a build/test failure (would otherwise confuse
470/// users about why nothing's there).
471pub fn run_cargo_test(
472 cwd: &Path,
473 package: Option<&str>,
474 feature: &str,
475 profile_dir: &Path,
476 test_filter: Option<&str>,
477) -> Result<()> {
478 // Mirror what the Anchor.toml flow sets. The env vars only affect the
479 // child cargo invocation; nothing we do here leaks into the user's
480 // shell.
481 let mut cmd = Command::new("cargo");
482 cmd.current_dir(cwd)
483 .env("ANCHOR_PROFILE_DIR", profile_dir)
484 .env("CARGO_PROFILE_RELEASE_DEBUG", "2")
485 .arg("test")
486 .arg("--features")
487 .arg(feature);
488 if let Some(pkg) = package {
489 cmd.arg("-p").arg(pkg);
490 }
491 if let Some(filter) = test_filter {
492 // After `cargo test [opts] [--] <filter>`. Cargo passes the filter
493 // through to libtest as a substring match — exactly how the user
494 // would run `cargo test my_specific_test`.
495 cmd.arg("--").arg(filter);
496 }
497
498 let status = cmd
499 .status()
500 .with_context(|| format!("spawn cargo test in {}: is `cargo` on PATH?", cwd.display()))?;
501 if !status.success() {
502 return Err(anyhow!(
503 "cargo test failed (exit {:?}). Fix test errors before stepping into the debugger.",
504 status.code()
505 ));
506 }
507 Ok(())
508}
509
510/// Wipe the per-test trace dir before a fresh run so stale traces from a
511/// previous session don't leak into the new picker. Idempotent — missing
512/// dir is fine.
513pub fn clear_profile_dir(profile_dir: &Path) -> Result<()> {
514 match std::fs::remove_dir_all(profile_dir) {
515 Ok(()) => Ok(()),
516 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
517 Err(e) => Err(anyhow::Error::new(e)
518 .context(format!("clear stale profile dir {}", profile_dir.display()))),
519 }
520}