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