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