Skip to main content

mur_common/
exec.rs

1//! Shared executable-path resolution.
2//!
3//! A single source of truth for turning a command (bare program name or path)
4//! into the absolute, symlink-resolved binary that will actually be executed.
5//! Used by both install-time MCP pinning (`mur agent mcp pin`) and the runtime
6//! startup verification (B0 rules 6 & 11) so a bare `command` like `node`
7//! resolves identically across the two passes — otherwise the runtime hashes a
8//! CWD-relative path that doesn't exist and silently skips the pin/signature
9//! check while `Command::new` runs the PATH-resolved binary.
10
11use anyhow::{Context, Result, bail};
12use std::path::{Path, PathBuf};
13
14/// Resolve `command` to an absolute path on disk.
15///
16/// - If `command` is already absolute or contains a path separator, canonicalize
17///   it (resolves symlinks).
18/// - Otherwise consult `PATH` (and try a `.exe` suffix on Windows). Returns the
19///   first match found, canonicalized.
20///
21/// Returns an error if the binary can't be located.
22pub fn resolve_command(command: &str) -> Result<PathBuf> {
23    let p = Path::new(command);
24    if p.is_absolute() || command.contains('/') || command.contains('\\') {
25        return p
26            .canonicalize()
27            .with_context(|| format!("canonicalize {command}"));
28    }
29    let path_var = std::env::var_os("PATH")
30        .ok_or_else(|| anyhow::anyhow!("PATH env var unset; cannot resolve `{command}`"))?;
31    for dir in std::env::split_paths(&path_var) {
32        let candidate = dir.join(command);
33        if candidate.is_file() {
34            return candidate
35                .canonicalize()
36                .with_context(|| format!("canonicalize {}", candidate.display()));
37        }
38        #[cfg(target_os = "windows")]
39        {
40            let with_exe = dir.join(format!("{command}.exe"));
41            if with_exe.is_file() {
42                return with_exe
43                    .canonicalize()
44                    .with_context(|| format!("canonicalize {}", with_exe.display()));
45            }
46        }
47    }
48    bail!("could not find `{command}` on PATH");
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn errors_on_missing_binary() {
57        assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
58    }
59
60    #[cfg(unix)]
61    #[test]
62    fn resolves_bare_program_on_path_to_absolute() {
63        // The whole point: a bare program name resolves to an absolute path.
64        // (The runtime pin check used to open it relative to CWD and soft-fail.)
65        let resolved = resolve_command("sh").expect("sh is on PATH");
66        assert!(
67            resolved.is_absolute(),
68            "expected absolute, got {resolved:?}"
69        );
70        assert!(resolved.exists());
71    }
72
73    #[test]
74    fn absolute_path_is_canonicalized() {
75        let tmp = tempfile::NamedTempFile::new().unwrap();
76        let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
77        assert!(resolved.is_absolute());
78    }
79}