1use 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#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ShimInstallOutcome {
43 Installed,
45 Refreshed,
47 ForeignPresent,
52 UpstreamBinaryNotFound,
55}
56
57#[derive(Debug, Clone)]
58pub struct ShimInstallEntry {
59 pub command: String,
60 pub outcome: ShimInstallOutcome,
61 pub resolved_path: Option<PathBuf>,
65 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 Removed,
101 ForeignPresent,
105 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
122pub 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
144pub 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
180fn install_one(
182 cmd: &str,
183 shim_path: &Path,
184 shim_dir: &Path,
185) -> Result<ShimInstallEntry> {
186 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
229pub 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
287pub 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
317fn write_shim(path: &Path, body: &str) -> Result<()> {
322 fs::write(path, body)
323 .with_context(|| format!("couldn't write shim to {}", path.display()))?;
324 #[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
337fn 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(¤t_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 true
394}
395
396
397pub 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 static ENV_LOCK: Mutex<()> = Mutex::new(());
434
435 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 (real_dir, shim_dir, real_bin)
452 }
453
454 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 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 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 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 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 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}