Skip to main content

cargo_capsec/
diff.rs

1//! Dependency version diffing and cross-crate comparison.
2//!
3//! `cargo capsec diff crate@v1 crate@v2` — what new authority did a version bump introduce?
4//! `cargo capsec compare crate_a crate_b` — which crate has less ambient authority?
5
6use 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
14// ── Types ──
15
16/// Result of diffing findings between two crate versions.
17pub struct DiffResult {
18    pub added: Vec<Finding>,
19    pub removed: Vec<Finding>,
20    pub unchanged: usize,
21}
22
23/// Parsed crate specifier: name@version.
24pub struct CrateSpec {
25    pub name: String,
26    pub version: String,
27}
28
29/// Options for `cargo capsec diff`.
30pub struct DiffOptions {
31    pub left: String,
32    pub right: String,
33    pub format: String,
34    pub fail_on_new: bool,
35}
36
37/// Options for `cargo capsec compare`.
38pub struct CompareOptions {
39    pub left: String,
40    pub right: String,
41    pub format: String,
42}
43
44// ── Public entry points ──
45
46/// Runs `cargo capsec diff crate@v1 crate@v2`.
47pub 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
94/// Runs `cargo capsec compare crate_a crate_b`.
95pub 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
132// ── Registry source fetcher ──
133
134/// Fetches the source directory for a crate@version.
135/// Checks ~/.cargo/registry/src/ first, falls back to `cargo fetch` with a temp manifest.
136fn 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    // Check registry cache first
143    if let Some(cached) = find_registry_source(crate_name, version) {
144        return Ok(cached);
145    }
146
147    // Not cached — create a temp project and cargo fetch
148    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    // Create a dummy src/lib.rs so cargo doesn't complain
163    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    // Run cargo fetch to download the crate
168    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    // Clean up temp dir
181    let _ = std::fs::remove_dir_all(&temp_dir);
182
183    // Now it should be in the registry cache
184    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
189/// Looks for a crate's source in ~/.cargo/registry/src/.
190/// When version is "*", finds the latest version available in the cache.
191fn 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(&registry_src).ok()?;
204    for index_dir in entries.flatten() {
205        if version == "*" {
206            // Find any version of this crate — pick the latest by name sort
207            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
241// ── Diff engine ──
242
243/// Compares findings between two versions of a crate.
244/// Matches by (function, call_text, category) — NOT by line number.
245fn 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
280// ── Parsers ──
281
282/// If version is "*", resolves it from the fetched directory path.
283/// e.g., path `.cargo/registry/src/.../ureq-2.12.1/src` → version "2.12.1"
284fn resolve_version_from_path(spec: &mut CrateSpec, dir: &Path) {
285    if spec.version != "*" {
286        return;
287    }
288    // Walk up from src/ to the crate dir: ureq-2.12.1
289    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
305/// Parses "serde_json@1.0.133" into CrateSpec.
306fn 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
319/// Parses "serde_json@1.0.133" or just "serde_json" (uses "latest" placeholder).
320fn parse_crate_spec_or_latest(spec: &str) -> CrateSpec {
321    if let Ok(parsed) = parse_crate_spec(spec) {
322        parsed
323    } else {
324        // No version specified — use a wildcard that cargo fetch will resolve
325        CrateSpec {
326            name: spec.to_string(),
327            version: "*".to_string(),
328        }
329    }
330}
331
332// ── Output formatters ──
333
334fn 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}