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 sha2::{Digest, Sha256};
13use std::path::{Path, PathBuf};
14
15/// File name of the MUR MCP server binary.
16#[cfg(windows)]
17const MCP_SERVER_BIN: &str = "mur-mcp-server.exe";
18#[cfg(not(windows))]
19const MCP_SERVER_BIN: &str = "mur-mcp-server";
20
21/// Canonical location MUR keeps its own copy of the MCP server binary:
22/// `~/.mur/mcp-servers/mur-mcp-server` (honors `$MUR_HOME`). Stable across how
23/// `mur` itself was installed (brew / cargo / source) and across upgrades, so
24/// agent profiles can pin this path once and never go stale.
25pub fn bundled_mcp_server_path() -> PathBuf {
26    crate::trust::mur_home()
27        .join("mcp-servers")
28        .join(MCP_SERVER_BIN)
29}
30
31/// Ensure [`bundled_mcp_server_path`] exists and matches the `mur-mcp-server`
32/// shipped alongside the running `mur` binary, copying it into place when
33/// missing or out of date. Returns the canonical target path.
34///
35/// Source resolution: the sibling of the current executable first (brew, cargo
36/// and source builds all colocate the two binaries), then `mur-mcp-server` on
37/// `PATH`. If no source is found but a copy already exists, that copy is
38/// returned (usable, just can't self-update). Errors only when there is neither
39/// a source nor an existing copy.
40///
41/// Call this BEFORE the kernel sandbox seals — the copy needs write access to
42/// `~/.mur`.
43pub fn ensure_bundled_mcp_server() -> Result<PathBuf> {
44    let target = bundled_mcp_server_path();
45    match locate_mcp_server_source() {
46        Some(src) => {
47            install_if_stale(&src, &target)?;
48            Ok(target)
49        }
50        None if target.is_file() => Ok(target),
51        None => bail!(
52            "mur-mcp-server not found next to `mur` or on PATH, and no copy at {}",
53            target.display()
54        ),
55    }
56}
57
58/// The `mur-mcp-server` to copy from: sibling of `mur` first, then PATH.
59fn locate_mcp_server_source() -> Option<PathBuf> {
60    if let Ok(exe) = std::env::current_exe()
61        && let Some(dir) = exe.parent()
62    {
63        let sibling = dir.join(MCP_SERVER_BIN);
64        if sibling.is_file() {
65            return sibling.canonicalize().ok();
66        }
67    }
68    resolve_command(MCP_SERVER_BIN).ok()
69}
70
71/// Copy `src` to `target` unless `target` already byte-matches it. Idempotent;
72/// writes via a uniquely-named temp file + rename in the target dir so the swap
73/// is atomic and never leaves a half-written binary an agent might try to spawn;
74/// sets mode 0755 on unix.
75fn install_if_stale(src: &Path, target: &Path) -> Result<()> {
76    if target.is_file() && sha256_file(src)? == sha256_file(target)? {
77        return Ok(());
78    }
79    let dir = target
80        .parent()
81        .ok_or_else(|| anyhow::anyhow!("target {} has no parent", target.display()))?;
82    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
83    // Unique temp name so two agents starting at once don't clobber each other.
84    let tmp = dir.join(format!(".{MCP_SERVER_BIN}.{}.tmp", std::process::id()));
85    std::fs::copy(src, &tmp)
86        .with_context(|| format!("copy {} -> {}", src.display(), tmp.display()))?;
87    #[cfg(unix)]
88    {
89        use std::os::unix::fs::PermissionsExt;
90        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
91            .with_context(|| format!("chmod {}", tmp.display()))?;
92    }
93    std::fs::rename(&tmp, target)
94        .with_context(|| format!("rename {} -> {}", tmp.display(), target.display()))?;
95    Ok(())
96}
97
98/// Stream-hash `path` SHA-256 (64 KiB chunks; lowercase hex).
99fn sha256_file(path: &Path) -> Result<String> {
100    use std::io::Read;
101    let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
102    let mut hasher = Sha256::new();
103    let mut buf = [0u8; 65536];
104    loop {
105        let n = f
106            .read(&mut buf)
107            .with_context(|| format!("read {}", path.display()))?;
108        if n == 0 {
109            break;
110        }
111        hasher.update(&buf[..n]);
112    }
113    Ok(hex::encode(hasher.finalize()))
114}
115
116/// Resolve `command` to an absolute path on disk.
117///
118/// - If `command` is already absolute or contains a path separator, canonicalize
119///   it (resolves symlinks).
120/// - Otherwise consult `PATH` (and try a `.exe` suffix on Windows). Returns the
121///   first match found, canonicalized.
122///
123/// Returns an error if the binary can't be located.
124pub fn resolve_command(command: &str) -> Result<PathBuf> {
125    let p = Path::new(command);
126    if p.is_absolute() || command.contains('/') || command.contains('\\') {
127        return p
128            .canonicalize()
129            .with_context(|| format!("canonicalize {command}"));
130    }
131    let path_var = std::env::var_os("PATH")
132        .ok_or_else(|| anyhow::anyhow!("PATH env var unset; cannot resolve `{command}`"))?;
133    for dir in std::env::split_paths(&path_var) {
134        let candidate = dir.join(command);
135        if candidate.is_file() {
136            return candidate
137                .canonicalize()
138                .with_context(|| format!("canonicalize {}", candidate.display()));
139        }
140        #[cfg(target_os = "windows")]
141        {
142            let with_exe = dir.join(format!("{command}.exe"));
143            if with_exe.is_file() {
144                return with_exe
145                    .canonicalize()
146                    .with_context(|| format!("canonicalize {}", with_exe.display()));
147            }
148        }
149    }
150    bail!("could not find `{command}` on PATH");
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn errors_on_missing_binary() {
159        assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
160    }
161
162    #[cfg(unix)]
163    #[test]
164    fn resolves_bare_program_on_path_to_absolute() {
165        // The whole point: a bare program name resolves to an absolute path.
166        // (The runtime pin check used to open it relative to CWD and soft-fail.)
167        let resolved = resolve_command("sh").expect("sh is on PATH");
168        assert!(
169            resolved.is_absolute(),
170            "expected absolute, got {resolved:?}"
171        );
172        assert!(resolved.exists());
173    }
174
175    #[test]
176    fn absolute_path_is_canonicalized() {
177        let tmp = tempfile::NamedTempFile::new().unwrap();
178        let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
179        assert!(resolved.is_absolute());
180    }
181
182    #[test]
183    fn install_if_stale_copies_then_is_idempotent_and_updates() {
184        let dir = tempfile::tempdir().unwrap();
185        let src = dir.path().join("src-bin");
186        let target = dir.path().join("mcp-servers/mur-mcp-server"); // parent must be created
187        std::fs::write(&src, b"v1").unwrap();
188
189        // Missing target -> copied.
190        install_if_stale(&src, &target).unwrap();
191        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
192        #[cfg(unix)]
193        {
194            use std::os::unix::fs::PermissionsExt;
195            let mode = std::fs::metadata(&target).unwrap().permissions().mode();
196            assert_eq!(mode & 0o111, 0o111, "target must be executable");
197        }
198
199        // Unchanged source -> no-op, still v1.
200        install_if_stale(&src, &target).unwrap();
201        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
202
203        // Updated source -> refreshed.
204        std::fs::write(&src, b"v2-newer").unwrap();
205        install_if_stale(&src, &target).unwrap();
206        assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
207    }
208}