1use crate::authorities::Category;
7use crate::config::Config;
8use crate::detector::Finding;
9use crate::scanner;
10use colored::Colorize;
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14pub struct DiffResult {
18 pub added: Vec<Finding>,
19 pub removed: Vec<Finding>,
20 pub unchanged: usize,
21}
22
23pub struct CrateSpec {
25 pub name: String,
26 pub version: String,
27}
28
29pub struct DiffOptions {
31 pub left: String,
32 pub right: String,
33 pub format: String,
34 pub fail_on_new: bool,
35}
36
37pub struct CompareOptions {
39 pub left: String,
40 pub right: String,
41 pub format: String,
42}
43
44pub fn run_diff(opts: DiffOptions) {
48 let cap_root = capsec_core::root::root();
49 let fs_read = cap_root.grant::<capsec_core::permission::FsRead>();
50 let spawn_cap = cap_root.grant::<capsec_core::permission::Spawn>();
51
52 let left = parse_crate_spec(&opts.left).unwrap_or_else(|e| {
53 eprintln!("Error: {e}");
54 std::process::exit(1);
55 });
56 let right = parse_crate_spec(&opts.right).unwrap_or_else(|e| {
57 eprintln!("Error: {e}");
58 std::process::exit(1);
59 });
60
61 eprintln!("Fetching {} v{}...", left.name, left.version);
62 let left_dir = fetch_crate_source(&left.name, &left.version, &spawn_cap, &fs_read)
63 .unwrap_or_else(|e| {
64 eprintln!("Error fetching {} v{}: {e}", left.name, left.version);
65 std::process::exit(1);
66 });
67
68 eprintln!("Fetching {} v{}...", right.name, right.version);
69 let right_dir = fetch_crate_source(&right.name, &right.version, &spawn_cap, &fs_read)
70 .unwrap_or_else(|e| {
71 eprintln!("Error fetching {} v{}: {e}", right.name, right.version);
72 std::process::exit(1);
73 });
74
75 eprintln!("Scanning...");
76 let config = Config::default();
77 let left_findings =
78 scanner::scan_crate(&left_dir, &left.name, &left.version, &config, &fs_read);
79 let right_findings =
80 scanner::scan_crate(&right_dir, &right.name, &right.version, &config, &fs_read);
81
82 let result = diff_findings(&left_findings, &right_findings);
83
84 match opts.format.as_str() {
85 "json" => print_diff_json(&left, &right, &result),
86 _ => print_diff_text(&left, &right, &result),
87 }
88
89 if opts.fail_on_new && !result.added.is_empty() {
90 std::process::exit(1);
91 }
92}
93
94pub fn run_compare(opts: CompareOptions) {
96 let cap_root = capsec_core::root::root();
97 let fs_read = cap_root.grant::<capsec_core::permission::FsRead>();
98 let spawn_cap = cap_root.grant::<capsec_core::permission::Spawn>();
99
100 let mut left = parse_crate_spec_or_latest(&opts.left);
101 let mut right = parse_crate_spec_or_latest(&opts.right);
102
103 eprintln!("Fetching {}...", left.name);
104 let left_dir = fetch_crate_source(&left.name, &left.version, &spawn_cap, &fs_read)
105 .unwrap_or_else(|e| {
106 eprintln!("Error: {e}");
107 std::process::exit(1);
108 });
109 resolve_version_from_path(&mut left, &left_dir);
110
111 eprintln!("Fetching {}...", right.name);
112 let right_dir = fetch_crate_source(&right.name, &right.version, &spawn_cap, &fs_read)
113 .unwrap_or_else(|e| {
114 eprintln!("Error: {e}");
115 std::process::exit(1);
116 });
117 resolve_version_from_path(&mut right, &right_dir);
118
119 eprintln!("Scanning...\n");
120 let config = Config::default();
121 let left_findings =
122 scanner::scan_crate(&left_dir, &left.name, &left.version, &config, &fs_read);
123 let right_findings =
124 scanner::scan_crate(&right_dir, &right.name, &right.version, &config, &fs_read);
125
126 match opts.format.as_str() {
127 "json" => print_compare_json(&left, &right, &left_findings, &right_findings),
128 _ => print_compare_text(&left, &right, &left_findings, &right_findings),
129 }
130}
131
132fn fetch_crate_source(
137 crate_name: &str,
138 version: &str,
139 spawn_cap: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::Spawn>,
140 _fs_read: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::FsRead>,
141) -> Result<PathBuf, String> {
142 if let Some(cached) = find_registry_source(crate_name, version) {
144 return Ok(cached);
145 }
146
147 let temp_dir = std::env::temp_dir().join(format!("capsec-fetch-{crate_name}-{version}"));
149 let _ = std::fs::create_dir_all(&temp_dir);
150
151 let version_spec = if version == "*" {
152 format!("\"{version}\"")
153 } else {
154 format!("\"={version}\"")
155 };
156 let cargo_toml = format!(
157 "[package]\nname = \"capsec-fetch-temp\"\nversion = \"0.0.1\"\nedition = \"2021\"\n\n[dependencies]\n{crate_name} = {version_spec}\n"
158 );
159 std::fs::write(temp_dir.join("Cargo.toml"), cargo_toml)
160 .map_err(|e| format!("Failed to write temp Cargo.toml: {e}"))?;
161
162 let _ = std::fs::create_dir_all(temp_dir.join("src"));
164 std::fs::write(temp_dir.join("src/lib.rs"), "")
165 .map_err(|e| format!("Failed to write temp lib.rs: {e}"))?;
166
167 let output = capsec_std::process::command("cargo", spawn_cap)
169 .map_err(|e| format!("Failed to create command: {e}"))?
170 .arg("fetch")
171 .current_dir(&temp_dir)
172 .output()
173 .map_err(|e| format!("Failed to run cargo fetch: {e}"))?;
174
175 if !output.status.success() {
176 let stderr = String::from_utf8_lossy(&output.stderr);
177 return Err(format!("cargo fetch failed: {stderr}"));
178 }
179
180 let _ = std::fs::remove_dir_all(&temp_dir);
182
183 find_registry_source(crate_name, version).ok_or_else(|| {
185 format!("Crate {crate_name}@{version} not found in registry cache after fetch")
186 })
187}
188
189fn find_registry_source(crate_name: &str, version: &str) -> Option<PathBuf> {
192 let home = std::env::var("CARGO_HOME").unwrap_or_else(|_| {
193 std::env::var("HOME")
194 .map(|h| format!("{h}/.cargo"))
195 .unwrap_or_default()
196 });
197 let registry_src = Path::new(&home).join("registry/src");
198
199 if !registry_src.exists() {
200 return None;
201 }
202
203 let entries = std::fs::read_dir(®istry_src).ok()?;
204 for index_dir in entries.flatten() {
205 if version == "*" {
206 let prefix = format!("{crate_name}-");
208 if let Ok(crate_dirs) = std::fs::read_dir(index_dir.path()) {
209 let mut matches: Vec<_> = crate_dirs
210 .flatten()
211 .filter(|e| {
212 e.file_name()
213 .to_str()
214 .is_some_and(|n| n.starts_with(&prefix))
215 })
216 .collect();
217 matches.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
218 if let Some(best) = matches.first() {
219 let src_dir = best.path().join("src");
220 if src_dir.exists() {
221 return Some(src_dir);
222 }
223 return Some(best.path());
224 }
225 }
226 } else {
227 let crate_dir = index_dir.path().join(format!("{crate_name}-{version}"));
228 if crate_dir.exists() {
229 let src_dir = crate_dir.join("src");
230 if src_dir.exists() {
231 return Some(src_dir);
232 }
233 return Some(crate_dir);
234 }
235 }
236 }
237
238 None
239}
240
241fn diff_findings(old: &[Finding], new: &[Finding]) -> DiffResult {
246 type Key = (String, String, String);
247
248 fn finding_key(f: &Finding) -> Key {
249 (
250 f.function.clone(),
251 f.call_text.clone(),
252 f.category.label().to_string(),
253 )
254 }
255
256 let old_keys: HashSet<Key> = old.iter().map(finding_key).collect();
257 let new_keys: HashSet<Key> = new.iter().map(finding_key).collect();
258
259 let added: Vec<Finding> = new
260 .iter()
261 .filter(|f| !old_keys.contains(&finding_key(f)))
262 .cloned()
263 .collect();
264
265 let removed: Vec<Finding> = old
266 .iter()
267 .filter(|f| !new_keys.contains(&finding_key(f)))
268 .cloned()
269 .collect();
270
271 let unchanged = new_keys.intersection(&old_keys).count();
272
273 DiffResult {
274 added,
275 removed,
276 unchanged,
277 }
278}
279
280fn resolve_version_from_path(spec: &mut CrateSpec, dir: &Path) {
285 if spec.version != "*" {
286 return;
287 }
288 let crate_dir = if dir.ends_with("src") {
290 dir.parent()
291 } else {
292 Some(dir)
293 };
294 if let Some(dir_name) = crate_dir
295 .and_then(|d| d.file_name())
296 .and_then(|n| n.to_str())
297 {
298 let prefix = format!("{}-", spec.name);
299 if let Some(ver) = dir_name.strip_prefix(&prefix) {
300 spec.version = ver.to_string();
301 }
302 }
303}
304
305fn parse_crate_spec(spec: &str) -> Result<CrateSpec, String> {
307 let parts: Vec<&str> = spec.splitn(2, '@').collect();
308 if parts.len() != 2 || parts[1].is_empty() {
309 return Err(format!(
310 "Invalid crate specifier '{spec}'. Expected format: crate_name@version"
311 ));
312 }
313 Ok(CrateSpec {
314 name: parts[0].to_string(),
315 version: parts[1].to_string(),
316 })
317}
318
319fn parse_crate_spec_or_latest(spec: &str) -> CrateSpec {
321 if let Ok(parsed) = parse_crate_spec(spec) {
322 parsed
323 } else {
324 CrateSpec {
326 name: spec.to_string(),
327 version: "*".to_string(),
328 }
329 }
330}
331
332fn print_diff_text(left: &CrateSpec, right: &CrateSpec, result: &DiffResult) {
335 println!(
336 "\n{} {} \u{2192} {}",
337 left.name.bold(),
338 left.version.dimmed(),
339 right.version.bold()
340 );
341 let sep_len = left.name.len() + left.version.len() + right.version.len() + 4;
342 println!("{}", "\u{2500}".repeat(sep_len));
343
344 for f in &result.added {
345 println!(
346 " {} {:<5} {}:{}:{} {:<28} {}()",
347 "+".green().bold(),
348 colorize_category(&f.category),
349 f.file.dimmed(),
350 f.call_line,
351 f.call_col,
352 f.call_text.bold(),
353 f.function,
354 );
355 }
356 for f in &result.removed {
357 println!(
358 " {} {:<5} {}:{}:{} {:<28} {}()",
359 "-".red().bold(),
360 colorize_category(&f.category),
361 f.file.dimmed(),
362 f.call_line,
363 f.call_col,
364 f.call_text.bold(),
365 f.function,
366 );
367 }
368
369 println!(
370 "\n{}: {} added, {} removed, {} unchanged",
371 "Summary".bold(),
372 result.added.len(),
373 result.removed.len(),
374 result.unchanged,
375 );
376}
377
378fn print_diff_json(left: &CrateSpec, right: &CrateSpec, result: &DiffResult) {
379 let json = serde_json::json!({
380 "left": { "name": left.name, "version": left.version },
381 "right": { "name": right.name, "version": right.version },
382 "added": result.added.len(),
383 "removed": result.removed.len(),
384 "unchanged": result.unchanged,
385 "findings_added": result.added,
386 "findings_removed": result.removed,
387 });
388 println!(
389 "{}",
390 serde_json::to_string_pretty(&json).unwrap_or_default()
391 );
392}
393
394fn print_compare_text(
395 left: &CrateSpec,
396 right: &CrateSpec,
397 left_findings: &[Finding],
398 right_findings: &[Finding],
399) {
400 fn count_by_cat(findings: &[Finding]) -> (usize, usize, usize, usize, usize) {
401 let mut fs = 0;
402 let mut net = 0;
403 let mut env = 0;
404 let mut proc_ = 0;
405 let mut ffi = 0;
406 for f in findings {
407 match f.category {
408 Category::Fs => fs += 1,
409 Category::Net => net += 1,
410 Category::Env => env += 1,
411 Category::Process => proc_ += 1,
412 Category::Ffi => ffi += 1,
413 }
414 }
415 (fs, net, env, proc_, ffi)
416 }
417
418 let (lfs, lnet, lenv, lproc, lffi) = count_by_cat(left_findings);
419 let (rfs, rnet, renv, rproc, rffi) = count_by_cat(right_findings);
420
421 let left_header = format!("{} v{}", left.name, left.version);
422 let right_header = format!("{} v{}", right.name, right.version);
423
424 println!("\n{:<30} {}", left_header.bold(), right_header.bold());
425 println!(
426 "{:<30} {}",
427 "\u{2500}".repeat(left_header.len()),
428 "\u{2500}".repeat(right_header.len())
429 );
430 println!(
431 "{:<30} {}",
432 format!("FS: {lfs}").blue(),
433 format!("FS: {rfs}").blue()
434 );
435 println!(
436 "{:<30} {}",
437 format!("NET: {lnet}").red(),
438 format!("NET: {rnet}").red()
439 );
440 println!(
441 "{:<30} {}",
442 format!("ENV: {lenv}").yellow(),
443 format!("ENV: {renv}").yellow()
444 );
445 println!(
446 "{:<30} {}",
447 format!("PROC: {lproc}").magenta(),
448 format!("PROC: {rproc}").magenta()
449 );
450 println!(
451 "{:<30} {}",
452 format!("FFI: {lffi}").cyan(),
453 format!("FFI: {rffi}").cyan()
454 );
455 println!(
456 "{:<30} {}",
457 format!("Total: {}", left_findings.len()).bold(),
458 format!("Total: {}", right_findings.len()).bold()
459 );
460}
461
462fn print_compare_json(
463 left: &CrateSpec,
464 right: &CrateSpec,
465 left_findings: &[Finding],
466 right_findings: &[Finding],
467) {
468 let json = serde_json::json!({
469 "left": {
470 "name": left.name,
471 "version": left.version,
472 "total": left_findings.len(),
473 "findings": left_findings,
474 },
475 "right": {
476 "name": right.name,
477 "version": right.version,
478 "total": right_findings.len(),
479 "findings": right_findings,
480 },
481 });
482 println!(
483 "{}",
484 serde_json::to_string_pretty(&json).unwrap_or_default()
485 );
486}
487
488fn colorize_category(cat: &Category) -> colored::ColoredString {
489 let label = cat.label();
490 match cat {
491 Category::Fs => label.blue(),
492 Category::Net => label.red(),
493 Category::Env => label.yellow(),
494 Category::Process => label.magenta(),
495 Category::Ffi => label.cyan(),
496 }
497}