Skip to main content

fleetreach_cli/
resolve.rs

1//! Feature-aware "is it actually built?" resolution via `cargo tree`.
2//!
3//! `Cargo.lock` records optional dependencies even when their feature is off, so
4//! a lockfile-only scan can flag a package (e.g. `proc-macro-error2` via `jiff`'s
5//! off-by-default `defmt` feature) that is never compiled. `cargo metadata`'s
6//! resolve graph is the *maximal* graph and includes those phantoms — but
7//! `cargo tree` **is** feature-aware, so we use it as the oracle for the host's
8//! default build set.
9//!
10//! This is opt-in (`--resolve-features`): it shells out to `cargo` and needs the
11//! repo's buildable source, so it is never the default. Best-effort — any
12//! failure leaves findings unannotated rather than aborting the scan.
13
14use std::collections::BTreeSet;
15use std::path::Path;
16use std::process::Command;
17
18use fleetreach_core::semver::Version;
19
20/// The host target triple (e.g. `x86_64-apple-darwin`), parsed from `rustc -vV`.
21pub fn host_triple() -> Option<String> {
22    let output = Command::new("rustc").arg("-vV").output().ok()?;
23    if !output.status.success() {
24        return None;
25    }
26    let text = String::from_utf8(output.stdout).ok()?;
27    text.lines()
28        .find_map(|line| line.strip_prefix("host: "))
29        .map(|triple| triple.trim().to_string())
30}
31
32/// The `(name, version)` set actually compiled for the host's default build of
33/// the project at `project_dir`, per `cargo tree` (normal + build edges, default
34/// features). `Err` (cargo missing, not a project, stale lock, …) tells the
35/// caller to skip annotation rather than fail the scan.
36pub fn built_package_set(
37    project_dir: &Path,
38    host_triple: &str,
39) -> Result<BTreeSet<(String, Version)>, String> {
40    // `cargo tree` runs cargo inside the (untrusted) scanned repo, where its
41    // `.cargo/config.toml` is honored. `--offline` keeps it from reaching the
42    // network or resolving git deps, and `CARGO_NET_GIT_FETCH_WITH_CLI=false`
43    // stops a hostile config from spawning the operator's `git` (with its
44    // credential helpers). The feature is best-effort, so an offline miss simply
45    // leaves findings unannotated rather than reaching out.
46    let output = Command::new("cargo")
47        .current_dir(project_dir)
48        .env("CARGO_NET_GIT_FETCH_WITH_CLI", "false")
49        .args([
50            "tree",
51            // What a default `cargo build` compiles for this target; excludes dev.
52            "--edges",
53            "normal,build",
54            "--prefix",
55            "none",
56            "--target",
57            host_triple,
58            "--format",
59            "{p}",
60            "--locked",
61            "--offline",
62        ])
63        .output()
64        .map_err(|e| format!("running cargo tree: {e}"))?;
65    if !output.status.success() {
66        return Err(format!(
67            "cargo tree failed in {}: {}",
68            project_dir.display(),
69            String::from_utf8_lossy(&output.stderr).trim()
70        ));
71    }
72
73    let text = String::from_utf8_lossy(&output.stdout);
74    Ok(parse_package_specs(&text))
75}
76
77/// Parse `cargo tree --format "{p}"` output: each line is `name vX.Y.Z[ (source)]`.
78fn parse_package_specs(text: &str) -> BTreeSet<(String, Version)> {
79    let mut set = BTreeSet::new();
80    for line in text.lines() {
81        let mut parts = line.split_whitespace();
82        let (Some(name), Some(version)) = (parts.next(), parts.next()) else {
83            continue;
84        };
85        if let Ok(version) = Version::parse(version.trim_start_matches('v')) {
86            set.insert((name.to_string(), version));
87        }
88    }
89    set
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn parses_cargo_tree_package_specs() {
98        let text = "fleetreach-cli v0.1.0 (/path)\njiff v0.2.0\nnot a version line\n";
99        let set = parse_package_specs(text);
100        assert!(set.contains(&("jiff".to_string(), Version::new(0, 2, 0))));
101        assert!(set.contains(&("fleetreach-cli".to_string(), Version::new(0, 1, 0))));
102        assert_eq!(set.len(), 2);
103    }
104}