Skip to main content

aperion_shield/shims/
install.rs

1//! Install / uninstall the per-command shims that route invocations
2//! through `aperion-shield --check-cmd`.
3//!
4//! Design points (mirroring `src/hooks/install.rs`):
5//!
6//!  * **Per-user, not system-wide.** Shims live in
7//!    `~/.aperion-shield/bin/` (override with `--shim-dir`). The user
8//!    adds that directory to their `$PATH` ahead of the system paths.
9//!    We do NOT touch `/usr/local/bin` or any shared location -- those
10//!    paths often require sudo and clobbering them would break other
11//!    users on the same machine.
12//!
13//!  * **Idempotent.** Re-running `--install-shims` is a no-op when the
14//!    existing shims still match the marker. Refreshing a shim across
15//!    Shield versions just rewrites it; user-authored scripts at the
16//!    same path are NEVER overwritten (we refuse with `ForeignPresent`
17//!    and let the operator decide).
18//!
19//!  * **Resolves the real binary path at install time** by running
20//!    `which <cmd>` after removing our shim directory from `$PATH`.
21//!    We bake the absolute path into the shim body. This both prevents
22//!    the obvious `$PATH` self-loop (shim execs itself) and gives the
23//!    user predictability: the shim does exactly what `which <cmd>`
24//!    did when they installed it.
25//!
26//!  * **Per-command granularity.** `--install-shims --for aws,kubectl`
27//!    installs only those two; the rest of `DEFAULT_SHIMMED_COMMANDS`
28//!    are untouched. This is how we keep adoption low-risk: protect
29//!    the one command you keep getting burned by, leave the rest alone.
30
31use anyhow::{anyhow, Context, Result};
32use std::collections::BTreeMap;
33use std::fs;
34#[cfg(unix)]
35use std::os::unix::fs::PermissionsExt;
36use std::path::{Path, PathBuf};
37
38use crate::shims::templates::{shim_script, APERION_SHIELD_SHIM_MARKER, DEFAULT_SHIMMED_COMMANDS};
39
40/// Outcome categories reported by the installer for a single command.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ShimInstallOutcome {
43    /// Wrote a fresh shim where none existed.
44    Installed,
45    /// Recognised our own prior shim and refreshed it in place.
46    Refreshed,
47    /// A file already exists at the target path that we don't recognise
48    /// (no `APERION_SHIELD_SHIM_MARKER`). We did NOT overwrite it. The
49    /// caller surfaces this to the operator who can either delete the
50    /// foreign file themselves or pick a different `--shim-dir`.
51    ForeignPresent,
52    /// The real binary couldn't be resolved on `$PATH` at install time.
53    /// Skipping rather than baking a broken path into the shim.
54    UpstreamBinaryNotFound,
55}
56
57#[derive(Debug, Clone)]
58pub struct ShimInstallEntry {
59    pub command: String,
60    pub outcome: ShimInstallOutcome,
61    /// The absolute path of the real binary that was baked into the
62    /// shim, when we got that far. None when the outcome was
63    /// `UpstreamBinaryNotFound` or `ForeignPresent`.
64    pub resolved_path: Option<PathBuf>,
65    /// Where the shim was (or would have been) written.
66    pub shim_path: PathBuf,
67}
68
69#[derive(Debug)]
70pub struct ShimInstallReport {
71    pub shim_dir: PathBuf,
72    pub entries: Vec<ShimInstallEntry>,
73}
74
75impl ShimInstallReport {
76    pub fn any_foreign(&self) -> bool {
77        self.entries.iter().any(|e| e.outcome == ShimInstallOutcome::ForeignPresent)
78    }
79
80    pub fn any_missing_upstream(&self) -> bool {
81        self.entries.iter().any(|e| e.outcome == ShimInstallOutcome::UpstreamBinaryNotFound)
82    }
83
84    pub fn successful(&self) -> usize {
85        self.entries
86            .iter()
87            .filter(|e| {
88                matches!(
89                    e.outcome,
90                    ShimInstallOutcome::Installed | ShimInstallOutcome::Refreshed
91                )
92            })
93            .count()
94    }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum ShimUninstallOutcome {
99    /// Recognised our marker and removed the shim.
100    Removed,
101    /// File at the target path didn't carry our marker; we left it
102    /// alone. Returned when the operator hand-rolled a wrapper after
103    /// running `--install-shims` once.
104    ForeignPresent,
105    /// No file at the target path; nothing to do.
106    AbsentNoop,
107}
108
109#[derive(Debug, Clone)]
110pub struct ShimUninstallEntry {
111    pub command: String,
112    pub outcome: ShimUninstallOutcome,
113    pub shim_path: PathBuf,
114}
115
116#[derive(Debug)]
117pub struct ShimUninstallReport {
118    pub shim_dir: PathBuf,
119    pub entries: Vec<ShimUninstallEntry>,
120}
121
122/// Resolve the canonical shim directory. Order:
123///
124///  1. Explicit `--shim-dir PATH` if supplied.
125///  2. `$APERION_SHIELD_SHIM_DIR` (mostly for tests).
126///  3. `$HOME/.aperion-shield/bin/`.
127///
128/// Returned path is not guaranteed to exist on disk yet -- callers that
129/// write to it must create it via `fs::create_dir_all`.
130pub fn resolve_shim_dir(explicit: Option<&Path>) -> Result<PathBuf> {
131    if let Some(p) = explicit {
132        return Ok(p.to_path_buf());
133    }
134    if let Ok(env_dir) = std::env::var("APERION_SHIELD_SHIM_DIR") {
135        if !env_dir.is_empty() {
136            return Ok(PathBuf::from(env_dir));
137        }
138    }
139    let home = std::env::var("HOME")
140        .context("couldn't resolve $HOME (set --shim-dir explicitly)")?;
141    Ok(PathBuf::from(home).join(".aperion-shield").join("bin"))
142}
143
144/// Install (or refresh) shims for each command in `commands`. When
145/// `commands` is empty, defaults to `DEFAULT_SHIMMED_COMMANDS`.
146///
147/// `shim_dir` is created if absent (mode 0700 on unix). The caller is
148/// expected to print a follow-on note telling the user how to add
149/// `shim_dir` to their `$PATH` -- this function does NOT modify
150/// dotfiles. That's deliberate: rewriting shell rc files is high-risk
151/// and the operator's choice.
152pub fn install(shim_dir: &Path, commands: &[String]) -> Result<ShimInstallReport> {
153    fs::create_dir_all(shim_dir)
154        .with_context(|| format!("couldn't create shim dir {}", shim_dir.display()))?;
155    #[cfg(unix)]
156    {
157        let mut perms = fs::metadata(shim_dir)?.permissions();
158        perms.set_mode(0o700);
159        let _ = fs::set_permissions(shim_dir, perms);
160    }
161
162    let to_install: Vec<String> = if commands.is_empty() {
163        DEFAULT_SHIMMED_COMMANDS.iter().map(|s| s.to_string()).collect()
164    } else {
165        commands.to_vec()
166    };
167
168    let mut entries = Vec::with_capacity(to_install.len());
169    for cmd in to_install {
170        let shim_path = shim_dir.join(&cmd);
171        entries.push(install_one(&cmd, &shim_path, shim_dir)?);
172    }
173
174    Ok(ShimInstallReport {
175        shim_dir: shim_dir.to_path_buf(),
176        entries,
177    })
178}
179
180/// Install (or refresh) the shim for a single command.
181fn install_one(
182    cmd: &str,
183    shim_path: &Path,
184    shim_dir: &Path,
185) -> Result<ShimInstallEntry> {
186    // Refuse to overwrite a foreign file at the target path.
187    if shim_path.exists() {
188        let existing = fs::read_to_string(shim_path)
189            .with_context(|| format!("couldn't read existing shim at {}", shim_path.display()))?;
190        if !existing.contains(APERION_SHIELD_SHIM_MARKER) {
191            return Ok(ShimInstallEntry {
192                command: cmd.to_string(),
193                outcome: ShimInstallOutcome::ForeignPresent,
194                resolved_path: None,
195                shim_path: shim_path.to_path_buf(),
196            });
197        }
198    }
199
200    let real_path = match resolve_real_binary(cmd, shim_dir)? {
201        Some(p) => p,
202        None => {
203            return Ok(ShimInstallEntry {
204                command: cmd.to_string(),
205                outcome: ShimInstallOutcome::UpstreamBinaryNotFound,
206                resolved_path: None,
207                shim_path: shim_path.to_path_buf(),
208            });
209        }
210    };
211
212    let outcome = if shim_path.exists() {
213        ShimInstallOutcome::Refreshed
214    } else {
215        ShimInstallOutcome::Installed
216    };
217
218    let body = shim_script(cmd, &real_path.to_string_lossy());
219    write_shim(shim_path, &body)?;
220
221    Ok(ShimInstallEntry {
222        command: cmd.to_string(),
223        outcome,
224        resolved_path: Some(real_path),
225        shim_path: shim_path.to_path_buf(),
226    })
227}
228
229/// Uninstall every Shield-managed shim found in `shim_dir`. Files that
230/// don't carry our marker are left alone (not our shim, not our problem
231/// -- the operator put them there for a reason).
232pub fn uninstall(shim_dir: &Path) -> Result<ShimUninstallReport> {
233    let mut entries = Vec::new();
234
235    if !shim_dir.exists() {
236        return Ok(ShimUninstallReport {
237            shim_dir: shim_dir.to_path_buf(),
238            entries,
239        });
240    }
241
242    for entry in fs::read_dir(shim_dir)
243        .with_context(|| format!("couldn't read shim dir {}", shim_dir.display()))?
244    {
245        let entry = entry?;
246        let path = entry.path();
247        if !path.is_file() {
248            continue;
249        }
250        let name = path
251            .file_name()
252            .and_then(|s| s.to_str())
253            .unwrap_or("")
254            .to_string();
255        if name.is_empty() {
256            continue;
257        }
258
259        let content = match fs::read_to_string(&path) {
260            Ok(c) => c,
261            Err(_) => continue,
262        };
263        if !content.contains(APERION_SHIELD_SHIM_MARKER) {
264            entries.push(ShimUninstallEntry {
265                command: name,
266                outcome: ShimUninstallOutcome::ForeignPresent,
267                shim_path: path,
268            });
269            continue;
270        }
271
272        fs::remove_file(&path)
273            .with_context(|| format!("couldn't remove shim {}", path.display()))?;
274        entries.push(ShimUninstallEntry {
275            command: name,
276            outcome: ShimUninstallOutcome::Removed,
277            shim_path: path,
278        });
279    }
280
281    Ok(ShimUninstallReport {
282        shim_dir: shim_dir.to_path_buf(),
283        entries,
284    })
285}
286
287/// List the shims currently installed in `shim_dir`, separated into
288/// "ours" vs "foreign" by marker match. Used by `--list-shims` and the
289/// install path's pre-state check.
290pub fn list(shim_dir: &Path) -> Result<BTreeMap<String, bool>> {
291    let mut out = BTreeMap::new();
292    if !shim_dir.exists() {
293        return Ok(out);
294    }
295    for entry in fs::read_dir(shim_dir)
296        .with_context(|| format!("couldn't read shim dir {}", shim_dir.display()))?
297    {
298        let entry = entry?;
299        let path = entry.path();
300        if !path.is_file() {
301            continue;
302        }
303        let name = path
304            .file_name()
305            .and_then(|s| s.to_str())
306            .unwrap_or("")
307            .to_string();
308        if name.is_empty() {
309            continue;
310        }
311        let content = fs::read_to_string(&path).unwrap_or_default();
312        out.insert(name, content.contains(APERION_SHIELD_SHIM_MARKER));
313    }
314    Ok(out)
315}
316
317// ─────────────────────────────────────────────────────────────────────
318// Filesystem helpers
319// ─────────────────────────────────────────────────────────────────────
320
321fn write_shim(path: &Path, body: &str) -> Result<()> {
322    fs::write(path, body)
323        .with_context(|| format!("couldn't write shim to {}", path.display()))?;
324    // Same Unix-only chmod story as src/hooks/install.rs: explicit 0755
325    // because fs::write honours the process umask and may produce 0644
326    // which the kernel refuses to exec. On Windows we skip; Windows
327    // doesn't carry an exec bit and command resolution is by extension.
328    #[cfg(unix)]
329    {
330        let mut perms = fs::metadata(path)?.permissions();
331        perms.set_mode(0o755);
332        fs::set_permissions(path, perms)?;
333    }
334    Ok(())
335}
336
337/// Resolve the real binary for `cmd` -- i.e. what the user *would* hit
338/// if our shim directory weren't on `$PATH`. Walks every directory on
339/// `$PATH` in order, skipping the shim dir (so we don't pick up our
340/// own shim, which would cause an infinite exec loop at runtime).
341///
342/// Pure Rust; no shell-out. That matters in two places:
343///
344///  1. **Tests** can manipulate `$PATH` without also needing `sh`
345///     reachable.
346///  2. **Windows** would otherwise need a totally different code path
347///     (no `/bin/sh`, different `which` semantics) -- here we just
348///     read whatever the user has on `PATH` and stat it directly.
349///
350/// Returns Ok(None) when the binary isn't on `$PATH` at all (we then
351/// record `UpstreamBinaryNotFound` rather than baking a broken path).
352fn resolve_real_binary(cmd: &str, shim_dir: &Path) -> Result<Option<PathBuf>> {
353    let current_path = std::env::var_os("PATH").unwrap_or_default();
354    let shim_dir_canon = shim_dir.canonicalize().unwrap_or_else(|_| shim_dir.to_path_buf());
355
356    for dir in std::env::split_paths(&current_path) {
357        if dir.as_os_str().is_empty() {
358            continue;
359        }
360        let dir_canon = dir.canonicalize().unwrap_or_else(|_| dir.clone());
361        if dir_canon == shim_dir_canon {
362            continue;
363        }
364        let candidate = dir.join(cmd);
365        if !candidate.is_file() {
366            continue;
367        }
368        if !is_executable(&candidate) {
369            continue;
370        }
371        return Ok(Some(candidate));
372    }
373
374    Ok(None)
375}
376
377#[cfg(unix)]
378fn is_executable(path: &Path) -> bool {
379    use std::os::unix::fs::PermissionsExt;
380    match path.metadata() {
381        Ok(m) => m.permissions().mode() & 0o111 != 0,
382        Err(_) => false,
383    }
384}
385
386#[cfg(windows)]
387fn is_executable(_path: &Path) -> bool {
388    // Windows: PATH resolution is by file extension (PATHEXT) and
389    // there's no exec bit. If the file exists at the candidate path
390    // with a runnable extension, treat it as executable. We don't
391    // ship shims for Windows in v0.8 -- this branch exists only so
392    // the crate compiles cross-target.
393    true
394}
395
396
397/// Convenience: parse a comma-separated `--for aws,kubectl,terraform`
398/// argument into a deduplicated, validated command list. Returns an
399/// error if any item isn't a plausible command name (no shell
400/// metacharacters, no path components -- otherwise install paths
401/// could escape `shim_dir`).
402pub fn parse_for_arg(raw: &str) -> Result<Vec<String>> {
403    let mut out = Vec::new();
404    for piece in raw.split(',') {
405        let cmd = piece.trim();
406        if cmd.is_empty() {
407            continue;
408        }
409        if cmd.contains('/') || cmd.contains('\\') || cmd.contains(' ') {
410            return Err(anyhow!(
411                "--for entry '{}' is not a plain command name (no paths, no spaces, no slashes)",
412                cmd
413            ));
414        }
415        if !out.iter().any(|c: &String| c == cmd) {
416            out.push(cmd.to_string());
417        }
418    }
419    Ok(out)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use std::sync::Mutex;
426    use tempfile::TempDir;
427
428    /// Tests in this module mutate `$PATH` and `$APERION_SHIELD_SHIM_DIR`,
429    /// which are process-global. cargo runs lib tests in parallel within
430    /// one binary, so without serialisation the env mutations race each
431    /// other and tests flake. Same defensive pattern we use in
432    /// `src/hooks/check_pushed.rs`.
433    static ENV_LOCK: Mutex<()> = Mutex::new(());
434
435    /// Build a tempdir that holds a fake real binary for `cmd_name`
436    /// (so `resolve_real_binary` finds something) and a clean shim
437    /// directory. Returns (real_dir, shim_dir, full_path_to_real).
438    fn fixture(cmd_name: &str) -> (TempDir, TempDir, PathBuf) {
439        let real_dir = TempDir::new().expect("real dir");
440        let shim_dir = TempDir::new().expect("shim dir");
441        let real_bin = real_dir.path().join(cmd_name);
442        fs::write(&real_bin, "#!/bin/sh\necho fake\n").expect("write fake bin");
443        #[cfg(unix)]
444        {
445            let mut perms = fs::metadata(&real_bin).unwrap().permissions();
446            perms.set_mode(0o755);
447            fs::set_permissions(&real_bin, perms).unwrap();
448        }
449        // Save & restore $PATH around the test so we don't pollute the
450        // global env for parallel tests in the same process.
451        (real_dir, shim_dir, real_bin)
452    }
453
454    /// Set PATH for the duration of a test, restoring on drop.
455    /// Holding the module-local `ENV_LOCK` for the whole call serialises
456    /// against every other test that reads or writes PATH.
457    ///
458    /// We deliberately PREPEND the test's fixture directory rather than
459    /// REPLACING $PATH, so unrelated tests that shell out to
460    /// `hostname`, `git`, etc. (e.g. `orgmode::state::fingerprint_is_stable`)
461    /// keep working while we hold the lock. The fixture binary always
462    /// resolves first because our directory is at the front of the
463    /// joined path.
464    fn with_path<R>(new_path_prefix: &Path, f: impl FnOnce() -> R) -> R {
465        let _guard = ENV_LOCK.lock().unwrap();
466        let prev = std::env::var_os("PATH");
467        let joined = match &prev {
468            Some(existing) => {
469                let mut s = std::ffi::OsString::new();
470                s.push(new_path_prefix);
471                s.push(":");
472                s.push(existing);
473                s
474            }
475            None => new_path_prefix.as_os_str().to_owned(),
476        };
477        std::env::set_var("PATH", &joined);
478        let r = f();
479        match prev {
480            Some(p) => std::env::set_var("PATH", p),
481            None => std::env::remove_var("PATH"),
482        }
483        r
484    }
485
486    #[test]
487    fn install_writes_a_shim_with_the_marker() {
488        let (real_dir, shim_dir, real_bin) = fixture("aws");
489        let report = with_path(real_dir.path(), || {
490            install(shim_dir.path(), &["aws".to_string()]).expect("install")
491        });
492
493        assert_eq!(report.entries.len(), 1);
494        let entry = &report.entries[0];
495        assert_eq!(entry.command, "aws");
496        assert_eq!(entry.outcome, ShimInstallOutcome::Installed);
497        assert_eq!(entry.resolved_path.as_deref(), Some(real_bin.as_path()));
498
499        let written = fs::read_to_string(&entry.shim_path).expect("read shim");
500        assert!(written.contains(APERION_SHIELD_SHIM_MARKER));
501        assert!(written.contains(&real_bin.to_string_lossy().to_string()));
502    }
503
504    #[test]
505    fn install_is_idempotent_refresh() {
506        let (real_dir, shim_dir, _real_bin) = fixture("kubectl");
507        let (r1, r2) = with_path(real_dir.path(), || {
508            let r1 = install(shim_dir.path(), &["kubectl".to_string()]).expect("install1");
509            let r2 = install(shim_dir.path(), &["kubectl".to_string()]).expect("install2");
510            (r1, r2)
511        });
512        assert_eq!(r1.entries[0].outcome, ShimInstallOutcome::Installed);
513        assert_eq!(r2.entries[0].outcome, ShimInstallOutcome::Refreshed);
514    }
515
516    #[test]
517    fn install_refuses_to_clobber_a_foreign_file() {
518        let (real_dir, shim_dir, _real_bin) = fixture("terraform");
519
520        // Pre-seed the target path with a user-authored wrapper.
521        fs::create_dir_all(shim_dir.path()).unwrap();
522        let path = shim_dir.path().join("terraform");
523        fs::write(&path, "#!/bin/sh\n# my custom wrapper\nexec /opt/tf \"$@\"\n").unwrap();
524
525        let report = with_path(real_dir.path(), || {
526            install(shim_dir.path(), &["terraform".to_string()]).expect("install")
527        });
528
529        assert_eq!(report.entries[0].outcome, ShimInstallOutcome::ForeignPresent);
530        // Foreign file must NOT have been rewritten.
531        let after = fs::read_to_string(&path).unwrap();
532        assert!(after.contains("# my custom wrapper"));
533        assert!(!after.contains(APERION_SHIELD_SHIM_MARKER));
534    }
535
536    #[test]
537    fn install_skips_when_upstream_binary_not_on_path() {
538        // Use a command name guaranteed not to exist on any sane $PATH
539        // so this assertion is independent of the host system. Picking
540        // a real name like `helm` would make the test pass or fail
541        // based on whether helm happens to be installed on the dev /
542        // CI machine.
543        let empty = TempDir::new().unwrap();
544        let shim_dir = TempDir::new().unwrap();
545
546        let cmd_name = "aperion-test-fake-binary-zzz999".to_string();
547        let report = with_path(empty.path(), || {
548            install(shim_dir.path(), &[cmd_name.clone()]).expect("install")
549        });
550
551        assert_eq!(
552            report.entries[0].outcome,
553            ShimInstallOutcome::UpstreamBinaryNotFound
554        );
555        assert!(!shim_dir.path().join(&cmd_name).exists());
556    }
557
558    #[test]
559    fn uninstall_removes_only_our_shims() {
560        let (real_dir, shim_dir, _real_bin) = fixture("psql");
561
562        with_path(real_dir.path(), || {
563            install(shim_dir.path(), &["psql".to_string()]).expect("install");
564        });
565
566        // Drop a foreign file in too (no PATH manipulation needed).
567        let foreign = shim_dir.path().join("not-ours");
568        fs::write(&foreign, "#!/bin/sh\necho foreign\n").unwrap();
569
570        let report = uninstall(shim_dir.path()).expect("uninstall");
571
572        let by_cmd: BTreeMap<_, _> = report
573            .entries
574            .into_iter()
575            .map(|e| (e.command, e.outcome))
576            .collect();
577        assert_eq!(by_cmd.get("psql"), Some(&ShimUninstallOutcome::Removed));
578        assert_eq!(by_cmd.get("not-ours"), Some(&ShimUninstallOutcome::ForeignPresent));
579        // Foreign file must still be there.
580        assert!(foreign.exists());
581    }
582
583    #[test]
584    fn resolve_shim_dir_honours_env_override() {
585        let _guard = ENV_LOCK.lock().unwrap();
586        let prev = std::env::var_os("APERION_SHIELD_SHIM_DIR");
587        std::env::set_var("APERION_SHIELD_SHIM_DIR", "/tmp/aperion-test-shims");
588        let resolved = resolve_shim_dir(None).expect("resolve");
589        assert_eq!(resolved, PathBuf::from("/tmp/aperion-test-shims"));
590        match prev {
591            Some(p) => std::env::set_var("APERION_SHIELD_SHIM_DIR", p),
592            None => std::env::remove_var("APERION_SHIELD_SHIM_DIR"),
593        }
594    }
595
596    #[test]
597    fn resolve_shim_dir_explicit_wins() {
598        let p = PathBuf::from("/explicit/path");
599        let resolved = resolve_shim_dir(Some(&p)).expect("resolve");
600        assert_eq!(resolved, p);
601    }
602
603    #[test]
604    fn parse_for_arg_accepts_canonical_form() {
605        let v = parse_for_arg("aws,kubectl, terraform").expect("parse");
606        assert_eq!(v, vec!["aws", "kubectl", "terraform"]);
607    }
608
609    #[test]
610    fn parse_for_arg_dedups() {
611        let v = parse_for_arg("aws,aws,kubectl,aws").expect("parse");
612        assert_eq!(v, vec!["aws", "kubectl"]);
613    }
614
615    #[test]
616    fn parse_for_arg_rejects_paths_or_metacharacters() {
617        assert!(parse_for_arg("/usr/bin/aws").is_err());
618        assert!(parse_for_arg("aws kubectl").is_err());
619        assert!(parse_for_arg("aws,../etc/passwd").is_err());
620    }
621}