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` 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}