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