diff --git a/crates/ide-extension/src/ledger.rs b/crates/ide-extension/src/ledger.rs index 19128165a..89a9f9f6c 100644 --- a/crates/ide-extension/src/ledger.rs +++ b/crates/ide-extension/src/ledger.rs @@ -308,6 +308,13 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec)> { /// Resolves a `process:` program to an absolute path once, at grant time: /// absolute names are taken as is, others are looked up in `path_var`. +/// The directory is canonicalized but the file name kept as found: `cargo`, +/// `rustc` and friends are often links to a multicall shim (rustup, asdf, +/// mise) that picks the tool by the name it was invoked under — resolving the +/// link itself would start the shim with the tool's arguments. The grant +/// still pins the target's content: a hash read through a link reads the file +/// it points to, so relinking or replacing either still fails the +/// approved-file check. pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result { let candidate = Path::new(name); if candidate.is_absolute() { @@ -325,15 +332,21 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result PathBuf { - std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) +/// `p` with its parent canonicalized but the file name as found: PATH +/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to +/// their real directory, while a linked program (`cargo` → rustup) keeps +/// its link name so the shim still dispatches on argv[0]. +fn canonical_dir(p: &Path) -> PathBuf { + let Some(dir) = p.parent() else { return p.to_path_buf() }; + let Some(name) = p.file_name() else { return p.to_path_buf() }; + std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf()) } fn is_executable(p: &Path) -> bool { @@ -474,6 +487,42 @@ mod tests { assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed")); } + /// A program found as a link stays the link: multicall shims (rustup, + /// asdf, mise) pick the tool by the name they were invoked under, so + /// `Command::new` must see the link. The hash still covers the target — + /// reading through the link reads it — so replacing either still trips + /// the approved-file check. + #[cfg(unix)] + #[test] + fn linked_programs_resolve_to_the_link() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let bin_dir = tmp.path().join("bin"); + let real_dir = tmp.path().join("real"); + std::fs::create_dir(&bin_dir).unwrap(); + std::fs::create_dir(&real_dir).unwrap(); + let target = real_dir.join("shim"); + std::fs::write(&target, "#!/bin/sh\necho v1\n").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::os::unix::fs::symlink(&target, bin_dir.join("cargo")).unwrap(); + + let path_var = std::env::join_paths([bin_dir.clone()]).unwrap(); + let resolved = resolve_program("cargo", Some(&path_var)).unwrap(); + // Directory canonicalized, file name kept — still the link. + let want = std::fs::canonicalize(&bin_dir).unwrap().join("cargo"); + assert_eq!(resolved, want); + assert!(resolved.symlink_metadata().unwrap().file_type().is_symlink()); + + let mut l = Ledger::in_memory(); + let r = req("1.0.0", "h", &["process:cargo"], &[]); + let cap: Capability = "process:cargo".parse().unwrap(); + let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() }; + l.confirm(&r, &[], [(cap.to_string(), bin)].into()); + assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved); + std::fs::write(&target, "#!/bin/sh\necho evil\n").unwrap(); + assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed")); + } + #[test] fn hashes_and_grouping() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index fc13fe8e9..b3e26c02c 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -1650,6 +1650,7 @@ mod tests { operations: Vec::new(), open: None, data: None, + file_action: false, }; assert_eq!(code_action_to_wit(&action).edits[0].path, expected); }