Skip to main content

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}