use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use crate::analysis::findings::Finding;
use crate::languages::spec::ToolSpec;
pub mod parsers;
pub use parsers::parse_output;
#[cfg(test)]
mod tests;
pub const TOOL_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolStatus {
Ok,
Skipped,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolOutcome {
pub tool: &'static str,
pub status: ToolStatus,
pub findings: Vec<Finding>,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolOutputError(pub String);
impl std::fmt::Display for ToolOutputError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ToolOutputError {}
pub(crate) fn is_executable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
match path.metadata() {
Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}
#[cfg(not(unix))]
{
path.is_file()
}
}
pub fn resolve_tool(spec: &ToolSpec, root: &Path) -> Option<PathBuf> {
resolve_tool_in(spec, root, std::env::var_os("PATH").as_deref())
}
pub(crate) fn resolve_tool_in(
spec: &ToolSpec,
root: &Path,
path: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
for relative in spec.local_paths {
let candidate = root.join(relative);
if is_executable(&candidate) {
return Some(candidate);
}
}
let name = spec.command.first()?;
which_first_in(name, path?).map(PathBuf::from)
}
fn which_first_in(command: &str, path: &std::ffi::OsStr) -> Option<String> {
for dir in std::env::split_paths(path) {
let candidate = dir.join(command);
if is_executable(&candidate) {
return Some(candidate.to_string_lossy().into_owned());
}
}
None
}
pub fn is_configured(spec: &ToolSpec, root: &Path) -> bool {
spec.config_files
.iter()
.any(|name| root.join(name).exists())
}
pub fn tool_status(spec: &ToolSpec, root: &Path) -> ToolOutcome {
if !is_configured(spec, root) {
let listed = spec.config_files[..spec.config_files.len().min(3)].join(", ");
return ToolOutcome {
tool: spec.name,
status: ToolStatus::Skipped,
findings: Vec::new(),
detail: format!("not configured (add one of: {listed})"),
};
}
if resolve_tool(spec, root).is_none() {
let looked = if spec.local_paths.is_empty() {
"PATH".to_owned()
} else {
format!("{}, then PATH", spec.local_paths.join(", "))
};
return ToolOutcome {
tool: spec.name,
status: ToolStatus::Unavailable,
findings: Vec::new(),
detail: format!("configured but not found (looked in {looked})"),
};
}
ToolOutcome {
tool: spec.name,
status: ToolStatus::Ok,
findings: Vec::new(),
detail: "ready".to_owned(),
}
}
pub async fn run_tool(spec: &ToolSpec, root: &Path, files: &[String]) -> ToolOutcome {
let eligibility = tool_status(spec, root);
if eligibility.status != ToolStatus::Ok {
return eligibility;
}
let Some(executable) = resolve_tool(spec, root) else {
return ToolOutcome {
tool: spec.name,
status: ToolStatus::Unavailable,
findings: Vec::new(),
detail: "configured but not found".to_owned(),
};
};
let executable = if executable.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(&executable))
.unwrap_or(executable)
} else {
executable
};
let mut argv: Vec<String> = Vec::with_capacity(spec.command.len() + files.len());
argv.push(executable.to_string_lossy().into_owned());
argv.extend(spec.command[1..].iter().map(|s| (*s).to_owned()));
if spec.accepts_files {
argv.extend(files.iter().map(|f| {
if f.starts_with('-') {
format!("./{f}")
} else {
f.clone()
}
}));
}
let mut command = Command::new(&argv[0]);
command.args(&argv[1..]);
command.current_dir(root);
command.stdin(Stdio::null());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
command.kill_on_drop(true);
let child = match command.spawn() {
Ok(child) => child,
Err(err) => {
return ToolOutcome {
tool: spec.name,
status: ToolStatus::Unavailable,
findings: Vec::new(),
detail: format!("{} could not be executed: {err}", spec.name),
};
}
};
let output = match tokio::time::timeout(TOOL_TIMEOUT, child.wait_with_output()).await {
Ok(Ok(output)) => output,
Ok(Err(err)) => {
return ToolOutcome {
tool: spec.name,
status: ToolStatus::Unavailable,
findings: Vec::new(),
detail: format!("{} could not be executed: {err}", spec.name),
};
}
Err(_) => {
return ToolOutcome {
tool: spec.name,
status: ToolStatus::Unavailable,
findings: Vec::new(),
detail: format!("{} timed out after {}s", spec.name, TOOL_TIMEOUT.as_secs()),
};
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let (diagnostics, other) = if spec.diagnostics_stream == "stderr" {
(stderr.as_ref(), stdout.as_ref())
} else {
(stdout.as_ref(), stderr.as_ref())
};
let parse_result = parse_output(
spec,
diagnostics,
files.first().map(String::as_str).unwrap_or(""),
);
match parse_result {
Ok(findings)
if findings.is_empty() && !output.status.success() && diagnostics.trim().is_empty() =>
{
ToolOutcome {
tool: spec.name,
status: ToolStatus::Unavailable,
findings: Vec::new(),
detail: format!(
"{} exited {} without producing diagnostics: {}",
spec.name,
output
.status
.code()
.map_or("by signal".to_owned(), |c| c.to_string()),
truncate(other.trim(), 200)
),
}
}
Ok(findings) => ToolOutcome {
tool: spec.name,
status: ToolStatus::Ok,
findings: retain_requested(spec, findings, files),
detail: truncate(other.trim(), 200),
},
Err(err) => ToolOutcome {
tool: spec.name,
status: ToolStatus::Unavailable,
findings: Vec::new(),
detail: format!("{err}. other stream: {}", truncate(other.trim(), 200)),
},
}
}
fn retain_requested(spec: &ToolSpec, findings: Vec<Finding>, files: &[String]) -> Vec<Finding> {
if spec.accepts_files {
return findings;
}
let wanted: std::collections::BTreeSet<&str> =
files.iter().map(|f| normalize_path(f)).collect();
findings
.into_iter()
.filter(|finding| wanted.contains(normalize_path(&finding.file_path)))
.collect()
}
fn normalize_path(path: &str) -> &str {
path.strip_prefix("./").unwrap_or(path)
}
pub(crate) fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_owned();
}
let end = (0..=max)
.rev()
.find(|&i| s.is_char_boundary(i))
.unwrap_or(0);
s[..end].to_owned()
}