1use std::collections::{BTreeMap, BTreeSet};
16use std::path::Path;
17
18use fleetreach_core::{FleetReport, Occurrence, ReachVerdict, Reachability, VulnFinding};
19use fleetreach_reach::{
20 analyze_project_cached, BuildConfig, FeatureSelection, SandboxPolicy, Verdict,
21};
22use sha2::{Digest, Sha256};
23
24use crate::config::Config;
25
26pub const TOOLCHAIN: &str = "nightly-2026-06-01";
29
30pub struct Options<'a> {
32 pub driver: &'a Path,
34 pub features: FeatureSelection,
36 pub sandbox: SandboxPolicy,
38 pub verbose: bool,
40}
41
42struct RepoData {
45 verdicts: BTreeMap<String, Verdict>,
46 cache_key: Option<String>,
47}
48
49type RepoOutcome = Result<RepoData, String>;
51
52pub fn assess(report: &mut FleetReport, config: &Config, opts: &Options) {
54 let engine = format!("static-mir-rta@{}", env!("CARGO_PKG_VERSION"));
55
56 let mut sinks_by_repo: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
58 for v in &report.vulnerabilities {
59 if v.affected_functions.is_empty() {
60 continue; }
62 for repo in repos_of(v) {
63 sinks_by_repo
64 .entry(repo)
65 .or_default()
66 .extend(v.affected_functions.iter().cloned());
67 }
68 }
69 if sinks_by_repo.is_empty() {
70 return;
71 }
72
73 let mut by_repo: BTreeMap<String, RepoOutcome> = BTreeMap::new();
75 for (repo_id, sinks) in &sinks_by_repo {
76 let outcome = match config.repos.iter().find(|r| r.id.0 == **repo_id) {
77 None => Err("repo is not in the fleet config".to_string()),
78 Some(repo) => {
79 if opts.verbose {
80 eprintln!("reachability(static): analyzing {repo_id} …");
81 }
82 let sink_vec: Vec<String> = sinks.iter().cloned().collect();
83 let build = BuildConfig {
84 toolchain: TOOLCHAIN,
85 features: opts.features.clone(),
86 sandbox: opts.sandbox,
87 };
88 analyze_project_cached(&repo.path, opts.driver, &build, &sink_vec)
89 .map(|c| {
90 if opts.verbose {
91 eprintln!(
92 "reachability(static): {repo_id} graph {}",
93 if c.from_cache {
94 "(cache hit)"
95 } else {
96 "(rebuilt)"
97 }
98 );
99 }
100 RepoData {
101 verdicts: c.verdicts,
102 cache_key: c.cache_key,
103 }
104 })
105 .map_err(|e| e.to_string())
106 }
107 };
108 if opts.verbose {
109 if let Err(e) = &outcome {
110 eprintln!("reachability(static): {repo_id} could not be analyzed: {e}");
111 }
112 }
113 by_repo.insert((*repo_id).to_string(), outcome);
114 }
115
116 let host = host_triple();
118
119 for v in &mut report.vulnerabilities {
121 if v.affected_functions.is_empty() {
122 continue;
123 }
124 let verdict = combine(v, &by_repo);
125 let (targets, witness) = match &verdict {
128 ReachVerdict::NotReachable => {
129 let cache_keys: BTreeSet<String> = repos_of(v)
130 .filter_map(|repo| by_repo.get(repo))
131 .filter_map(|outcome| outcome.as_ref().ok())
132 .filter_map(|data| data.cache_key.clone())
133 .collect();
134 (
135 vec![host.clone()],
136 Some(witness_hash(&engine, &v.affected_functions, &cache_keys)),
137 )
138 }
139 _ => (Vec::new(), None),
140 };
141 let reachability = Reachability {
142 verdict,
143 config: TOOLCHAIN.to_string(),
144 engine: engine.clone(),
145 targets,
146 witness,
147 };
148 v.reachable = reachability.as_legacy_bool();
149 v.reachability = Some(reachability);
150 }
151}
152
153fn host_triple() -> String {
156 std::process::Command::new("rustc")
157 .arg("-vV")
158 .output()
159 .ok()
160 .and_then(|out| String::from_utf8(out.stdout).ok())
161 .and_then(|text| {
162 text.lines()
163 .find_map(|line| line.strip_prefix("host: ").map(str::to_string))
164 })
165 .unwrap_or_else(|| std::env::consts::ARCH.to_string())
166}
167
168fn witness_hash(engine: &str, sinks: &[String], cache_keys: &BTreeSet<String>) -> String {
172 let mut hasher = Sha256::new();
173 hasher.update(TOOLCHAIN.as_bytes());
174 hasher.update([0]);
175 hasher.update(engine.as_bytes());
176 let mut sorted: Vec<&str> = sinks.iter().map(String::as_str).collect();
177 sorted.sort_unstable();
178 sorted.dedup();
179 for sink in sorted {
180 hasher.update([0]);
181 hasher.update(sink.as_bytes());
182 }
183 for key in cache_keys {
185 hasher.update([0]);
186 hasher.update(key.as_bytes());
187 }
188 let digest = hasher.finalize();
189 let mut out = String::with_capacity(7 + 64);
190 out.push_str("sha256:");
191 for byte in digest {
192 out.push_str(&format!("{byte:02x}"));
193 }
194 out
195}
196
197fn repos_of(v: &VulnFinding) -> impl Iterator<Item = &str> {
199 v.occurrences.iter().filter_map(|o| match o {
200 Occurrence::InRepo { repo, .. } => Some(repo.0.as_str()),
201 Occurrence::Toolchain { .. } => None,
202 })
203}
204
205fn combine(v: &VulnFinding, by_repo: &BTreeMap<String, RepoOutcome>) -> ReachVerdict {
209 let mut witness: Option<Vec<String>> = None;
210 let mut saw_unknown = false;
211 let mut saw_not_reachable = false;
212
213 for repo in repos_of(v) {
214 match by_repo.get(repo) {
215 None | Some(Err(_)) => saw_unknown = true,
217 Some(Ok(data)) => {
218 for func in &v.affected_functions {
219 match data.verdicts.get(func) {
221 Some(Verdict::Reachable { witness: w }) => {
222 witness.get_or_insert_with(|| w.clone());
223 }
224 Some(Verdict::NotReachable) => saw_not_reachable = true,
225 Some(Verdict::Unknown { .. }) => saw_unknown = true,
226 None => saw_unknown = true,
229 }
230 }
231 }
232 }
233 }
234
235 if let Some(w) = witness {
236 ReachVerdict::Reachable { witness: w }
237 } else if saw_unknown {
238 ReachVerdict::Unknown {
239 reason: "could not prove unreachable for every occurrence (build failure, \
240 opaque boundary, or unresolved sink)"
241 .to_string(),
242 }
243 } else if saw_not_reachable {
244 ReachVerdict::NotReachable
245 } else {
246 ReachVerdict::Unknown {
247 reason: "no affected function resolved to a call-graph node".to_string(),
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use fleetreach_core::{Exploitability, RepoId, Severity};
256
257 fn finding(funcs: &[&str], repos: &[&str]) -> VulnFinding {
258 VulnFinding {
259 advisory_id: "RUSTSEC-2099-0001".into(),
260 aliases: vec![],
261 ecosystem: Default::default(),
262 title: "t".into(),
263 severity: Severity::High,
264 cvss_score: None,
265 url: None,
266 occurrences: repos
267 .iter()
268 .map(|r| Occurrence::InRepo {
269 repo: RepoId((*r).into()),
270 package: "p".into(),
271 installed: fleetreach_core::semver::Version::new(1, 0, 0),
272 patched: vec![],
273 dependency_kind: fleetreach_core::DependencyKind::Direct,
274 dependency_path: vec![],
275 active: None,
276 source: Default::default(),
277 })
278 .collect(),
279 affected_functions: funcs.iter().map(|s| (*s).into()).collect(),
280 reachable: None,
281 reachability: None,
282 exploit: Exploitability::default(),
283 }
284 }
285
286 fn ok(pairs: Vec<(&str, Verdict)>) -> RepoOutcome {
288 Ok(RepoData {
289 verdicts: pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
290 cache_key: Some("reach-test".to_string()),
291 })
292 }
293
294 fn repos(pairs: Vec<(&str, RepoOutcome)>) -> BTreeMap<String, RepoOutcome> {
295 pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
296 }
297
298 #[test]
299 fn reachable_wins_with_witness() {
300 let f = finding(&["k::v"], &["a"]);
301 let by_repo = repos(vec![(
302 "a",
303 ok(vec![(
304 "k::v",
305 Verdict::Reachable {
306 witness: vec!["main".into(), "k::v".into()],
307 },
308 )]),
309 )]);
310 assert_eq!(
311 combine(&f, &by_repo),
312 ReachVerdict::Reachable {
313 witness: vec!["main".into(), "k::v".into()]
314 }
315 );
316 }
317
318 #[test]
319 fn all_definite_not_reachable_is_not_reachable() {
320 let f = finding(&["k::v"], &["a", "b"]);
321 let by_repo = repos(vec![
322 ("a", ok(vec![("k::v", Verdict::NotReachable)])),
323 ("b", ok(vec![("k::v", Verdict::NotReachable)])),
324 ]);
325 assert_eq!(combine(&f, &by_repo), ReachVerdict::NotReachable);
326 }
327
328 #[test]
329 fn one_unknown_repo_makes_it_unknown_not_notreachable() {
330 let f = finding(&["k::v"], &["a", "b"]);
332 let by_repo = repos(vec![
333 ("a", ok(vec![("k::v", Verdict::NotReachable)])),
334 ("b", Err("build failed".into())),
335 ]);
336 assert!(matches!(
337 combine(&f, &by_repo),
338 ReachVerdict::Unknown { .. }
339 ));
340 }
341
342 #[test]
343 fn reachable_in_any_repo_beats_notreachable_elsewhere() {
344 let f = finding(&["k::v"], &["a", "b"]);
345 let by_repo = repos(vec![
346 ("a", ok(vec![("k::v", Verdict::NotReachable)])),
347 (
348 "b",
349 ok(vec![(
350 "k::v",
351 Verdict::Reachable {
352 witness: vec!["x".into()],
353 },
354 )]),
355 ),
356 ]);
357 assert!(matches!(
358 combine(&f, &by_repo),
359 ReachVerdict::Reachable { .. }
360 ));
361 }
362
363 #[test]
364 fn witness_hash_is_deterministic_and_order_independent() {
365 let keys: BTreeSet<String> = ["reach-a", "reach-b"]
366 .iter()
367 .map(|s| s.to_string())
368 .collect();
369 let a = witness_hash("eng", &["a::x".into(), "b::y".into()], &keys);
370 let b = witness_hash("eng", &["b::y".into(), "a::x".into()], &keys);
371 assert_eq!(a, b, "sink order must not change the witness");
372 assert!(a.starts_with("sha256:"));
373 assert_eq!(a.len(), "sha256:".len() + 64);
374 assert_ne!(a, witness_hash("eng2", &["a::x".into()], &keys));
376 }
377
378 #[test]
379 fn unresolved_function_fails_closed_to_unknown() {
380 let f = finding(&["k::v"], &["a"]);
382 let by_repo = repos(vec![("a", ok(vec![("other::fn", Verdict::NotReachable)]))]);
383 assert!(matches!(
384 combine(&f, &by_repo),
385 ReachVerdict::Unknown { .. }
386 ));
387 }
388}