1use crate::analyzer::dependency_parser::Language;
2use crate::error::{AnalysisError, IaCGeneratorError, Result};
3use log::{info, warn, debug};
4use std::process::Command;
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8pub struct ToolInstaller {
10 installed_tools: HashMap<String, bool>,
11}
12
13impl ToolInstaller {
14 pub fn new() -> Self {
15 Self {
16 installed_tools: HashMap::new(),
17 }
18 }
19
20 pub fn ensure_tools_for_languages(&mut self, languages: &[Language]) -> Result<()> {
22 for language in languages {
23 match language {
24 Language::Rust => self.ensure_cargo_audit()?,
25 Language::JavaScript | Language::TypeScript => self.ensure_npm()?,
26 Language::Python => self.ensure_pip_audit()?,
27 Language::Go => self.ensure_govulncheck()?,
28 Language::Java | Language::Kotlin => self.ensure_grype()?,
29 _ => {} }
31 }
32 Ok(())
33 }
34
35 fn ensure_cargo_audit(&mut self) -> Result<()> {
37 if self.is_tool_installed("cargo-audit") {
38 return Ok(());
39 }
40
41 info!("š§ Installing cargo-audit for Rust vulnerability scanning...");
42
43 let output = Command::new("cargo")
44 .args(&["install", "cargo-audit"])
45 .output()
46 .map_err(|e| IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
47 file: "cargo-audit installation".to_string(),
48 reason: format!("Failed to install cargo-audit: {}", e),
49 }))?;
50
51 if output.status.success() {
52 info!("ā
cargo-audit installed successfully");
53 self.installed_tools.insert("cargo-audit".to_string(), true);
54 } else {
55 let stderr = String::from_utf8_lossy(&output.stderr);
56 warn!("ā Failed to install cargo-audit: {}", stderr);
57 return Err(IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
58 file: "cargo-audit installation".to_string(),
59 reason: format!("Installation failed: {}", stderr),
60 }));
61 }
62
63 Ok(())
64 }
65
66 fn ensure_npm(&mut self) -> Result<()> {
68 if self.is_tool_installed("npm") {
69 return Ok(());
70 }
71
72 warn!("š¦ npm not found. Please install Node.js from https://nodejs.org/");
73 warn!(" npm audit is required for JavaScript/TypeScript vulnerability scanning");
74
75 Ok(()) }
77
78 fn ensure_pip_audit(&mut self) -> Result<()> {
80 if self.is_tool_installed("pip-audit") {
81 return Ok(());
82 }
83
84 info!("š§ Installing pip-audit for Python vulnerability scanning...");
85
86 let install_commands = vec![
88 vec!["pipx", "install", "pip-audit"],
89 vec!["pip3", "install", "--user", "pip-audit"],
90 vec!["pip", "install", "--user", "pip-audit"],
91 ];
92
93 for cmd in install_commands {
94 debug!("Trying installation command: {:?}", cmd);
95
96 let output = Command::new(&cmd[0])
97 .args(&cmd[1..])
98 .output();
99
100 if let Ok(result) = output {
101 if result.status.success() {
102 info!("ā
pip-audit installed successfully using {}", cmd[0]);
103 self.installed_tools.insert("pip-audit".to_string(), true);
104 return Ok(());
105 }
106 }
107 }
108
109 warn!("š¦ Failed to auto-install pip-audit. Please install manually:");
110 warn!(" Option 1: pipx install pip-audit");
111 warn!(" Option 2: pip3 install --user pip-audit");
112
113 Ok(()) }
115
116 fn ensure_govulncheck(&mut self) -> Result<()> {
118 if self.is_tool_installed("govulncheck") {
119 return Ok(());
120 }
121
122 info!("š§ Installing govulncheck for Go vulnerability scanning...");
123
124 let output = Command::new("go")
125 .args(&["install", "golang.org/x/vuln/cmd/govulncheck@latest"])
126 .output()
127 .map_err(|e| IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
128 file: "govulncheck installation".to_string(),
129 reason: format!("Failed to install govulncheck (is Go installed?): {}", e),
130 }))?;
131
132 if output.status.success() {
133 info!("ā
govulncheck installed successfully");
134 self.installed_tools.insert("govulncheck".to_string(), true);
135
136 info!("š” Note: Make sure ~/go/bin is in your PATH to use govulncheck");
138 } else {
139 let stderr = String::from_utf8_lossy(&output.stderr);
140 warn!("ā Failed to install govulncheck: {}", stderr);
141 warn!("š¦ Please install Go from https://golang.org/ first");
142 }
143
144 Ok(())
145 }
146
147 fn ensure_grype(&mut self) -> Result<()> {
149 if self.is_tool_installed("grype") {
150 return Ok(());
151 }
152
153 info!("š§ Installing grype for vulnerability scanning...");
154
155 let os = std::env::consts::OS;
157 let arch = std::env::consts::ARCH;
158
159 match os {
161 "macos" => {
162 let output = Command::new("brew")
164 .args(&["install", "grype"])
165 .output();
166
167 match output {
168 Ok(result) if result.status.success() => {
169 info!("ā
grype installed successfully via Homebrew");
170 self.installed_tools.insert("grype".to_string(), true);
171 return Ok(());
172 }
173 _ => {
174 warn!("ā Failed to install via Homebrew. Trying manual installation...");
175 }
176 }
177 }
178 _ => {}
179 }
180
181 self.install_grype_manually(os, arch)
183 }
184
185 fn install_grype_manually(&mut self, os: &str, arch: &str) -> Result<()> {
187 use std::fs;
188 use std::path::PathBuf;
189
190 info!("š„ Downloading grype from GitHub releases...");
191
192 let version = "v0.92.2"; let bin_dir = if cfg!(windows) {
196 let home_dir = std::env::var("USERPROFILE")
198 .or_else(|_| std::env::var("APPDATA"))
199 .unwrap_or_else(|_| ".".to_string());
200 PathBuf::from(&home_dir).join(".local").join("bin")
201 } else {
202 let home_dir = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
204 PathBuf::from(&home_dir).join(".local").join("bin")
205 };
206
207 fs::create_dir_all(&bin_dir).map_err(|e| {
209 IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
210 file: "grype installation".to_string(),
211 reason: format!("Failed to create directory: {}", e),
212 })
213 })?;
214
215 let (os_name, arch_name, file_extension) = match (os, arch) {
217 ("macos", "x86_64") => ("darwin", "amd64", ""),
218 ("macos", "aarch64") => ("darwin", "arm64", ""),
219 ("linux", "x86_64") => ("linux", "amd64", ""),
220 ("linux", "aarch64") => ("linux", "arm64", ""),
221 ("windows", "x86_64") => ("windows", "amd64", ".exe"),
222 ("windows", "aarch64") => ("windows", "arm64", ".exe"),
223 _ => {
224 warn!("ā Unsupported platform: {} {}", os, arch);
225 return Ok(());
226 }
227 };
228
229 let (archive_name, download_url) = if cfg!(windows) {
231 let archive_name = format!("grype_{}_windows_{}.zip", version.trim_start_matches('v'), arch_name);
232 let download_url = format!(
233 "https://github.com/anchore/grype/releases/download/{}/{}",
234 version, archive_name
235 );
236 (archive_name, download_url)
237 } else {
238 let archive_name = format!("grype_{}_{}.tar.gz", os_name, arch_name);
239 let download_url = format!(
240 "https://github.com/anchore/grype/releases/download/{}/grype_{}_{}_{}.tar.gz",
241 version, version.trim_start_matches('v'), os_name, arch_name
242 );
243 (archive_name, download_url)
244 };
245
246 let archive_path = bin_dir.join(&archive_name);
247 let grype_binary = bin_dir.join(format!("grype{}", file_extension));
248
249 info!("š¦ Downloading from: {}", download_url);
250
251 let download_success = if cfg!(windows) {
253 self.download_file_windows(&download_url, &archive_path)
255 } else {
256 self.download_file_unix(&download_url, &archive_path)
258 };
259
260 if download_success {
261 info!("ā
Download complete. Extracting...");
262
263 let extract_success = if cfg!(windows) {
264 self.extract_zip_windows(&archive_path, &bin_dir)
265 } else {
266 self.extract_tar_unix(&archive_path, &bin_dir)
267 };
268
269 if extract_success {
270 info!("ā
grype installed successfully to {}", bin_dir.display());
271 if cfg!(windows) {
272 info!("š” Make sure {} is in your PATH", bin_dir.display());
273 } else {
274 info!("š” Make sure ~/.local/bin is in your PATH");
275 }
276 self.installed_tools.insert("grype".to_string(), true);
277
278 fs::remove_file(&archive_path).ok();
280
281 return Ok(());
282 }
283 }
284
285 warn!("ā Automatic installation failed. Please install manually:");
286 if cfg!(windows) {
287 warn!(" ⢠Download from: https://github.com/anchore/grype/releases");
288 warn!(" ⢠Or use: scoop install grype (if you have Scoop)");
289 } else {
290 warn!(" ⢠macOS: brew install grype");
291 warn!(" ⢠Download: https://github.com/anchore/grype/releases");
292 }
293
294 Ok(())
295 }
296
297 fn download_file_windows(&self, url: &str, output_path: &PathBuf) -> bool {
299 use std::process::Command;
300
301 let powershell_result = Command::new("powershell")
303 .args(&[
304 "-Command",
305 &format!(
306 "Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing",
307 url,
308 output_path.to_string_lossy()
309 )
310 ])
311 .output();
312
313 if let Ok(result) = powershell_result {
314 if result.status.success() {
315 return true;
316 }
317 }
318
319 let curl_result = Command::new("curl")
321 .args(&["-L", "-o", &output_path.to_string_lossy(), url])
322 .output();
323
324 curl_result.map(|o| o.status.success()).unwrap_or(false)
325 }
326
327 fn download_file_unix(&self, url: &str, output_path: &PathBuf) -> bool {
329 use std::process::Command;
330
331 let output = Command::new("curl")
332 .args(&["-L", "-o", &output_path.to_string_lossy(), url])
333 .output();
334
335 output.map(|o| o.status.success()).unwrap_or(false)
336 }
337
338 fn extract_zip_windows(&self, archive_path: &PathBuf, extract_dir: &PathBuf) -> bool {
340 use std::process::Command;
341
342 let powershell_result = Command::new("powershell")
344 .args(&[
345 "-Command",
346 &format!(
347 "Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
348 archive_path.to_string_lossy(),
349 extract_dir.to_string_lossy()
350 )
351 ])
352 .output();
353
354 if let Ok(result) = powershell_result {
355 if result.status.success() {
356 return true;
357 }
358 }
359
360 let tar_result = Command::new("tar")
362 .args(&["-xf", &archive_path.to_string_lossy(), "-C", &extract_dir.to_string_lossy()])
363 .output();
364
365 tar_result.map(|o| o.status.success()).unwrap_or(false)
366 }
367
368 fn extract_tar_unix(&self, archive_path: &PathBuf, extract_dir: &PathBuf) -> bool {
370 use std::process::Command;
371
372 let extract_output = Command::new("tar")
373 .args(&["-xzf", &archive_path.to_string_lossy(), "-C", &extract_dir.to_string_lossy()])
374 .output();
375
376 if let Ok(result) = extract_output {
377 if result.status.success() {
378 #[cfg(unix)]
380 {
381 let grype_path = extract_dir.join("grype");
382 Command::new("chmod")
383 .args(&["+x", &grype_path.to_string_lossy()])
384 .output()
385 .ok();
386 }
387 return true;
388 }
389 }
390
391 false
392 }
393
394 fn ensure_dependency_check(&mut self) -> Result<()> {
396 if self.is_tool_installed("dependency-check") {
397 return Ok(());
398 }
399
400 info!("š§ Installing dependency-check for Java/Kotlin vulnerability scanning...");
401
402 let os = std::env::consts::OS;
404
405 match os {
406 "macos" => {
407 let output = Command::new("brew")
409 .args(&["install", "dependency-check"])
410 .output();
411
412 match output {
413 Ok(result) if result.status.success() => {
414 info!("ā
dependency-check installed successfully via Homebrew");
415 self.installed_tools.insert("dependency-check".to_string(), true);
416 return Ok(());
417 }
418 _ => {
419 warn!("ā Failed to install via Homebrew. Trying manual installation...");
420 }
421 }
422 }
423 "linux" => {
424 let output = Command::new("snap")
426 .args(&["install", "dependency-check"])
427 .output();
428
429 if output.map(|o| o.status.success()).unwrap_or(false) {
430 info!("ā
dependency-check installed successfully via snap");
431 self.installed_tools.insert("dependency-check".to_string(), true);
432 return Ok(());
433 }
434 }
435 _ => {}
436 }
437
438 self.install_dependency_check_manually()
440 }
441
442 fn install_dependency_check_manually(&mut self) -> Result<()> {
444 use std::fs;
445 use std::path::PathBuf;
446
447 info!("š„ Downloading dependency-check from GitHub releases...");
448
449 let version = "11.1.0"; let (home_dir, install_dir) = if cfg!(windows) {
453 let home = std::env::var("USERPROFILE")
454 .or_else(|_| std::env::var("APPDATA"))
455 .unwrap_or_else(|_| ".".to_string());
456 let install = PathBuf::from(&home).join("dependency-check");
457 (home, install)
458 } else {
459 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
460 let install = PathBuf::from(&home).join(".local").join("share").join("dependency-check");
461 (home, install)
462 };
463
464 fs::create_dir_all(&install_dir).map_err(|e| {
466 IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
467 file: "dependency-check installation".to_string(),
468 reason: format!("Failed to create directory: {}", e),
469 })
470 })?;
471
472 let archive_name = "dependency-check-11.1.0-release.zip";
473 let download_url = format!(
474 "https://github.com/jeremylong/DependencyCheck/releases/download/v{}/{}",
475 version, archive_name
476 );
477
478 let archive_path = install_dir.join(archive_name);
479
480 info!("š¦ Downloading from: {}", download_url);
481
482 let download_success = if cfg!(windows) {
484 self.download_file_windows(&download_url, &archive_path)
485 } else {
486 self.download_file_unix(&download_url, &archive_path)
487 };
488
489 if download_success {
490 info!("ā
Download complete. Extracting...");
491
492 let extract_success = if cfg!(windows) {
493 self.extract_zip_windows(&archive_path, &install_dir)
494 } else {
495 let output = std::process::Command::new("unzip")
497 .args(&["-o", &archive_path.to_string_lossy(), "-d", &install_dir.to_string_lossy()])
498 .output();
499 output.map(|o| o.status.success()).unwrap_or(false)
500 };
501
502 if extract_success {
503 if cfg!(windows) {
505 self.create_windows_launcher(&install_dir, &home_dir)?;
506 } else {
507 self.create_unix_launcher(&install_dir, &home_dir)?;
508 }
509
510 info!("ā
dependency-check installed successfully to {}", install_dir.display());
511 self.installed_tools.insert("dependency-check".to_string(), true);
512
513 fs::remove_file(&archive_path).ok();
515 return Ok(());
516 }
517 }
518
519 warn!("ā Automatic installation failed. Please install manually:");
520 if cfg!(windows) {
521 warn!(" ⢠Download: https://github.com/jeremylong/DependencyCheck/releases");
522 warn!(" ⢠Or use: scoop install dependency-check (if you have Scoop)");
523 } else {
524 warn!(" ⢠macOS: brew install dependency-check");
525 warn!(" ⢠Download: https://github.com/jeremylong/DependencyCheck/releases");
526 }
527
528 Ok(())
529 }
530
531 fn create_windows_launcher(&self, install_dir: &PathBuf, home_dir: &str) -> Result<()> {
533 use std::fs;
534
535 let bin_dir = PathBuf::from(home_dir).join(".local").join("bin");
536 fs::create_dir_all(&bin_dir).ok();
537
538 let dc_script = install_dir.join("dependency-check").join("bin").join("dependency-check.bat");
539 let launcher_path = bin_dir.join("dependency-check.bat");
540
541 let launcher_content = format!(
543 "@echo off\n\"{}\" %*\n",
544 dc_script.to_string_lossy()
545 );
546
547 fs::write(&launcher_path, launcher_content).map_err(|e| {
548 IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
549 file: "dependency-check launcher".to_string(),
550 reason: format!("Failed to create launcher: {}", e),
551 })
552 })?;
553
554 info!("š” Added to {}", launcher_path.display());
555 info!("š” Make sure {} is in your PATH", bin_dir.display());
556
557 Ok(())
558 }
559
560 fn create_unix_launcher(&self, install_dir: &PathBuf, home_dir: &str) -> Result<()> {
562 use std::fs;
563
564 let bin_dir = PathBuf::from(home_dir).join(".local").join("bin");
565 fs::create_dir_all(&bin_dir).ok();
566
567 let dc_script = install_dir.join("dependency-check").join("bin").join("dependency-check.sh");
568 let symlink = bin_dir.join("dependency-check");
569
570 fs::remove_file(&symlink).ok();
572
573 #[cfg(unix)]
575 {
576 if std::os::unix::fs::symlink(&dc_script, &symlink).is_ok() {
577 info!("š” Added to ~/.local/bin/dependency-check");
578 info!("š” Make sure ~/.local/bin is in your PATH");
579 return Ok(());
580 }
581 }
582
583 let wrapper_content = format!(
585 "#!/bin/bash\nexec \"{}\" \"$@\"\n",
586 dc_script.to_string_lossy()
587 );
588
589 fs::write(&symlink, wrapper_content).map_err(|e| {
590 IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
591 file: "dependency-check wrapper".to_string(),
592 reason: format!("Failed to create wrapper: {}", e),
593 })
594 })?;
595
596 #[cfg(unix)]
598 {
599 use std::process::Command;
600 Command::new("chmod")
601 .args(&["+x", &symlink.to_string_lossy()])
602 .output()
603 .ok();
604 }
605
606 Ok(())
607 }
608
609 fn is_tool_installed(&self, tool: &str) -> bool {
611 use std::process::Command;
612
613 if let Some(&cached) = self.installed_tools.get(tool) {
615 return cached;
616 }
617
618 let version_arg = match tool {
620 "grype" => "version",
621 "cargo-audit" => "--version",
622 "pip-audit" => "--version",
623 "govulncheck" => "-version",
624 "dependency-check" => "--version",
625 _ => "--version",
626 };
627
628 let result = Command::new(tool)
629 .arg(version_arg)
630 .output();
631
632 match result {
633 Ok(output) => output.status.success(),
634 Err(_) => {
635 self.try_alternative_paths(tool, version_arg)
637 }
638 }
639 }
640
641 fn try_alternative_paths(&self, tool: &str, version_arg: &str) -> bool {
643 use std::process::Command;
644
645 let alternative_paths = if cfg!(windows) {
646 let userprofile = std::env::var("USERPROFILE").unwrap_or_default();
648 let appdata = std::env::var("APPDATA").unwrap_or_default();
649 vec![
650 format!("{}/.local/bin/{}.exe", userprofile, tool),
651 format!("{}/syncable-cli/bin/{}.exe", appdata, tool),
652 format!("C:/Program Files/{}/{}.exe", tool, tool),
653 ]
654 } else {
655 let home = std::env::var("HOME").unwrap_or_default();
657 vec![
658 format!("{}/go/bin/{}", home, tool),
659 format!("{}/.local/bin/{}", home, tool),
660 format!("{}/.cargo/bin/{}", home, tool),
661 ]
662 };
663
664 for path in alternative_paths {
665 if let Ok(output) = Command::new(&path).arg(version_arg).output() {
666 if output.status.success() {
667 return true;
668 }
669 }
670 }
671
672 false
673 }
674
675 pub fn test_tool_availability(&self, tool: &str) -> bool {
677 self.is_tool_installed(tool)
678 }
679
680 pub fn get_tool_status(&self) -> HashMap<String, bool> {
682 self.installed_tools.clone()
683 }
684
685 pub fn print_tool_status(&self, languages: &[Language]) {
687 println!("\nš§ Vulnerability Scanning Tools Status:");
688 println!("{}", "=".repeat(50));
689
690 for language in languages {
691 let (tool, status) = match language {
692 Language::Rust => ("cargo-audit", self.installed_tools.get("cargo-audit").unwrap_or(&false)),
693 Language::JavaScript | Language::TypeScript => ("npm", self.installed_tools.get("npm").unwrap_or(&false)),
694 Language::Python => ("pip-audit", self.installed_tools.get("pip-audit").unwrap_or(&false)),
695 Language::Go => ("govulncheck", self.installed_tools.get("govulncheck").unwrap_or(&false)),
696 Language::Java | Language::Kotlin => ("grype", self.installed_tools.get("grype").unwrap_or(&false)),
697 _ => continue,
698 };
699
700 let status_icon = if *status { "ā
" } else { "ā" };
701 println!(" {} {:?}: {} {}", status_icon, language, tool, if *status { "installed" } else { "missing" });
702 }
703 println!();
704 }
705}