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 if cached.last_checked.elapsed().unwrap_or(Duration::MAX) < self.config.cache_ttl {
74 debug!(
75 "Using cached status for {}: available={}",
76 tool_name, cached.available
77 );
78 return cached.clone();
79 }
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 if let Some(cached) = self.cache.get(primary_name) {
159 if cached.last_checked.elapsed().unwrap_or(Duration::MAX) < self.config.cache_ttl {
160 debug!(
161 "Using cached status for {}: available={}",
162 primary_name, cached.available
163 );
164 return cached.clone();
165 }
166 }
167 }
168
169 for alternative in alternatives {
171 debug!("Trying to detect tool: {}", alternative);
172 let status = self.detect_tool_real_time(alternative);
173 if status.available {
174 debug!("Found {} via alternative: {}", primary_name, alternative);
175 if self.config.enable_cache {
176 self.cache.insert(primary_name.to_string(), status.clone());
177 }
178 return status;
179 }
180 }
181
182 let not_found = ToolStatus {
184 available: false,
185 path: None,
186 execution_path: None,
187 version: None,
188 installation_source: InstallationSource::NotFound,
189 last_checked: SystemTime::now(),
190 };
191
192 if self.config.enable_cache {
193 self.cache
194 .insert(primary_name.to_string(), not_found.clone());
195 }
196 not_found
197 }
198
199 fn detect_tool_real_time(&self, tool_name: &str) -> ToolStatus {
201 debug!("Starting real-time detection for {}", tool_name);
202
203 if let Some((path, version)) = self.try_command_in_path(tool_name) {
205 info!(
206 "Found {} in PATH at {:?} with version {:?}",
207 tool_name, path, version
208 );
209 return ToolStatus {
210 available: true,
211 path: Some(path),
212 execution_path: None, version,
214 installation_source: InstallationSource::SystemPath,
215 last_checked: SystemTime::now(),
216 };
217 }
218
219 if self.config.search_user_paths || self.config.search_system_paths {
221 let search_paths = self.get_tool_search_paths(tool_name);
222 debug!(
223 "Searching alternative paths for {}: {:?}",
224 tool_name, search_paths
225 );
226
227 for search_path in search_paths {
228 let tool_path = search_path.join(tool_name);
229 debug!("Checking path: {:?}", tool_path);
230
231 if let Some(version) = self.verify_tool_at_path(&tool_path, tool_name) {
232 let source = self.determine_installation_source(&search_path);
233 info!(
234 "Found {} at {:?} with version {:?} (source: {:?})",
235 tool_name, tool_path, version, source
236 );
237 return ToolStatus {
238 available: true,
239 path: Some(tool_path.clone()),
240 execution_path: Some(tool_path), version: Some(version),
242 installation_source: source,
243 last_checked: SystemTime::now(),
244 };
245 }
246
247 #[cfg(windows)]
249 {
250 let tool_path_exe = search_path.join(format!("{}.exe", tool_name));
251 if let Some(version) = self.verify_tool_at_path(&tool_path_exe, tool_name) {
252 let source = self.determine_installation_source(&search_path);
253 info!(
254 "Found {} at {:?} with version {:?} (source: {:?})",
255 tool_name, tool_path_exe, version, source
256 );
257 return ToolStatus {
258 available: true,
259 path: Some(tool_path_exe.clone()),
260 execution_path: Some(tool_path_exe), version: Some(version),
262 installation_source: source,
263 last_checked: SystemTime::now(),
264 };
265 }
266 }
267 }
268 }
269
270 debug!("Tool {} not found in any location", tool_name);
272 ToolStatus {
273 available: false,
274 path: None,
275 execution_path: None,
276 version: None,
277 installation_source: InstallationSource::NotFound,
278 last_checked: SystemTime::now(),
279 }
280 }
281
282 fn get_tool_search_paths(&self, tool_name: &str) -> Vec<PathBuf> {
284 let mut paths = Vec::new();
285
286 if !self.config.search_user_paths && !self.config.search_system_paths {
287 return paths;
288 }
289
290 if self.config.search_user_paths {
292 if let Ok(home) = std::env::var("HOME") {
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
304 #[cfg(windows)]
306 {
307 if let Ok(userprofile) = std::env::var("USERPROFILE") {
308 let userprofile_path = PathBuf::from(userprofile);
309 paths.push(userprofile_path.join(".local").join("bin"));
310 paths.push(userprofile_path.join("scoop").join("shims"));
311 paths.push(userprofile_path.join(".cargo").join("bin"));
312 paths.push(userprofile_path.join("go").join("bin"));
313 }
314 if let Ok(appdata) = std::env::var("APPDATA") {
315 paths.push(PathBuf::from(&appdata).join("syncable-cli").join("bin"));
316 paths.push(PathBuf::from(&appdata).join("npm"));
317 }
318 paths.push(PathBuf::from("C:\\Program Files"));
320 paths.push(PathBuf::from("C:\\Program Files (x86)"));
321 }
322 }
323
324 if self.config.search_system_paths {
326 paths.push(PathBuf::from("/usr/local/bin"));
327 paths.push(PathBuf::from("/usr/bin"));
328 paths.push(PathBuf::from("/bin"));
329 }
330
331 paths.sort();
333 paths.dedup();
334 paths.into_iter().filter(|p| p.exists()).collect()
335 }
336
337 fn add_tool_specific_paths(
338 &self,
339 tool_name: &str,
340 home_path: &PathBuf,
341 paths: &mut Vec<PathBuf>,
342 ) {
343 match tool_name {
344 "cargo-audit" => {
345 paths.push(home_path.join(".cargo").join("bin"));
346 }
347 "govulncheck" => {
348 paths.push(home_path.join("go").join("bin"));
349 if let Ok(gopath) = std::env::var("GOPATH") {
350 paths.push(PathBuf::from(gopath).join("bin"));
351 }
352 if let Ok(goroot) = std::env::var("GOROOT") {
353 paths.push(PathBuf::from(goroot).join("bin"));
354 }
355 }
356 "grype" => {
357 paths.push(home_path.join(".local").join("bin"));
358 paths.push(PathBuf::from("/opt/homebrew/bin"));
359 paths.push(PathBuf::from("/usr/local/bin"));
360 }
361 "pip-audit" => {
362 paths.push(home_path.join(".local").join("bin"));
363 if let Ok(output) = Command::new("python3")
364 .args(&["-m", "site", "--user-base"])
365 .output()
366 {
367 if let Ok(user_base) = String::from_utf8(output.stdout) {
368 paths.push(PathBuf::from(user_base.trim()).join("bin"));
369 }
370 }
371 if let Ok(output) = Command::new("python")
372 .args(&["-m", "site", "--user-base"])
373 .output()
374 {
375 if let Ok(user_base) = String::from_utf8(output.stdout) {
376 paths.push(PathBuf::from(user_base.trim()).join("bin"));
377 }
378 }
379 }
380 "bun" | "bunx" => {
381 paths.push(home_path.join(".bun").join("bin"));
382 paths.push(home_path.join(".npm-global").join("bin"));
383 paths.push(PathBuf::from("/opt/homebrew/bin"));
384 paths.push(PathBuf::from("/usr/local/bin"));
385 paths.push(home_path.join(".local").join("bin"));
386 }
387 "yarn" => {
388 paths.push(home_path.join(".yarn").join("bin"));
389 paths.push(home_path.join(".npm-global").join("bin"));
390 }
391 "pnpm" => {
392 paths.push(home_path.join(".local").join("share").join("pnpm"));
393 paths.push(home_path.join(".npm-global").join("bin"));
394 }
395 "npm" => {
396 if let Ok(node_path) = std::env::var("NODE_PATH") {
397 paths.push(PathBuf::from(node_path).join(".bin"));
398 }
399 paths.push(home_path.join(".npm-global").join("bin"));
400 paths.push(PathBuf::from("/usr/local/lib/node_modules/.bin"));
401 }
402 _ => {}
403 }
404 }
405
406 fn try_command_in_path(&self, tool_name: &str) -> Option<(PathBuf, Option<String>)> {
408 let version_args = self.get_version_args(tool_name);
409 debug!("Trying {} with args: {:?}", tool_name, version_args);
410
411 let output = Command::new(tool_name).args(&version_args).output().ok()?;
412
413 if output.status.success() {
414 let version = self.parse_version_output(&output.stdout, tool_name);
415 let path = self
416 .find_tool_path(tool_name)
417 .unwrap_or_else(|| PathBuf::from(tool_name));
418 return Some((path, version));
419 }
420
421 if !output.stderr.is_empty() {
423 if let Some(version) = self.parse_version_output(&output.stderr, tool_name) {
424 let path = self
425 .find_tool_path(tool_name)
426 .unwrap_or_else(|| PathBuf::from(tool_name));
427 return Some((path, Some(version)));
428 }
429 }
430
431 None
432 }
433
434 fn verify_tool_at_path(&self, tool_path: &Path, tool_name: &str) -> Option<String> {
436 if !tool_path.exists() {
437 return None;
438 }
439
440 let version_args = self.get_version_args(tool_name);
441 debug!(
442 "Verifying {} at {:?} with args: {:?}",
443 tool_name, tool_path, version_args
444 );
445
446 let output = Command::new(tool_path).args(&version_args).output().ok()?;
447
448 if output.status.success() {
449 self.parse_version_output(&output.stdout, tool_name)
450 } else if !output.stderr.is_empty() {
451 self.parse_version_output(&output.stderr, tool_name)
452 } else {
453 None
454 }
455 }
456
457 fn get_version_args(&self, tool_name: &str) -> Vec<&str> {
459 match tool_name {
460 "cargo-audit" => vec!["audit", "--version"],
461 "npm" => vec!["--version"],
462 "pip-audit" => vec!["--version"],
463 "govulncheck" => vec!["-version"],
464 "grype" => vec!["version"],
465 "dependency-check" => vec!["--version"],
466 "bun" => vec!["--version"],
467 "bunx" => vec!["--version"],
468 "yarn" => vec!["--version"],
469 "pnpm" => vec!["--version"],
470 _ => vec!["--version"],
471 }
472 }
473
474 fn parse_version_output(&self, output: &[u8], tool_name: &str) -> Option<String> {
476 let output_str = String::from_utf8_lossy(output);
477 debug!(
478 "Parsing version output for {}: {}",
479 tool_name,
480 output_str.trim()
481 );
482
483 match tool_name {
484 "cargo-audit" => {
485 for line in output_str.lines() {
486 if line.contains("cargo-audit") {
487 if let Some(version) = line.split_whitespace().nth(1) {
488 return Some(version.to_string());
489 }
490 }
491 }
492 }
493 "grype" => {
494 for line in output_str.lines() {
495 if line.trim_start().starts_with("grype") {
496 if let Some(version) = line.split_whitespace().nth(1) {
497 return Some(version.to_string());
498 }
499 }
500 if line.contains("\"version\"") {
501 if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
502 if let Some(version) = json.get("version").and_then(|v| v.as_str()) {
503 return Some(version.to_string());
504 }
505 }
506 }
507 }
508 }
509 "govulncheck" => {
510 for line in output_str.lines() {
511 if let Some(at_pos) = line.find('@') {
512 let version_part = &line[at_pos + 1..];
513 if let Some(version) = version_part.split_whitespace().next() {
514 return Some(version.trim_start_matches('v').to_string());
515 }
516 }
517 if line.contains("govulncheck") {
518 if let Some(version) = line.split_whitespace().nth(1) {
519 return Some(version.trim_start_matches('v').to_string());
520 }
521 }
522 }
523 }
524 "npm" | "yarn" | "pnpm" => {
525 if let Some(first_line) = output_str.lines().next() {
526 let version = first_line.trim();
527 if !version.is_empty() {
528 return Some(version.to_string());
529 }
530 }
531 }
532 "bun" | "bunx" => {
533 for line in output_str.lines() {
534 let line = line.trim();
535 if line.starts_with("bun ") {
536 if let Some(version) = line.split_whitespace().nth(1) {
537 return Some(version.to_string());
538 }
539 }
540 if let Some(version) = extract_version_generic(line) {
541 return Some(version);
542 }
543 }
544 }
545 "pip-audit" => {
546 for line in output_str.lines() {
547 if line.contains("pip-audit") {
548 if let Some(version) = line.split_whitespace().nth(1) {
549 return Some(version.to_string());
550 }
551 }
552 }
553 if let Some(version) = extract_version_generic(&output_str) {
554 return Some(version);
555 }
556 }
557 _ => {
558 if let Some(version) = extract_version_generic(&output_str) {
559 return Some(version);
560 }
561 }
562 }
563
564 None
565 }
566
567 fn determine_installation_source(&self, path: &Path) -> InstallationSource {
569 let path_str = path.to_string_lossy().to_lowercase();
570
571 if path_str.contains(".cargo") {
572 InstallationSource::CargoHome
573 } else if path_str.contains("go/bin") || path_str.contains("gopath") {
574 InstallationSource::GoHome
575 } else if path_str.contains(".local") {
576 InstallationSource::UserLocal
577 } else if path_str.contains("homebrew") || path_str.contains("brew") {
578 InstallationSource::PackageManager("brew".to_string())
579 } else if path_str.contains("scoop") {
580 InstallationSource::PackageManager("scoop".to_string())
581 } else if path_str.contains("apt") || path_str.contains("/usr/bin") {
582 InstallationSource::PackageManager("apt".to_string())
583 } else if path_str.contains("/usr/local")
584 || path_str.contains("/usr/bin")
585 || path_str.contains("/bin")
586 {
587 InstallationSource::SystemPath
588 } else {
589 InstallationSource::Manual
590 }
591 }
592
593 fn find_tool_path(&self, tool_name: &str) -> Option<PathBuf> {
595 #[cfg(unix)]
596 {
597 if let Ok(output) = Command::new("which").arg(tool_name).output() {
598 if output.status.success() {
599 let output_str = String::from_utf8_lossy(&output.stdout);
600 let path_str = output_str.trim();
601 if !path_str.is_empty() {
602 return Some(PathBuf::from(path_str));
603 }
604 }
605 }
606 }
607
608 #[cfg(windows)]
609 {
610 if let Ok(output) = Command::new("where").arg(tool_name).output() {
611 if output.status.success() {
612 let output_str = String::from_utf8_lossy(&output.stdout);
613 let path_str = output_str.trim();
614 if let Some(first_path) = path_str.lines().next() {
615 if !first_path.is_empty() {
616 return Some(PathBuf::from(first_path));
617 }
618 }
619 }
620 }
621 }
622
623 None
624 }
625}
626
627impl Default for ToolDetector {
628 fn default() -> Self {
629 Self::new()
630 }
631}
632
633fn extract_version_generic(text: &str) -> Option<String> {
635 use regex::Regex;
636
637 let patterns = vec![
638 r"\b(\d+\.\d+\.\d+(?:[+-][a-zA-Z0-9-.]+)?)\b",
639 r"\bv?(\d+\.\d+\.\d+)\b",
640 r"\b(\d+\.\d+)\b",
641 ];
642
643 for pattern in patterns {
644 if let Ok(re) = Regex::new(pattern) {
645 if let Some(captures) = re.captures(text) {
646 if let Some(version) = captures.get(1) {
647 let version_str = version.as_str();
648 if !version_str.starts_with("127.") && !version_str.starts_with("192.") {
649 return Some(version_str.to_string());
650 }
651 }
652 }
653 }
654 }
655
656 None
657}