1use std::path::PathBuf;
21
22#[cfg(unix)]
23const SYSTEM_BIN_DIRS: &[&str] = &[
24 "/usr/bin",
25 "/usr/local/bin",
26 "/usr/local/sbin",
27 "/usr/sbin",
28 "/bin",
29 "/sbin",
30 "/opt/homebrew/bin", "/opt/homebrew/sbin",
32];
33
34#[cfg(windows)]
35const SYSTEM_BIN_DIRS: &[&str] = &[
36 "C:\\Windows\\System32",
37 "C:\\Windows",
38 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0",
39 "C:\\Program Files\\Git\\cmd",
40 "C:\\Program Files\\Git\\bin",
41];
42
43#[cfg(unix)]
44const EXE_SUFFIXES: &[&str] = &[""];
45
46#[cfg(windows)]
47const EXE_SUFFIXES: &[&str] = &[".exe", ".com", ".bat", ".cmd"];
48
49pub fn resolve_safe_bin(name: &str) -> Option<PathBuf> {
54 if name.contains('/') || name.contains('\\') {
55 let p = PathBuf::from(name);
58 if p.is_absolute() && in_trusted_dir(&p) && p.exists() {
59 return Some(p);
60 }
61 return None;
62 }
63
64 let mut search_dirs: Vec<PathBuf> = SYSTEM_BIN_DIRS.iter().map(PathBuf::from).collect();
65 if let Ok(extra) = std::env::var("KEYHOG_TRUSTED_BIN_DIR") {
66 let sep = if cfg!(windows) { ';' } else { ':' };
67 for dir in extra.split(sep).filter(|s| !s.is_empty()) {
68 search_dirs.push(PathBuf::from(dir));
69 }
70 }
71
72 for dir in &search_dirs {
73 for suffix in EXE_SUFFIXES {
74 let candidate = dir.join(format!("{name}{suffix}"));
75 if candidate.is_file() {
76 return Some(candidate);
77 }
78 }
79 }
80 None
81}
82
83pub fn resolve_or_fallback(name: &str) -> PathBuf {
92 if let Some(p) = resolve_safe_bin(name) {
93 return p;
94 }
95 tracing::warn!(
96 "keyhog: '{name}' not found in trusted system bin dirs; falling back to PATH lookup. \
97 Set KEYHOG_TRUSTED_BIN_DIR if running on a non-standard distro."
98 );
99 PathBuf::from(name)
100}
101
102fn in_trusted_dir(p: &std::path::Path) -> bool {
103 let parent = match p.parent() {
104 Some(p) => p,
105 None => return false,
106 };
107 SYSTEM_BIN_DIRS
108 .iter()
109 .any(|d| parent == std::path::Path::new(d))
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[test]
117 #[cfg(unix)]
118 fn resolves_sh_to_known_path() {
119 let resolved = resolve_safe_bin("sh").expect("sh should resolve");
121 assert!(resolved.is_absolute());
122 assert!(resolved.ends_with("sh"));
123 }
124
125 #[test]
126 fn refuses_relative_path() {
127 assert!(resolve_safe_bin("./malicious").is_none());
128 assert!(resolve_safe_bin("../../../bin/sh").is_none());
129 }
130
131 #[test]
132 fn refuses_absolute_path_outside_trusted_dirs() {
133 assert!(resolve_safe_bin("/tmp/whatever").is_none());
134 }
135
136 #[test]
137 fn unknown_binary_is_none() {
138 assert!(resolve_safe_bin("definitely-not-a-real-binary-xyz123").is_none());
140 }
141}