use std::path::PathBuf;
#[cfg(unix)]
const SYSTEM_BIN_DIRS: &[&str] = &[
"/usr/bin",
"/usr/local/bin",
"/usr/local/sbin",
"/usr/sbin",
"/bin",
"/sbin",
"/opt/homebrew/bin", "/opt/homebrew/sbin",
];
#[cfg(windows)]
const SYSTEM_BIN_DIRS: &[&str] = &[
"C:\\Windows\\System32",
"C:\\Windows",
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0",
"C:\\Program Files\\Git\\cmd",
"C:\\Program Files\\Git\\bin",
];
#[cfg(unix)]
const EXE_SUFFIXES: &[&str] = &[""];
#[cfg(windows)]
const EXE_SUFFIXES: &[&str] = &[".exe", ".com", ".bat", ".cmd"];
pub fn resolve_safe_bin(name: &str) -> Option<PathBuf> {
if name.contains('/') || name.contains('\\') {
let p = PathBuf::from(name);
if p.is_absolute() && in_trusted_dir(&p) && p.exists() {
return Some(p);
}
return None;
}
let mut search_dirs: Vec<PathBuf> = SYSTEM_BIN_DIRS.iter().map(PathBuf::from).collect();
if let Ok(extra) = std::env::var("KEYHOG_TRUSTED_BIN_DIR") {
let sep = if cfg!(windows) { ';' } else { ':' };
for dir in extra.split(sep).filter(|s| !s.is_empty()) {
search_dirs.push(PathBuf::from(dir));
}
}
for dir in &search_dirs {
for suffix in EXE_SUFFIXES {
let candidate = dir.join(format!("{name}{suffix}"));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
pub fn resolve_or_fallback(name: &str) -> PathBuf {
if let Some(p) = resolve_safe_bin(name) {
return p;
}
tracing::warn!(
"keyhog: '{name}' not found in trusted system bin dirs; falling back to PATH lookup. \
Set KEYHOG_TRUSTED_BIN_DIR if running on a non-standard distro."
);
PathBuf::from(name)
}
fn in_trusted_dir(p: &std::path::Path) -> bool {
let parent = match p.parent() {
Some(p) => p,
None => return false,
};
SYSTEM_BIN_DIRS
.iter()
.any(|d| parent == std::path::Path::new(d))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(unix)]
fn resolves_sh_to_known_path() {
let resolved = resolve_safe_bin("sh").expect("sh should resolve");
assert!(resolved.is_absolute());
assert!(resolved.ends_with("sh"));
}
#[test]
fn refuses_relative_path() {
assert!(resolve_safe_bin("./malicious").is_none());
assert!(resolve_safe_bin("../../../bin/sh").is_none());
}
#[test]
fn refuses_absolute_path_outside_trusted_dirs() {
assert!(resolve_safe_bin("/tmp/whatever").is_none());
}
#[test]
fn unknown_binary_is_none() {
assert!(resolve_safe_bin("definitely-not-a-real-binary-xyz123").is_none());
}
}