//! External agent detection: PATH lookup for unmanaged agents, version //! probing in parallel (one thread per binary), and an on-disk cache keyed //! by the PATH string and by binary identity (path + mtime + size), so //! repeated runs never re-scan the PATH or re-probe versions. use crate::app::App; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use wait_timeout::ChildExt; #[derive(Debug, Default)] pub struct ExternalInfo { /// agent name -> binary path (catalog agents found on PATH, unmanaged). pub paths: BTreeMap, /// agent name -> detected version (None = unknown). pub versions: BTreeMap>, } impl ExternalInfo { pub fn path_of(&self, name: &str) -> Option<&PathBuf> { self.paths.get(name) } pub fn version_of(&self, name: &str) -> Option<&Option> { self.versions.get(name) } } /// Detect the version of an external binary (--version), bounded by a /// timeout so a misbehaving agent can never hang the whole listing. pub fn detect_external_version(bin_path: &Path) -> Option { let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string()); let mut cmd = std::process::Command::new(&prog); cmd.args(&prefix) .arg("--version") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); let Ok(mut child) = cmd.spawn() else { return None; }; let stdout_pipe = child.stdout.take(); let stderr_pipe = child.stderr.take(); let stdout_handle = std::thread::spawn(move || { let mut out = String::new(); if let Some(mut pipe) = stdout_pipe { let _ = std::io::Read::read_to_string(&mut pipe, &mut out); } out }); let stderr_handle = std::thread::spawn(move || { let mut err = String::new(); if let Some(mut pipe) = stderr_pipe { let _ = std::io::Read::read_to_string(&mut pipe, &mut err); } err }); const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); let status: Option = match child.wait_timeout(PROBE_TIMEOUT) { Ok(Some(s)) => Some(s), Ok(None) => { let _ = child.kill(); let _ = child.wait(); None } Err(_) => None, }; let stdout = stdout_handle.join().unwrap_or_default(); let stderr = stderr_handle.join().unwrap_or_default(); let status = status?; if !status.success() { return None; } let combined = format!("{stdout}\n{stderr}"); crate::version::find_version(&combined) } // --------------------------------------------------------------------------- // On-disk cache // --------------------------------------------------------------------------- #[derive(Debug, Default, Serialize, Deserialize)] struct ProbeCache { /// FNV-1a hash of the PATH string the token index was built from. #[serde(default)] path_hash: u64, /// FNV-1a hash of the catalog's detect tokens. When the catalog changes /// which binaries it probes (new agent, `detect` added), the token /// index must be rebuilt even if the PATH is unchanged. #[serde(default)] token_hash: u64, /// Catalog run-command first token -> resolved path (null = not found). #[serde(default)] tokens: BTreeMap>, /// Binary identity key -> detected version. #[serde(default)] versions: BTreeMap>, } fn path_hash() -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; if let Some(p) = std::env::var_os("PATH") { for b in p.to_string_lossy().bytes() { h ^= b as u64; h = h.wrapping_mul(0x0000_0100_0000_01b3); } } h } /// Hash of the sorted catalog detect tokens: cache invalidation for catalog /// changes (new agent, added `detect` field) without re-scanning the PATH. fn catalog_token_hash(app: &App) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; let mut tokens: Vec = app .catalog .agents() .iter() .filter_map(|a| a.detect_token()) .collect(); tokens.sort(); for t in tokens { for b in t.bytes() { h ^= b as u64; h = h.wrapping_mul(0x0000_0100_0000_01b3); } } h } /// Build an index of every executable on the PATH in a single pass. /// Much cheaper than one which() call per agent when the catalog is large. fn build_path_index() -> BTreeMap { let mut map: BTreeMap = BTreeMap::new(); let Some(path) = std::env::var_os("PATH") else { return map; }; for dir in std::env::split_paths(&path) { let Ok(rd) = std::fs::read_dir(&dir) else { continue; }; for entry in rd.flatten() { let p = entry.path(); if !p.is_file() { continue; } let Some(name) = p.file_name().and_then(|n| n.to_str()) else { continue; }; let lower = name.to_lowercase(); #[cfg(windows)] { let stem = if lower.ends_with(".exe") { lower[..lower.len() - 4].to_string() } else if lower.ends_with(".cmd") { lower[..lower.len() - 4].to_string() } else if lower.ends_with(".bat") { lower[..lower.len() - 4].to_string() } else if lower.ends_with(".com") { lower[..lower.len() - 4].to_string() } else { continue; }; map.entry(stem).or_insert(p); } #[cfg(not(windows))] { use std::os::unix::fs::PermissionsExt; let executable = std::fs::metadata(&p) .map(|m| m.permissions().mode() & 0o111 != 0) .unwrap_or(false); if executable { map.entry(lower).or_insert(p); } } } } map } /// Detect every unmanaged catalog agent present on the PATH. /// /// The PATH is scanned once and its result cached (invalidated by the PATH /// string hash); version probes run in parallel and are cached by binary /// identity. A steady-state run does no PATH scanning and no subprocess. pub fn detect_externals(app: &App) -> ExternalInfo { let managed: BTreeMap = app.state.all().unwrap_or_default(); let cache_path = app.paths.probe_cache_file.clone(); let mut cache: ProbeCache = std::fs::read_to_string(&cache_path) .ok() .and_then(|t| serde_json::from_str(&t).ok()) .unwrap_or_default(); let mut dirty = false; // Resolve the detect tokens of the catalog (once per PATH change or // catalog change). let current_hash = path_hash(); let current_tokens = catalog_token_hash(app); if cache.path_hash != current_hash || cache.token_hash != current_tokens { let index = build_path_index(); cache.tokens.clear(); for agent in app.catalog.agents() { let Some(token) = agent.detect_token() else { continue; }; if !cache.tokens.contains_key(&token) { let found = index .get(&token.to_lowercase()) .map(|p| p.display().to_string()); cache.tokens.insert(token, found); } } cache.path_hash = current_hash; cache.token_hash = current_tokens; dirty = true; } let mut info = ExternalInfo::default(); // Stale cached paths (binary removed since the cache was built) force a // rebuild of the token index. let stale = cache .tokens .values() .flatten() .any(|p| !Path::new(p).exists()); if stale { let index = build_path_index(); cache.tokens.clear(); for agent in app.catalog.agents() { let Some(token) = agent.detect_token() else { continue; }; if !cache.tokens.contains_key(&token) { let found = index .get(&token.to_lowercase()) .map(|p| p.display().to_string()); cache.tokens.insert(token, found); } } dirty = true; } for agent in app.catalog.agents() { if agent.hidden || !agent.installable || managed.contains_key(&agent.name) { continue; } let Some(token) = agent.detect_token() else { continue; }; if let Some(Some(path)) = cache.tokens.get(&token) { info.paths.insert(agent.name.clone(), PathBuf::from(path)); } } // Versions: on-disk cache first, then parallel probes for the misses. let mut missing: Vec<(String, PathBuf)> = Vec::new(); for (name, bin) in &info.paths { let key = bin_key(bin); match cache.versions.get(&key) { Some(v) => { info.versions.insert(name.clone(), v.clone()); } None => missing.push((name.clone(), bin.clone())), } } if !missing.is_empty() { let results = std::sync::Mutex::new(Vec::new()); std::thread::scope(|s| { for (name, bin) in &missing { let results = &results; let name = name.clone(); let bin = bin.clone(); s.spawn(move || { let version = detect_external_version(&bin); results.lock().unwrap().push((name, bin, version)); }); } }); for (name, bin, version) in results.into_inner().unwrap_or_default() { let key = bin_key(&bin); cache.versions.insert(key.clone(), version.clone()); info.versions.insert(name, version); dirty = true; } } // Keep the caches bounded. if cache.versions.len() > 500 { cache.versions.clear(); dirty = true; } if dirty { if let Ok(text) = serde_json::to_string_pretty(&cache) { if let Some(parent) = cache_path.parent() { let _ = std::fs::create_dir_all(parent); } let _ = std::fs::write(&cache_path, text); } } info } /// Identity key for a binary: path + modification time + size, so a stale /// cache entry is never reused after the binary changed. fn bin_key(bin: &Path) -> String { let meta = std::fs::metadata(bin).ok(); let mtime = meta .as_ref() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); let size = meta.map(|m| m.len()).unwrap_or(0); format!("{}|{mtime}|{size}", bin.display()) } #[cfg(test)] mod tests { use super::*; #[test] fn bin_key_is_stable_and_distinct() { let dir = tempfile::tempdir().unwrap(); let a = dir.path().join("a.exe"); std::fs::write(&a, b"abc").unwrap(); let k1 = bin_key(&a); let k2 = bin_key(&a); assert_eq!(k1, k2); std::fs::write(&a, b"abcd").unwrap(); let k3 = bin_key(&a); assert_ne!(k1, k3); } #[test] fn probe_cache_roundtrip() { let mut cache = ProbeCache::default(); cache .versions .insert("C:/x|1|2".to_string(), Some("1.2.3".to_string())); cache.tokens.insert("node".to_string(), None); let text = serde_json::to_string(&cache).unwrap(); let back: ProbeCache = serde_json::from_str(&text).unwrap(); assert_eq!( back.versions.get("C:/x|1|2").unwrap().as_deref(), Some("1.2.3") ); assert!(back.tokens.contains_key("node")); } #[test] fn old_cache_format_is_ignored() { // The previous cache format had only an "entries" field; loading it // must not fail. let text = r#"{ "entries": { "a": "1.0" } }"#; let back: ProbeCache = serde_json::from_str(text).unwrap(); assert!(back.versions.is_empty()); assert!(back.tokens.is_empty()); } }