1use log::{debug, info};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::time::{Duration, SystemTime};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ToolStatus {
10 pub available: bool,
11 pub path: Option<PathBuf>,
12 pub execution_path: Option<PathBuf>, pub version: Option<String>,
14 pub installation_source: InstallationSource,
15 pub last_checked: SystemTime,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub enum InstallationSource {
20 SystemPath,
21 UserLocal,
22 CargoHome,
23 GoHome,
24 PackageManager(String),
25 Manual,
26 NotFound,
27}
28
29#[derive(Debug, Clone)]
30pub struct ToolDetectionConfig {
31 pub cache_ttl: Duration,
32 pub enable_cache: bool,
33 pub search_user_paths: bool,
34 pub search_system_paths: bool,
35}
36
37impl Default for ToolDetectionConfig {
38 fn default() -> Self {
39 Self {
40 cache_ttl: Duration::from_secs(300), enable_cache: true,
42 search_user_paths: true,
43 search_system_paths: true,
44 }
45 }
46}
47
48pub struct ToolDetector {
49 cache: HashMap<String, ToolStatus>,
50 config: ToolDetectionConfig,
51}
52
53impl ToolDetector {
54 pub fn new() -> Self {
55 Self::with_config(ToolDetectionConfig::default())
56 }
57
58 pub fn with_config(config: ToolDetectionConfig) -> Self {
59 Self {
60 cache: HashMap::new(),
61 config,
62 }
63 }
64
65 pub fn detect_tool(&mut self, tool_name: &str) -> ToolStatus {
67 if !self.config.enable_cache {
68 return self.detect_tool_real_time(tool_name);
69 }
70
71 if let Some(cached) = self.cache.get(tool_name)
73 && cached.last_checked.elapsed().unwrap_or(Duration::MAX) < self.config.cache_ttl
74 {
75 debug!(
76 "Using cached status for {}: available={}",
77 tool_name, cached.available
78 );
79 return cached.clone();
80 }
81
82 let status = self.detect_tool_real_time(tool_name);
84 debug!(
85 "Real-time detection for {}: available={}, path={:?}",
86 tool_name, status.available, status.path
87 );
88 self.cache.insert(tool_name.to_string(), status.clone());
89 status
90 }
91
92 pub fn detect_all_vulnerability_tools(
94 &mut self,
95 languages: &[crate::analyzer::dependency_parser::Language],
96 ) -> HashMap<String, ToolStatus> {
97 let mut results = HashMap::new();
98
99 for language in languages {
100 let tool_names = self.get_tools_for_language(language);
101
102 for tool_name in tool_names {
103 if !results.contains_key(tool_name) {
104 results.insert(tool_name.to_string(), self.detect_tool(tool_name));
105 }
106 }
107 }
108
109 results
110 }
111
112 fn get_tools_for_language(
113 &self,
114 language: &crate::analyzer::dependency_parser::Language,
115 ) -> Vec<&'static str> {
116 match language {
117 crate::analyzer::dependency_parser::Language::Rust => vec!["cargo-audit"],
118 crate::analyzer::dependency_parser::Language::JavaScript
119 | crate::analyzer::dependency_parser::Language::TypeScript => {
120 vec!["bun", "npm", "yarn", "pnpm"]
121 }
122 crate::analyzer::dependency_parser::Language::Python => vec!["pip-audit"],
123 crate::analyzer::dependency_parser::Language::Go => vec!["govulncheck"],
124 crate::analyzer::dependency_parser::Language::Java
125 | crate::analyzer::dependency_parser::Language::Kotlin => vec!["grype"],
126 _ => vec![],
127 }
128 }
129
130 pub fn clear_cache(&mut self) {
132 self.cache.clear();
133 }
134
135 pub fn detect_bun(&mut self) -> ToolStatus {
137 self.detect_tool_with_alternatives("bun", &["bun", "bunx"])
138 }
139
140 pub fn detect_js_package_managers(&mut self) -> HashMap<String, ToolStatus> {
142 let mut managers = HashMap::new();
143 managers.insert("bun".to_string(), self.detect_bun());
144 managers.insert("npm".to_string(), self.detect_tool("npm"));
145 managers.insert("yarn".to_string(), self.detect_tool("yarn"));
146 managers.insert("pnpm".to_string(), self.detect_tool("pnpm"));
147 managers
148 }
149
150 pub fn detect_tool_with_alternatives(
152 &mut self,
153 primary_name: &str,
154 alternatives: &[&str],
155 ) -> ToolStatus {
156 if self.config.enable_cache
158 && let Some(cached) = self.cache.get(primary_name)
159 && cached.last_checked.elapsed().unwrap_or(Duration::MAX) < self.config.cache_ttl
160 {
161 debug!(
162 "Using cached status for {}: available={}",
163 primary_name, cached.available
164 );
165 return cached.clone();
166 }
167
168 for alternative in alternatives {
170 debug!("Trying to detect tool: {}", alternative);
171 let status = self.detect_tool_real_time(alternative);
172 if status.available {
173 debug!("Found {} via alternative: {}", primary_name, alternative);
174 if self.config.enable_cache {
175 self.cache.insert(primary_name.to_string(), status.clone());
176 }
177 return status;
178 }
179 }
180
181 let not_found = ToolStatus {
183 available: false,
184 path: None,
185 execution_path: None,
186 version: None,
187 installation_source: InstallationSource::NotFound,
188 last_checked: SystemTime::now(),
189 };
190
191 if self.config.enable_cache {
192 self.cache
193 .insert(primary_name.to_string(), not_found.clone());
194 }
195 not_found
196 }
197
198 fn detect_tool_real_time(&self, tool_name: &str) -> ToolStatus {
200 debug!("Starting real-time detection for {}", tool_name);
201
202 if let Some((path, version)) = self.try_command_in_path(tool_name) {
204 info!(
205 "Found {} in PATH at {:?} with version {:?}",
206 tool_name, path, version
207 );
208 return ToolStatus {
209 available: true,
210 path: Some(path),
211 execution_path: None, version,
213 installation_source: InstallationSource::SystemPath,
214 last_checked: SystemTime::now(),
215 };
216 }
217
218 if self.config.search_user_paths || self.config.search_system_paths {
220 let search_paths = self.get_tool_search_paths(tool_name);
221 debug!(
222 "Searching alternative paths for {}: {:?}",
223 tool_name, search_paths
224 );
225
226 for search_path in search_paths {
227 let tool_path = search_path.join(tool_name);
228 debug!("Checking path: {:?}", tool_path);
229
230 if let Some(version) = self.verify_tool_at_path(&tool_path, tool_name) {
231 let source = self.determine_installation_source(&search_path);
232 info!(
233 "Found {} at {:?} with version {:?} (source: {:?})",
234 tool_name, tool_path, version, source
235 );
236 return ToolStatus {
237 available: true,
238 path: Some(tool_path.clone()),
239 execution_path: Some(tool_path), version: Some(version),
241 installation_source: source,
242 last_checked: SystemTime::now(),
243 };
244 }
245
246 #[cfg(windows)]
248 {
249 let tool_path_exe = search_path.join(format!("{}.exe", tool_name));
250 if let Some(version) = self.verify_tool_at_path(&tool_path_exe, tool_name) {
251 let source = self.determine_installation_source(&search_path);
252 info!(
253 "Found {} at {:?} with version {:?} (source: {:?})",
254 tool_name, tool_path_exe, version, source
255 );
256 return ToolStatus {
257 available: true,
258 path: Some(tool_path_exe.clone()),
259 execution_path: Some(tool_path_exe), version: Some(version),
261 installation_source: source,
262 last_checked: SystemTime::now(),
263 };
264 }
265 }
266 }
267 }
268
269 debug!("Tool {} not found in any location", tool_name);
271 ToolStatus {
272 available: false,
273 path: None,
274 execution_path: None,
275 version: None,
276 installation_source: InstallationSource::NotFound,
277 last_checked: SystemTime::now(),
278 }
279 }
280
281 fn get_tool_search_paths(&self, tool_name: &str) -> Vec<PathBuf> {
283 let mut paths = Vec::new();
284
285 if !self.config.search_user_paths && !self.config.search_system_paths {
286 return paths;
287 }
288
289 if self.config.search_user_paths
291 && let Ok(home) = std::env::var("HOME")
292 {
293 let home_path = PathBuf::from(home);
294
295 paths.push(home_path.join(".local").join("bin"));
297 paths.push(home_path.join(".cargo").join("bin"));
298 paths.push(home_path.join("go").join("bin"));
299
300 self.add_tool_specific_paths(tool_name, &home_path, &mut paths);
302
303 #[cfg(windows)]
305 {
306 if let Ok(userprofile) = std::env::var("USERPROFILE") {
307 let userprofile_path = PathBuf::from(userprofile);
308 paths.push(userprofile_path.join(".local").join("bin"));
309 paths.push(userprofile_path.join("scoop").join("shims"));
310 paths.push(userprofile_path.join(".cargo").join("bin"));
311 paths.push(userprofile_path.join("go").join("bin"));
312 }
313 if let Ok(appdata) = std::env::var("APPDATA") {
314 paths.push(PathBuf::from(&appdata).join("syncable-cli").join("bin"));
315 paths.push(PathBuf::from(&appdata).join("npm"));
316 }
317 paths.push(PathBuf::from("C:\\Program Files"));
319 paths.push(PathBuf::from("C:\\Program Files (x86)"));
320 }
321 }
322
323 if self.config.search_system_paths {
325 paths.push(PathBuf::from("/usr/local/bin"));
326 paths.push(PathBuf::from("/usr/bin"));
327 paths.push(PathBuf::from("/bin"));
328 }
329
330 paths.sort();
332 paths.dedup();
333 paths.into_iter().filter(|p| p.exists()).collect()
334 }
335
336 fn add_tool_specific_paths(&self, tool_name: &str, home_path: &Path, paths: &mut Vec<PathBuf>) {
337 match tool_name {
338 "cargo-audit" => {
339 paths.push(home_path.join(".cargo").join("bin"));
340 }
341 "govulncheck" => {
342 paths.push(home_path.join("go").join("bin"));
343 if let Ok(gopath) = std::env::var("GOPATH") {
344 paths.push(PathBuf::from(gopath).join("bin"));
345 }
346 if let Ok(goroot) = std::env::var("GOROOT") {
347 paths.push(PathBuf::from(goroot).join("bin"));
348 }
349 }
350 "grype" => {
351 paths.push(home_path.join(".local").join("bin"));
352 paths.push(PathBuf::from("/opt/homebrew/bin"));
353 paths.push(PathBuf::from("/usr/local/bin"));
354 }
355 "pip-audit" => {
356 paths.push(home_path.join(".local").join("bin"));
357 if let Ok(output) = Command::new("python3")
358 .args(["--", "site", "--user-base"])
359 .output()
360 && let Ok(user_base) = String::from_utf8(output.stdout)
361 {
362 paths.push(PathBuf::from(user_base.trim()).join("bin"));
363 }
364 if let Ok(output) = Command::new("python")
365 .args(["-m", "site", "--user-base"])
366 .output()
367 && let Ok(user_base) = String::from_utf8(output.stdout)
368 {
369 paths.push(PathBuf::from(user_base.trim()).join("bin"));
370 }
371 }
372 "bun" | "bunx" => {
373 paths.push(home_path.join(".bun").join("bin"));
374 paths.push(home_path.join(".npm-global").join("bin"));
375 paths.push(PathBuf::from("/opt/homebrew/bin"));
376 paths.push(PathBuf::from("/usr/local/bin"));
377 paths.push(home_path.join(".local").join("bin"));
378 }
379 "yarn" => {
380 paths.push(home_path.join(".yarn").join("bin"));
381 paths.push(home_path.join(".npm-global").join("bin"));
382 }
383 "pnpm" => {
384 paths.push(home_path.join(".local").join("share").join("pnpm"));
385 paths.push(home_path.join(".npm-global").join("bin"));
386 }
387 "npm" => {
388 if let Ok(node_path) = std::env::var("NODE_PATH") {
389 paths.push(PathBuf::from(node_path).join(".bin"));
390 }
391 paths.push(home_path.join(".npm-global").join("bin"));
392 paths.push(PathBuf::from("/usr/local/lib/node_modules/.bin"));
393 }
394 _ => {}
395 }
396 }
397
398 fn try_command_in_path(&self, tool_name: &str) -> Option<(PathBuf, Option<String>)> {
400 let version_args = self.get_version_args(tool_name);
401 debug!("Trying {} with args: {:?}", tool_name, version_args);
402
403 let output = Command::new(tool_name).args(&version_args).output().ok()?;
404
405 if output.status.success() {
406 let version = self.parse_version_output(&output.stdout, tool_name);
407 let path = self
408 .find_tool_path(tool_name)
409 .unwrap_or_else(|| PathBuf::from(tool_name));
410 return Some((path, version));
411 }
412
413 if !output.stderr.is_empty()
415 && let Some(version) = self.parse_version_output(&output.stderr, tool_name)
416 {
417 let path = self
418 .find_tool_path(tool_name)
419 .unwrap_or_else(|| PathBuf::from(tool_name));
420 return Some((path, Some(version)));
421 }
422
423 None
424 }
425
426 fn verify_tool_at_path(&self, tool_path: &Path, tool_name: &str) -> Option<String> {
428 if !tool_path.exists() {
429 return None;
430 }
431
432 let version_args = self.get_version_args(tool_name);
433 debug!(
434 "Verifying {} at {:?} with args: {:?}",
435 tool_name, tool_path, version_args
436 );
437
438 let output = Command::new(tool_path).args(&version_args).output().ok()?;
439
440 if output.status.success() {
441 self.parse_version_output(&output.stdout, tool_name)
442 } else if !output.stderr.is_empty() {
443 self.parse_version_output(&output.stderr, tool_name)
444 } else {
445 None
446 }
447 }
448
449 fn get_version_args(&self, tool_name: &str) -> Vec<&str> {
451 match tool_name {
452 "cargo-audit" => vec!["audit", "--version"],
453 "npm" => vec!["--version"],
454 "pip-audit" => vec!["--version"],
455 "govulncheck" => vec!["-version"],
456 "grype" => vec!["version"],
457 "dependency-check" => vec!["--version"],
458 "bun" => vec!["--version"],
459 "bunx" => vec!["--version"],
460 "yarn" => vec!["--version"],
461 "pnpm" => vec!["--version"],
462 _ => vec!["--version"],
463 }
464 }
465
466 fn parse_version_output(&self, output: &[u8], tool_name: &str) -> Option<String> {
468 let output_str = String::from_utf8_lossy(output);
469 debug!(
470 "Parsing version output for {}: {}",
471 tool_name,
472 output_str.trim()
473 );
474
475 match tool_name {
476 "cargo-audit" => {
477 for line in output_str.lines() {
478 if line.contains("cargo-audit")
479 && let Some(version) = line.split_whitespace().nth(1)
480 {
481 return Some(version.to_string());
482 }
483 }
484 }
485 "grype" => {
486 for line in output_str.lines() {
487 if line.trim_start().starts_with("grype")
488 && let Some(version) = line.split_whitespace().nth(1)
489 {
490 return Some(version.to_string());
491 }
492 if line.contains("\"version\"")
493 && let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
494 && let Some(version) = json.get("version").and_then(|v| v.as_str())
495 {
496 return Some(version.to_string());
497 }
498 }
499 }
500 "govulncheck" => {
501 for line in output_str.lines() {
502 if let Some(at_pos) = line.find('@') {
503 let version_part = &line[at_pos + 1..];
504 if let Some(version) = version_part.split_whitespace().next() {
505 return Some(version.trim_start_matches('v').to_string());
506 }
507 }
508 if line.contains("govulncheck")
509 && let Some(version) = line.split_whitespace().nth(1)
510 {
511 return Some(version.trim_start_matches('v').to_string());
512 }
513 }
514 }
515 "npm" | "yarn" | "pnpm" => {
516 if let Some(first_line) = output_str.lines().next() {
517 let version = first_line.trim();
518 if !version.is_empty() {
519 return Some(version.to_string());
520 }
521 }
522 }
523 "bun" | "bunx" => {
524 for line in output_str.lines() {
525 let line = line.trim();
526 if line.starts_with("bun ")
527 && let Some(version) = line.split_whitespace().nth(1)
528 {
529 return Some(version.to_string());
530 }
531 if let Some(version) = extract_version_generic(line) {
532 return Some(version);
533 }
534 }
535 }
536 "pip-audit" => {
537 for line in output_str.lines() {
538 if line.contains("pip-audit")
539 && let Some(version) = line.split_whitespace().nth(1)
540 {
541 return Some(version.to_string());
542 }
543 }
544 if let Some(version) = extract_version_generic(&output_str) {
545 return Some(version);
546 }
547 }
548 _ => {
549 if let Some(version) = extract_version_generic(&output_str) {
550 return Some(version);
551 }
552 }
553 }
554
555 None
556 }
557
558 fn determine_installation_source(&self, path: &Path) -> InstallationSource {
560 let path_str = path.to_string_lossy().to_lowercase();
561
562 if path_str.contains(".cargo") {
563 InstallationSource::CargoHome
564 } else if path_str.contains("go/bin") || path_str.contains("gopath") {
565 InstallationSource::GoHome
566 } else if path_str.contains(".local") {
567 InstallationSource::UserLocal
568 } else if path_str.contains("homebrew") || path_str.contains("brew") {
569 InstallationSource::PackageManager("brew".to_string())
570 } else if path_str.contains("scoop") {
571 InstallationSource::PackageManager("scoop".to_string())
572 } else if path_str.contains("apt") || path_str.contains("/usr/bin") {
573 InstallationSource::PackageManager("apt".to_string())
574 } else if path_str.contains("/usr/local")
575 || path_str.contains("/usr/bin")
576 || path_str.contains("/bin")
577 {
578 InstallationSource::SystemPath
579 } else {
580 InstallationSource::Manual
581 }
582 }
583
584 fn find_tool_path(&self, tool_name: &str) -> Option<PathBuf> {
586 #[cfg(unix)]
587 {
588 if let Ok(output) = Command::new("which").arg(tool_name).output()
589 && output.status.success()
590 {
591 let output_str = String::from_utf8_lossy(&output.stdout);
592 let path_str = output_str.trim();
593 if !path_str.is_empty() {
594 return Some(PathBuf::from(path_str));
595 }
596 }
597 }
598
599 #[cfg(windows)]
600 {
601 if let Ok(output) = Command::new("where").arg(tool_name).output()
602 && output.status.success()
603 {
604 let output_str = String::from_utf8_lossy(&output.stdout);
605 let path_str = output_str.trim();
606 if let Some(first_path) = path_str.lines().next()
607 && !first_path.is_empty()
608 {
609 return Some(PathBuf::from(first_path));
610 }
611 }
612 }
613
614 None
615 }
616}
617
618impl Default for ToolDetector {
619 fn default() -> Self {
620 Self::new()
621 }
622}
623
624fn extract_version_generic(text: &str) -> Option<String> {
626 use regex::Regex;
627
628 let patterns = vec![
629 r"\b(\d+\.\d+\.\d+(?:[+-][a-zA-Z0-9-.]+)?)\b",
630 r"\bv?(\d+\.\d+\.\d+)\b",
631 r"\b(\d+\.\d+)\b",
632 ];
633
634 for pattern in patterns {
635 if let Ok(re) = Regex::new(pattern)
636 && let Some(captures) = re.captures(text)
637 && let Some(version) = captures.get(1)
638 {
639 let version_str = version.as_str();
640 if !version_str.starts_with("127.") && !version_str.starts_with("192.") {
641 return Some(version_str.to_string());
642 }
643 }
644 }
645
646 None
647}