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}