use std::{
collections::{BTreeMap, HashSet},
env,
ffi::OsString,
fs,
os::unix::fs::PermissionsExt,
path::PathBuf,
time::Duration,
};
use semver::Version;
use crate::{
dependencies::command::{CommandOutput, CommandRunner, CommandSpec},
domain::errors::{AgentError, AgentResult, ErrorCode},
};
const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_PROBE_STREAM_BYTES: usize = 1024 * 1024;
pub(crate) const NODE_VERSION_POLICY: VersionPolicy = VersionPolicy {
minimum_inclusive: "22.19.0",
maximum_exclusive: "23.0.0",
};
pub(crate) const PI_VERSION_POLICY: VersionPolicy = VersionPolicy {
minimum_inclusive: "0.80.10",
maximum_exclusive: "0.81.0",
};
pub(crate) const CLAUDE_VERSION_POLICY: VersionPolicy = VersionPolicy {
minimum_inclusive: "2.1.212",
maximum_exclusive: "3.0.0",
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExecutableSource {
Explicit,
Path,
Managed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedExecutable {
pub path: PathBuf,
pub version: String,
pub source: ExecutableSource,
pub skipped: Vec<String>,
}
pub(crate) struct ExecutableRequirement {
pub name: &'static str,
pub explicit: Option<PathBuf>,
pub managed: PathBuf,
pub trusted_managed_explicit: bool,
pub version_args: &'static [&'static str],
pub version_policy: VersionPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct VersionPolicy {
pub minimum_inclusive: &'static str,
pub maximum_exclusive: &'static str,
}
pub(crate) struct ExecutableResolver<'a> {
runner: &'a dyn CommandRunner,
path: Option<OsString>,
}
impl<'a> ExecutableResolver<'a> {
pub(crate) fn with_path(runner: &'a dyn CommandRunner, path: Option<OsString>) -> Self {
Self { runner, path }
}
pub(crate) async fn resolve(
&self,
requirement: ExecutableRequirement,
) -> AgentResult<Option<ResolvedExecutable>> {
let minimum = Version::parse(requirement.version_policy.minimum_inclusive)
.map_err(|_| invalid_policy(requirement.name))?;
let mut candidates = Vec::new();
if let Some(explicit) = requirement.explicit {
let source = if requirement.trusted_managed_explicit && explicit == requirement.managed
{
ExecutableSource::Managed
} else {
ExecutableSource::Explicit
};
candidates.push((source, explicit));
}
if let Some(path) = &self.path {
candidates.extend(
env::split_paths(path)
.filter(|directory| !directory.as_os_str().is_empty())
.map(|directory| (ExecutableSource::Path, directory.join(requirement.name))),
);
}
candidates.push((ExecutableSource::Managed, requirement.managed));
let mut skipped = Vec::new();
let mut visited = HashSet::new();
for (source, candidate) in candidates {
let Some(canonical) = canonical_executable(&candidate) else {
if source != ExecutableSource::Path || candidate.symlink_metadata().is_ok() {
skipped.push(format!(
"{} {} candidate is not a canonical regular executable",
source_label(source),
requirement.name
));
}
continue;
};
if !visited.insert(canonical.clone()) {
continue;
}
let spec = CommandSpec {
program: canonical.clone(),
args: requirement
.version_args
.iter()
.map(OsString::from)
.collect(),
env: BTreeMap::new(),
cwd: None,
timeout: VERSION_PROBE_TIMEOUT,
};
let output = match self.runner.output(&spec).await {
Ok(output) if output.status == 0 => output,
Ok(_) | Err(_) => {
skipped.push(format!(
"{} {} version probe failed",
source_label(source),
requirement.name
));
continue;
}
};
let Some(version) = version_from_output(&output) else {
skipped.push(format!(
"{} {} version output was invalid",
source_label(source),
requirement.name
));
continue;
};
if !version.pre.is_empty() || version < minimum {
skipped.push(format!(
"{} {} version {} is outside >= {}",
source_label(source),
requirement.name,
numeric_version(&version),
requirement.version_policy.minimum_inclusive,
));
continue;
}
return Ok(Some(ResolvedExecutable {
path: if source == ExecutableSource::Managed {
candidate
} else {
canonical
},
version: version.to_string(),
source,
skipped,
}));
}
Ok(None)
}
}
fn canonical_executable(candidate: &PathBuf) -> Option<PathBuf> {
let canonical = fs::canonicalize(candidate).ok()?;
let metadata = fs::metadata(&canonical).ok()?;
(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0).then_some(canonical)
}
fn version_from_output(output: &CommandOutput) -> Option<Version> {
if output.stdout.len() > MAX_PROBE_STREAM_BYTES || output.stderr.len() > MAX_PROBE_STREAM_BYTES
{
return None;
}
let stdout = std::str::from_utf8(&output.stdout).ok()?;
let stderr = std::str::from_utf8(&output.stderr).ok()?;
first_dotted_version(stdout).or_else(|| first_dotted_version(stderr))
}
fn first_dotted_version(output: &str) -> Option<Version> {
let bytes = output.as_bytes();
let mut start = 0;
while start < bytes.len() {
if !bytes[start].is_ascii_digit() || !has_left_version_boundary(output, start) {
start += 1;
continue;
}
let mut end = consume_digits(bytes, start);
if bytes.get(end) != Some(&b'.') {
start += 1;
continue;
}
end = consume_digits(bytes, end + 1);
if bytes.get(end) != Some(&b'.') {
start += 1;
continue;
}
end = consume_digits(bytes, end + 1);
if bytes.get(end.saturating_sub(1)) == Some(&b'.') {
start += 1;
continue;
}
let token_end = if matches!(bytes.get(end), Some(b'-' | b'+')) {
consume_semver_suffix(bytes, end)
} else {
end
};
if !has_right_version_boundary(output, token_end) {
start += 1;
continue;
}
if let Ok(version) = Version::parse(&output[start..token_end]) {
return Some(version);
}
start = token_end.max(start + 1);
}
None
}
fn consume_digits(bytes: &[u8], mut index: usize) -> usize {
while matches!(bytes.get(index), Some(b'0'..=b'9')) {
index += 1;
}
index
}
fn consume_semver_suffix(bytes: &[u8], mut index: usize) -> usize {
while matches!(
bytes.get(index),
Some(byte)
if byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'-' | b'+')
) {
index += 1;
}
index
}
fn has_left_version_boundary(output: &str, start: usize) -> bool {
let Some(previous) = output[..start].chars().next_back() else {
return true;
};
if matches!(previous, 'v' | 'V') {
let prefix_start = start - previous.len_utf8();
return output[..prefix_start]
.chars()
.next_back()
.is_none_or(is_version_delimiter);
}
is_version_delimiter(previous)
}
fn has_right_version_boundary(output: &str, end: usize) -> bool {
output[end..]
.chars()
.next()
.is_none_or(is_version_delimiter)
}
fn is_version_delimiter(character: char) -> bool {
!character.is_alphanumeric() && !matches!(character, '_' | '.')
}
fn source_label(source: ExecutableSource) -> &'static str {
match source {
ExecutableSource::Explicit => "explicit",
ExecutableSource::Path => "PATH",
ExecutableSource::Managed => "managed",
}
}
fn numeric_version(version: &Version) -> String {
format!("{}.{}.{}", version.major, version.minor, version.patch)
}
fn invalid_policy(name: &str) -> AgentError {
AgentError::new(
ErrorCode::InvalidMessage,
format!("invalid version policy for {name}"),
)
}