use anyhow::{anyhow, Context, Result};
use std::collections::BTreeMap;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use crate::shims::templates::{shim_script, APERION_SHIELD_SHIM_MARKER, DEFAULT_SHIMMED_COMMANDS};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShimInstallOutcome {
Installed,
Refreshed,
ForeignPresent,
UpstreamBinaryNotFound,
}
#[derive(Debug, Clone)]
pub struct ShimInstallEntry {
pub command: String,
pub outcome: ShimInstallOutcome,
pub resolved_path: Option<PathBuf>,
pub shim_path: PathBuf,
}
#[derive(Debug)]
pub struct ShimInstallReport {
pub shim_dir: PathBuf,
pub entries: Vec<ShimInstallEntry>,
}
impl ShimInstallReport {
pub fn any_foreign(&self) -> bool {
self.entries.iter().any(|e| e.outcome == ShimInstallOutcome::ForeignPresent)
}
pub fn any_missing_upstream(&self) -> bool {
self.entries.iter().any(|e| e.outcome == ShimInstallOutcome::UpstreamBinaryNotFound)
}
pub fn successful(&self) -> usize {
self.entries
.iter()
.filter(|e| {
matches!(
e.outcome,
ShimInstallOutcome::Installed | ShimInstallOutcome::Refreshed
)
})
.count()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShimUninstallOutcome {
Removed,
ForeignPresent,
AbsentNoop,
}
#[derive(Debug, Clone)]
pub struct ShimUninstallEntry {
pub command: String,
pub outcome: ShimUninstallOutcome,
pub shim_path: PathBuf,
}
#[derive(Debug)]
pub struct ShimUninstallReport {
pub shim_dir: PathBuf,
pub entries: Vec<ShimUninstallEntry>,
}
pub fn resolve_shim_dir(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = explicit {
return Ok(p.to_path_buf());
}
if let Ok(env_dir) = std::env::var("APERION_SHIELD_SHIM_DIR") {
if !env_dir.is_empty() {
return Ok(PathBuf::from(env_dir));
}
}
let home = std::env::var("HOME")
.context("couldn't resolve $HOME (set --shim-dir explicitly)")?;
Ok(PathBuf::from(home).join(".aperion-shield").join("bin"))
}
pub fn install(shim_dir: &Path, commands: &[String]) -> Result<ShimInstallReport> {
fs::create_dir_all(shim_dir)
.with_context(|| format!("couldn't create shim dir {}", shim_dir.display()))?;
#[cfg(unix)]
{
let mut perms = fs::metadata(shim_dir)?.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(shim_dir, perms);
}
let to_install: Vec<String> = if commands.is_empty() {
DEFAULT_SHIMMED_COMMANDS.iter().map(|s| s.to_string()).collect()
} else {
commands.to_vec()
};
let mut entries = Vec::with_capacity(to_install.len());
for cmd in to_install {
let shim_path = shim_dir.join(&cmd);
entries.push(install_one(&cmd, &shim_path, shim_dir)?);
}
Ok(ShimInstallReport {
shim_dir: shim_dir.to_path_buf(),
entries,
})
}
fn install_one(
cmd: &str,
shim_path: &Path,
shim_dir: &Path,
) -> Result<ShimInstallEntry> {
if shim_path.exists() {
let existing = fs::read_to_string(shim_path)
.with_context(|| format!("couldn't read existing shim at {}", shim_path.display()))?;
if !existing.contains(APERION_SHIELD_SHIM_MARKER) {
return Ok(ShimInstallEntry {
command: cmd.to_string(),
outcome: ShimInstallOutcome::ForeignPresent,
resolved_path: None,
shim_path: shim_path.to_path_buf(),
});
}
}
let real_path = match resolve_real_binary(cmd, shim_dir)? {
Some(p) => p,
None => {
return Ok(ShimInstallEntry {
command: cmd.to_string(),
outcome: ShimInstallOutcome::UpstreamBinaryNotFound,
resolved_path: None,
shim_path: shim_path.to_path_buf(),
});
}
};
let outcome = if shim_path.exists() {
ShimInstallOutcome::Refreshed
} else {
ShimInstallOutcome::Installed
};
let body = shim_script(cmd, &real_path.to_string_lossy());
write_shim(shim_path, &body)?;
Ok(ShimInstallEntry {
command: cmd.to_string(),
outcome,
resolved_path: Some(real_path),
shim_path: shim_path.to_path_buf(),
})
}
pub fn uninstall(shim_dir: &Path) -> Result<ShimUninstallReport> {
let mut entries = Vec::new();
if !shim_dir.exists() {
return Ok(ShimUninstallReport {
shim_dir: shim_dir.to_path_buf(),
entries,
});
}
for entry in fs::read_dir(shim_dir)
.with_context(|| format!("couldn't read shim dir {}", shim_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
if !content.contains(APERION_SHIELD_SHIM_MARKER) {
entries.push(ShimUninstallEntry {
command: name,
outcome: ShimUninstallOutcome::ForeignPresent,
shim_path: path,
});
continue;
}
fs::remove_file(&path)
.with_context(|| format!("couldn't remove shim {}", path.display()))?;
entries.push(ShimUninstallEntry {
command: name,
outcome: ShimUninstallOutcome::Removed,
shim_path: path,
});
}
Ok(ShimUninstallReport {
shim_dir: shim_dir.to_path_buf(),
entries,
})
}
pub fn list(shim_dir: &Path) -> Result<BTreeMap<String, bool>> {
let mut out = BTreeMap::new();
if !shim_dir.exists() {
return Ok(out);
}
for entry in fs::read_dir(shim_dir)
.with_context(|| format!("couldn't read shim dir {}", shim_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let content = fs::read_to_string(&path).unwrap_or_default();
out.insert(name, content.contains(APERION_SHIELD_SHIM_MARKER));
}
Ok(out)
}
fn write_shim(path: &Path, body: &str) -> Result<()> {
fs::write(path, body)
.with_context(|| format!("couldn't write shim to {}", path.display()))?;
#[cfg(unix)]
{
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)?;
}
Ok(())
}
fn resolve_real_binary(cmd: &str, shim_dir: &Path) -> Result<Option<PathBuf>> {
let current_path = std::env::var_os("PATH").unwrap_or_default();
let shim_dir_canon = shim_dir.canonicalize().unwrap_or_else(|_| shim_dir.to_path_buf());
for dir in std::env::split_paths(¤t_path) {
if dir.as_os_str().is_empty() {
continue;
}
let dir_canon = dir.canonicalize().unwrap_or_else(|_| dir.clone());
if dir_canon == shim_dir_canon {
continue;
}
let candidate = dir.join(cmd);
if !candidate.is_file() {
continue;
}
if !is_executable(&candidate) {
continue;
}
return Ok(Some(candidate));
}
Ok(None)
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
match path.metadata() {
Ok(m) => m.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}
#[cfg(windows)]
fn is_executable(_path: &Path) -> bool {
true
}
pub fn parse_for_arg(raw: &str) -> Result<Vec<String>> {
let mut out = Vec::new();
for piece in raw.split(',') {
let cmd = piece.trim();
if cmd.is_empty() {
continue;
}
if cmd.contains('/') || cmd.contains('\\') || cmd.contains(' ') {
return Err(anyhow!(
"--for entry '{}' is not a plain command name (no paths, no spaces, no slashes)",
cmd
));
}
if !out.iter().any(|c: &String| c == cmd) {
out.push(cmd.to_string());
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn fixture(cmd_name: &str) -> (TempDir, TempDir, PathBuf) {
let real_dir = TempDir::new().expect("real dir");
let shim_dir = TempDir::new().expect("shim dir");
let real_bin = real_dir.path().join(cmd_name);
fs::write(&real_bin, "#!/bin/sh\necho fake\n").expect("write fake bin");
#[cfg(unix)]
{
let mut perms = fs::metadata(&real_bin).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&real_bin, perms).unwrap();
}
(real_dir, shim_dir, real_bin)
}
fn with_path<R>(new_path_prefix: &Path, f: impl FnOnce() -> R) -> R {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var_os("PATH");
let joined = match &prev {
Some(existing) => {
let mut s = std::ffi::OsString::new();
s.push(new_path_prefix);
s.push(":");
s.push(existing);
s
}
None => new_path_prefix.as_os_str().to_owned(),
};
std::env::set_var("PATH", &joined);
let r = f();
match prev {
Some(p) => std::env::set_var("PATH", p),
None => std::env::remove_var("PATH"),
}
r
}
#[test]
fn install_writes_a_shim_with_the_marker() {
let (real_dir, shim_dir, real_bin) = fixture("aws");
let report = with_path(real_dir.path(), || {
install(shim_dir.path(), &["aws".to_string()]).expect("install")
});
assert_eq!(report.entries.len(), 1);
let entry = &report.entries[0];
assert_eq!(entry.command, "aws");
assert_eq!(entry.outcome, ShimInstallOutcome::Installed);
assert_eq!(entry.resolved_path.as_deref(), Some(real_bin.as_path()));
let written = fs::read_to_string(&entry.shim_path).expect("read shim");
assert!(written.contains(APERION_SHIELD_SHIM_MARKER));
assert!(written.contains(&real_bin.to_string_lossy().to_string()));
}
#[test]
fn install_is_idempotent_refresh() {
let (real_dir, shim_dir, _real_bin) = fixture("kubectl");
let (r1, r2) = with_path(real_dir.path(), || {
let r1 = install(shim_dir.path(), &["kubectl".to_string()]).expect("install1");
let r2 = install(shim_dir.path(), &["kubectl".to_string()]).expect("install2");
(r1, r2)
});
assert_eq!(r1.entries[0].outcome, ShimInstallOutcome::Installed);
assert_eq!(r2.entries[0].outcome, ShimInstallOutcome::Refreshed);
}
#[test]
fn install_refuses_to_clobber_a_foreign_file() {
let (real_dir, shim_dir, _real_bin) = fixture("terraform");
fs::create_dir_all(shim_dir.path()).unwrap();
let path = shim_dir.path().join("terraform");
fs::write(&path, "#!/bin/sh\n# my custom wrapper\nexec /opt/tf \"$@\"\n").unwrap();
let report = with_path(real_dir.path(), || {
install(shim_dir.path(), &["terraform".to_string()]).expect("install")
});
assert_eq!(report.entries[0].outcome, ShimInstallOutcome::ForeignPresent);
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("# my custom wrapper"));
assert!(!after.contains(APERION_SHIELD_SHIM_MARKER));
}
#[test]
fn install_skips_when_upstream_binary_not_on_path() {
let empty = TempDir::new().unwrap();
let shim_dir = TempDir::new().unwrap();
let cmd_name = "aperion-test-fake-binary-zzz999".to_string();
let report = with_path(empty.path(), || {
install(shim_dir.path(), &[cmd_name.clone()]).expect("install")
});
assert_eq!(
report.entries[0].outcome,
ShimInstallOutcome::UpstreamBinaryNotFound
);
assert!(!shim_dir.path().join(&cmd_name).exists());
}
#[test]
fn uninstall_removes_only_our_shims() {
let (real_dir, shim_dir, _real_bin) = fixture("psql");
with_path(real_dir.path(), || {
install(shim_dir.path(), &["psql".to_string()]).expect("install");
});
let foreign = shim_dir.path().join("not-ours");
fs::write(&foreign, "#!/bin/sh\necho foreign\n").unwrap();
let report = uninstall(shim_dir.path()).expect("uninstall");
let by_cmd: BTreeMap<_, _> = report
.entries
.into_iter()
.map(|e| (e.command, e.outcome))
.collect();
assert_eq!(by_cmd.get("psql"), Some(&ShimUninstallOutcome::Removed));
assert_eq!(by_cmd.get("not-ours"), Some(&ShimUninstallOutcome::ForeignPresent));
assert!(foreign.exists());
}
#[test]
fn resolve_shim_dir_honours_env_override() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var_os("APERION_SHIELD_SHIM_DIR");
std::env::set_var("APERION_SHIELD_SHIM_DIR", "/tmp/aperion-test-shims");
let resolved = resolve_shim_dir(None).expect("resolve");
assert_eq!(resolved, PathBuf::from("/tmp/aperion-test-shims"));
match prev {
Some(p) => std::env::set_var("APERION_SHIELD_SHIM_DIR", p),
None => std::env::remove_var("APERION_SHIELD_SHIM_DIR"),
}
}
#[test]
fn resolve_shim_dir_explicit_wins() {
let p = PathBuf::from("/explicit/path");
let resolved = resolve_shim_dir(Some(&p)).expect("resolve");
assert_eq!(resolved, p);
}
#[test]
fn parse_for_arg_accepts_canonical_form() {
let v = parse_for_arg("aws,kubectl, terraform").expect("parse");
assert_eq!(v, vec!["aws", "kubectl", "terraform"]);
}
#[test]
fn parse_for_arg_dedups() {
let v = parse_for_arg("aws,aws,kubectl,aws").expect("parse");
assert_eq!(v, vec!["aws", "kubectl"]);
}
#[test]
fn parse_for_arg_rejects_paths_or_metacharacters() {
assert!(parse_for_arg("/usr/bin/aws").is_err());
assert!(parse_for_arg("aws kubectl").is_err());
assert!(parse_for_arg("aws,../etc/passwd").is_err());
}
}