use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, OnceLock};
use std::time::UNIX_EPOCH;
use openmoat_hosts::{Advisory, Host};
use serde::{Deserialize, Serialize};
use super::claude_version::{Version, parse, run_version};
static CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
const CACHE_LIMIT: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Seen {
path: String,
len: u64,
modified_ns: u128,
version: Version,
}
type Asked = Mutex<Vec<(&'static str, Option<Version>)>>;
pub fn unsupported(host: Host) -> Option<String> {
let gate = host.spec().install.as_ref()?.versions.as_ref()?;
let version = version(gate.program)?;
gate.hook_of(version).is_none().then(|| {
format!(
"`{} --version` reports {}: {}",
gate.program,
dotted(version),
gate.older
)
})
}
pub fn hook_api(host: Host) -> Option<String> {
let gate = host.spec().install.as_ref()?.versions.as_ref()?;
let version = version(gate.program)?;
let hook = gate.hook_of(version)?;
Some(format!("{} {}: {hook}", gate.program, dotted(version)))
}
pub fn advisory(host: Host) -> Option<String> {
let advisories = &host.spec().advisories;
if advisories.program.is_empty() {
return None;
}
let version = version(advisories.program)?;
let affecting: Vec<&Advisory> = advisories
.affecting(version, crate::os_version::platform())
.collect();
advisory_line(host.display_name(), version, &affecting)
}
pub fn prefetch_present(home: &crate::home::Home) {
if home.exists() {
let _ = CACHE_DIR.set(home.root().to_path_buf());
}
prefetch(
Host::ALL.into_iter().filter(|host| {
super::HostConfig::for_host(home, *host).is_ok_and(|c| c.host_present())
}),
);
}
pub fn prefetch(hosts: impl IntoIterator<Item = Host>) {
let mut programs: Vec<&'static str> = hosts
.into_iter()
.flat_map(|host| {
let spec = host.spec();
let gate = spec.install.as_ref().and_then(|i| i.versions.as_ref());
[gate.map(|g| g.program), Some(spec.advisories.program)]
})
.flatten()
.filter(|program| !program.is_empty())
.collect();
programs.sort_unstable();
programs.dedup();
std::thread::scope(|scope| {
for program in programs {
scope.spawn(move || version(program));
}
});
}
fn advisory_line(name: &str, version: Version, affecting: &[&Advisory]) -> Option<String> {
let first = affecting.first()?;
let fixed = affecting.iter().map(|a| a.fixed).max()?;
let more = match affecting.len() - 1 {
0 => String::new(),
1 => " and 1 more".to_owned(),
n => format!(" and {n} more"),
};
Some(format!(
"{name} {} has a known security advisory, {} ({}){more}; upgrade to {} or later: {}",
dotted(version),
first.id,
first.summary,
dotted(fixed),
first.source
))
}
fn dotted((major, minor, patch): Version) -> String {
format!("{major}.{minor}.{patch}")
}
fn version(program: &'static str) -> Option<Version> {
static ASKED: OnceLock<Asked> = OnceLock::new();
let asked = ASKED.get_or_init(|| Mutex::new(Vec::new()));
if let Some((_, known)) = asked.lock().ok()?.iter().find(|(name, _)| *name == program) {
return *known;
}
let found = ask(program);
let mut asked = asked.lock().ok()?;
if !asked.iter().any(|(name, _)| *name == program) {
asked.push((program, found));
}
found
}
fn ask(program: &str) -> Option<Version> {
#[cfg(debug_assertions)]
if let Some(value) = std::env::var_os(format!("MOAT_{}_VERSION", program.to_uppercase())) {
return parse_any(&value.to_string_lossy());
}
let path: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
let found = crate::environment::find_in(&path, &[], program)?;
let dir = CACHE_DIR.get();
let file = dir.and_then(|_| identify(&found));
if let (Some(dir), Some(seen)) = (dir, &file)
&& let Some(known) = cached(dir, seen)
{
return Some(known);
}
let version = parse_any(&run_version(Command::new(&found))?)?;
if let (Some(dir), Some(mut seen)) = (dir, file) {
seen.version = version;
remember(dir, seen);
}
Some(version)
}
fn identify(path: &Path) -> Option<Seen> {
let meta = std::fs::metadata(path).ok()?;
let modified_ns = meta
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()?
.as_nanos();
Some(Seen {
path: path.to_string_lossy().into_owned(),
len: meta.len(),
modified_ns,
version: (0, 0, 0),
})
}
fn read_cache(dir: &Path) -> Vec<Seen> {
std::fs::read(dir.join("host-versions.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn cached(dir: &Path, file: &Seen) -> Option<Version> {
read_cache(dir)
.into_iter()
.find(|s| s.path == file.path && s.len == file.len && s.modified_ns == file.modified_ns)
.map(|s| s.version)
}
fn remember(dir: &Path, seen: Seen) {
static WRITING: Mutex<()> = Mutex::new(());
let Ok(_one_at_a_time) = WRITING.lock() else {
return;
};
let mut all = read_cache(dir);
all.retain(|s| s.path != seen.path);
all.insert(0, seen);
all.truncate(CACHE_LIMIT);
if let Ok(bytes) = serde_json::to_vec_pretty(&all) {
let _ = crate::home::write_private(&dir.join("host-versions.json"), &bytes);
}
}
fn parse_any(text: &str) -> Option<Version> {
text.split_whitespace().find_map(parse)
}
#[cfg(test)]
mod tests;