rto_exec/tool_security.rs
1//! The **read-only `security list` / `security status` documents** the
2//! model-facing tool surfaces return.
3//!
4//! `roteiro security list` and `roteiro security status` are the two `security`
5//! subcommands that read and never write, so they are the two that may be
6//! exposed to a model at all — the other three (`ingest`, `run`, `prefetch`) are
7//! permanent refusals, and `rto_render::mcp`'s module documentation carries the
8//! disposition table with each reason. What this module adds is the two things a
9//! CLI does not need and a tool surface cannot do without.
10//!
11//! # 1. An empty listing must not read as a clean one
12//!
13//! `roteiro security list --json` is 36 bytes on a repository no analyzer has
14//! ever run against: `{"layers": [], "findings": 0}`. **"Nothing has been
15//! analyzed" and "an analyzer ran and found nothing" are opposite facts**, and
16//! `findings: 0` reads as the second while meaning the first. A model that
17//! reports "no security findings" from that document is confidently wrong, and it
18//! is the single most likely misuse of these tools.
19//!
20//! The data does distinguish them — a clean run leaves a live layer whose
21//! `findings` is empty, and no run leaves no layer — so this is a defect in the
22//! *document*, not in the store. [`Coverage`] fixes it the way
23//! [`rto_spec::tool_check`]'s `Gate` fixes the same hazard for `check`: a
24//! discriminator that is **always** present, and the payload omitted entirely in
25//! the case that has no answer. A consumer reaching for findings in a
26//! [`Coverage::NoAnalyzerOnRecord`] document finds no `report` at all, rather
27//! than finding nothing-wrong.
28//!
29//! [`rto_spec::tool_check`]: https://docs.rs/rto-spec
30//!
31//! # 2. `security status` is two halves with two different scopes
32//!
33//! The CLI's status output reads the machine-global asset cache
34//! ([`crate::asset_root`], [`crate::status`]) *and* the current repository's
35//! findings layers, and prints them as one screen. On a CLI that is invisible and
36//! harmless: one process, one repository, one machine.
37//!
38//! Over a tool surface it is neither. A caller selects a *project* (ADR-0008), so
39//! the layer half follows the selected project and **the asset half does not** —
40//! those digests describe the machine the server runs on, whichever project was
41//! asked about. A model handed one flat blob has no way to tell which half is
42//! which, and "this repository's analyzers are not provisioned" is a claim the
43//! asset half cannot support.
44//!
45//! So the split is in the *output*, not only in this comment:
46//! [`ToolSecurityStatus`] has exactly two named sections, each carrying an
47//! explicit `scope` field, and each scope's identifying value lives **inside** its
48//! own section — the asset root under `machine`, the project name under
49//! `repository`. Neither half can be quoted without its scope travelling with it.
50//!
51//! # 3. A readiness claim names what it has actually checked
52//!
53//! `roteiro security status` used to label one analyzer `ready` on the strength of
54//! its *pinned assets* being provisioned. Running it needs a second thing — the
55//! analyzer's own program on `PATH` — and that is the one Roteiro deliberately
56//! **never installs** (ADR-0014). So on a host with the rules provisioned and
57//! `semgrep` absent, the old report read `semgrep ready` and the run then failed
58//! with `analyzer binary not found on PATH`. Both statements were true about
59//! different things and only one of them used the word *ready* (issue #464).
60//!
61//! `docs/REVIEW_CHECKLIST.md` has the rule this is a corollary of — *a refusal
62//! names the way forward* — applied to a report rather than a refusal: **a
63//! readiness claim names what it has actually checked.** And it is the same shape
64//! as §1, one field over: a caller that cannot run `command -v` — which is every
65//! caller on a tool surface — will read `ready` as *this will run*.
66//!
67//! [`Readiness`] is therefore three states rather than a `bool`, because **the
68//! remedy differs**: `assets-not-provisioned` is fixed by `prefetch`, which
69//! Roteiro performs; `binary-not-found` is fixed by an install, which it refuses
70//! to perform; `ready` is both. Both underlying facts are reported alongside it,
71//! so a host missing both is fully readable in one call rather than in two.
72//!
73//! @rto:0012
74//! @rto:0018
75
76use std::path::Path;
77
78use rto_graph::{
79 AdvisoryDb, AnalysisRun, Finding, FindingsLayer, Isolation, RunnerKind, Severity, age_in_days,
80};
81use serde::Serialize;
82
83use crate::adapter::ADAPTERS;
84use crate::assets::{AssetStatus, resolve, status};
85use crate::crossref::{Correspondence, across_analyzers};
86
87/// Schema tag for the tool-surface `security list` document.
88pub const TOOL_SECURITY_LIST_SCHEMA: &str = "roteiro.security.list/v1";
89
90/// Schema tag for the tool-surface `security status` document.
91pub const TOOL_SECURITY_STATUS_SCHEMA: &str = "roteiro.security.status/v1";
92
93/// Whether any analyzer result is on record, as a value rather than an absence.
94///
95/// This is the whole reason these documents exist rather than the CLI's `--json`
96/// being served directly. A caller that only tested `findings == 0` would read a
97/// repository nobody has analyzed as a clean one; making the absence of a result
98/// its own value means that caller has to notice.
99///
100/// # Why the negative case is not called `never-run`
101///
102/// Because that is more than the store can support.
103/// [`rto_graph::Store::delete_findings_layer`] exists, so "no live layer" means
104/// *no analyzer result is on record* — which covers a repository nobody analyzed
105/// and one whose layer was later deleted. Both are the same actionable fact and
106/// neither is "clean", so they share a token; claiming the stronger "never ran"
107/// would be a guess dressed as evidence.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
109#[serde(rename_all = "kebab-case")]
110pub enum Coverage {
111 /// At least one analyzer has a live findings layer. The `report` is present,
112 /// and a layer whose findings are empty is a genuine clean result.
113 Analyzed,
114 /// No live findings layer — nothing has been analyzed (or a layer was
115 /// deleted). **Not a clean result**: there is no `report` at all.
116 NoAnalyzerOnRecord,
117}
118
119impl Coverage {
120 /// The token this serialises as, for a caller that renders it as text.
121 #[must_use]
122 pub fn as_str(self) -> &'static str {
123 match self {
124 Self::Analyzed => "analyzed",
125 Self::NoAnalyzerOnRecord => "no-analyzer-on-record",
126 }
127 }
128}
129
130/// The tool-surface `security list` result.
131///
132/// # Why `report` is an `Option` and not an empty listing
133///
134/// The hazard this shape addresses is that a listing which had nothing to list
135/// looks exactly like a clean repository once it is serialised. A listing has
136/// `findings: usize`, and `0` is the *good* answer — so a
137/// [`Coverage::NoAnalyzerOnRecord`] result must not produce a listing at all. It
138/// does not: `report` is `None` and is skipped entirely in JSON, so a consumer
139/// reaching for `findings` finds nothing rather than nothing-wrong. `coverage`
140/// says the same thing in one word for a consumer that reads only that.
141#[derive(Debug, Clone, Serialize)]
142pub struct ToolSecurityList {
143 /// Stable schema tag ([`TOOL_SECURITY_LIST_SCHEMA`]).
144 pub schema: &'static str,
145 /// Whether any analyzer result is on record. Always present.
146 pub coverage: Coverage,
147 /// The listing. **Absent unless an analyzer result is on record.**
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub report: Option<SecurityListReport>,
150 /// Why there is nothing to list, and what to run. Present exactly when
151 /// `coverage` is `no-analyzer-on-record`.
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub no_result_reason: Option<String>,
154}
155
156/// The listing itself, present only when an analyzer result is on record.
157#[derive(Debug, Clone, Serialize)]
158pub struct SecurityListReport {
159 /// Every live layer, with its run evidence and a bounded page of findings.
160 pub layers: Vec<ToolFindingsLayer>,
161 /// Total findings across those layers — the **true** count, never reduced by
162 /// the page bound. **Unchanged** by the cross-reference below, which is a
163 /// view over these findings and not a replacement for them (ADR-0018 v1.1).
164 pub findings: usize,
165 /// How many findings this document actually carries. Below `findings`
166 /// whenever any layer was truncated.
167 pub returned: usize,
168 /// True when `returned < findings` — i.e. this document is a page and not the
169 /// whole listing. Each layer says which one of them was cut, and by how much.
170 pub truncated: bool,
171 /// Dependency advisories seen across analyzers, most-corroborated first, and
172 /// bounded by the same page size.
173 ///
174 /// **Empty unless at least two analyzers appear on the dependency axis**, and
175 /// that emptiness is an **explicit guard**, not something the data does on its
176 /// own: [`crate::cross_reference`] happily returns one row per advisory for a
177 /// single analyzer, each reading `confirmed_by: 1`, which is noise dressed as
178 /// information. [`crate::cross_reference_across_analyzers`] is the suppression,
179 /// it is the only implementation of it, and
180 /// `a_single_dependency_analyzer_yields_no_cross_reference` is what keeps this
181 /// sentence true.
182 ///
183 /// Do not remove the guard believing the emptiness is emergent — this comment
184 /// once claimed it was, and it was wrong (PR #468 review). Do not add a second
185 /// one either: the CLI's `security list --json` reaches the same suppression
186 /// through the same function.
187 #[serde(skip_serializing_if = "Vec::is_empty")]
188 pub cross_reference: Vec<CrossReference>,
189 /// How many advisories the cross-reference found in total, before the page
190 /// bound. Equal to `cross_reference.len()` when nothing was cut.
191 pub cross_reference_total: usize,
192}
193
194/// One live layer: its run evidence, and a **bounded page** of its findings.
195///
196/// # Why the count and the page are separate fields
197///
198/// `findings` here is the layer's real size and `page` is what fits. A single
199/// field would have to be one or the other, and a model reading a truncated
200/// count as a total under-reports a security result — the failure mode the whole
201/// module is written against, one level down. This is the vocabulary
202/// `rto_graph::tool_context` already uses for the same reason: a bound that
203/// reports what it bound.
204#[derive(Debug, Clone, Serialize)]
205pub struct ToolFindingsLayer {
206 /// The run that owns this layer: analyzer, version, backend, isolation,
207 /// advisory database, command policy, source identity and report digest.
208 pub run: AnalysisRun,
209 /// Every finding this layer owns — the **true** count, never reduced by the
210 /// page bound.
211 pub findings: usize,
212 /// The findings actually included, **most severe first** (see
213 /// [`security_list`] for why this order and not the store's).
214 pub page: Vec<Finding>,
215 /// True when `page` is shorter than `findings`.
216 pub truncated: bool,
217 /// How many of `findings` are missing from `page`.
218 pub omitted: usize,
219}
220
221/// One advisory in the cross-reference (ADR-0018 v1.1), as a serialisable view.
222///
223/// A **view**, not a record: every finding it names is still in its own layer
224/// under its own key, and [`SecurityListReport::findings`] still counts them all.
225/// That is what makes a duplicate pair read as one advisory confirmed by two
226/// analyzers rather than as a count that silently halved.
227#[derive(Debug, Clone, Serialize)]
228pub struct CrossReference {
229 /// The advisory's canonical id — the RUSTSEC id where both sides publish one.
230 pub advisory: String,
231 /// Every identifier it is published under.
232 pub aliases: Vec<String>,
233 /// The package and resolved version it is about.
234 pub package: String,
235 /// That package's resolved version.
236 pub version: String,
237 /// How many distinct analyzers reported it. `1` is a normal state, not a
238 /// discrepancy: the two databases are pinned independently, and `yanked` is
239 /// not an advisory kind OSV can carry at all.
240 pub confirmed_by: usize,
241 /// Which analyzers, and the still-addressable finding key each one wrote.
242 pub reports: Vec<CrossReferenceReport>,
243}
244
245/// One analyzer's report inside a [`CrossReference`].
246#[derive(Debug, Clone, Serialize)]
247pub struct CrossReferenceReport {
248 /// The analyzer that reported it.
249 pub analyzer: String,
250 /// The finding key, unchanged and still addressable.
251 pub key: String,
252 /// The id *this* analyzer fired, which need not be the canonical one.
253 pub rule: String,
254 /// The severity that analyzer assigned.
255 pub severity: Severity,
256}
257
258impl From<Correspondence> for CrossReference {
259 fn from(c: Correspondence) -> Self {
260 // `confirmed_by` comes from `Correspondence::confirmed_by`, never from a
261 // second count written here: one concept reporting different numbers on
262 // different surfaces is issue #321, and this is the same number the CLI
263 // prints.
264 let confirmed_by = c.confirmed_by();
265 Self {
266 advisory: c.advisory,
267 aliases: c.aliases,
268 package: c.package,
269 version: c.version,
270 confirmed_by,
271 reports: c
272 .reports
273 .into_iter()
274 .map(|r| CrossReferenceReport {
275 analyzer: r.analyzer,
276 key: r.key,
277 rule: r.rule,
278 severity: r.severity,
279 })
280 .collect(),
281 }
282 }
283}
284
285/// Build the tool-surface `security list` document from a project's live layers.
286///
287/// `limit` bounds the findings **per layer**, not across the document. That is the
288/// deliberate choice: a document-wide bound spends its whole budget on the first
289/// layer in key order and hands back `semgrep: 0 findings` for a layer it never
290/// reached — which reads as "semgrep found nothing" and is the exact defect this
291/// module exists to prevent, one level down. The worst case is therefore `limit ×
292/// live layers`, and a live layer is one per analyzer per checkout, so it is
293/// small and knowable rather than unbounded.
294///
295/// # Why the page is ordered by severity and the store's listing is not
296///
297/// [`rto_graph::Store::findings_layers`] returns findings ordered by key, which is
298/// right for a full listing and wrong for a truncated one: it would drop findings
299/// by alphabetical luck, and a critical whose advisory id sorts late would vanish
300/// behind an informational one. The page is therefore sorted by severity,
301/// descending, with the store's key order preserved within each level (the sort is
302/// stable). One caveat, stated because it decides what gets dropped first:
303/// [`Severity::Other`] — a level no shipped adapter emits, kept verbatim for a
304/// future analyzer's vocabulary — orders *after* `info`, so an unrecognised
305/// severity is truncated first. `truncated` and `omitted` are what keep that
306/// visible instead of silent.
307#[must_use]
308pub fn security_list(layers: Vec<FindingsLayer>, limit: usize) -> ToolSecurityList {
309 if layers.is_empty() {
310 return ToolSecurityList {
311 schema: TOOL_SECURITY_LIST_SCHEMA,
312 coverage: Coverage::NoAnalyzerOnRecord,
313 report: None,
314 no_result_reason: Some(NO_RESULT_REASON.to_owned()),
315 };
316 }
317
318 // The cross-reference is computed over the **full** layers, before any page
319 // bound, so `confirmed_by` counts every analyzer that reported an advisory
320 // rather than every analyzer whose page happened to include it. A bound
321 // applied first would turn agreement between two sources into a single-source
322 // row — inventing a disagreement out of a page size.
323 //
324 // `across_analyzers`, not `cross_reference`: the second does not suppress
325 // single-source rows, and this document says it does. The CLI reaches the same
326 // function, so there is one guard rather than one per surface.
327 let correspondences = across_analyzers(&layers);
328 let cross_reference_total = correspondences.len();
329 let mut cross_reference = corroborated_first(correspondences);
330 cross_reference.truncate(limit);
331
332 let findings: usize = layers.iter().map(|l| l.findings.len()).sum();
333 let layers: Vec<ToolFindingsLayer> = layers.into_iter().map(|l| page(l, limit)).collect();
334 let returned: usize = layers.iter().map(|l| l.page.len()).sum();
335
336 ToolSecurityList {
337 schema: TOOL_SECURITY_LIST_SCHEMA,
338 coverage: Coverage::Analyzed,
339 report: Some(SecurityListReport {
340 layers,
341 findings,
342 returned,
343 truncated: returned < findings,
344 cross_reference,
345 cross_reference_total,
346 }),
347 no_result_reason: None,
348 }
349}
350
351/// What a `no-analyzer-on-record` listing says instead of listing nothing.
352///
353/// It names the fact and the remedy, and it says the thing a model must not
354/// conclude — because the description of a tool is read once and the body of its
355/// result is read every time.
356const NO_RESULT_REASON: &str = "No analyzer has filed a findings layer here, so nothing has been \
357 analyzed. This is NOT a clean result and must not be reported as \
358 one: a clean run leaves a layer whose findings are empty, which \
359 would appear above with coverage `analyzed`. Run `roteiro \
360 security ingest <report.json>` (or `roteiro security run \
361 --analyzer <name>`) to produce a result.";
362
363/// Sort a cross-reference so advisories more than one analyzer reported come
364/// first, preserving [`cross_reference`]'s order within each group.
365///
366/// The page bound cuts from the end, so what it must never cut is the agreement
367/// between independent sources — that is the evidence ADR-0018 v1.1 exists to
368/// keep. Single-source rows are the ordinary state and are the right thing to
369/// lose first; `cross_reference_total` is what says how many were lost.
370///
371/// Single-source rows still reach here, and that is not in tension with the
372/// suppression above: [`crate::cross_reference_across_analyzers`] drops the *whole
373/// section* when no advisory has a second source, and passes everything through
374/// once one does — including the advisories only one analyzer happened to report,
375/// which are real findings about a repository that does have two dependency
376/// analyzers. This orders those last.
377fn corroborated_first(correspondences: Vec<Correspondence>) -> Vec<CrossReference> {
378 let mut views: Vec<CrossReference> = correspondences.into_iter().map(Into::into).collect();
379 // Stable, so `cross_reference`'s own ordering survives inside each group.
380 views.sort_by_key(|c| std::cmp::Reverse(c.confirmed_by));
381 views
382}
383
384/// One layer's bounded page, with the real count kept alongside it.
385fn page(layer: FindingsLayer, limit: usize) -> ToolFindingsLayer {
386 let FindingsLayer { run, mut findings } = layer;
387 let total = findings.len();
388 // Stable sort on severity alone: `Severity`'s `Ord` runs critical → info →
389 // other, so ascending order is most-severe-first, and the store's key order
390 // survives as the tie-break without needing `FindingKey: Ord`.
391 findings.sort_by(|a, b| a.severity.cmp(&b.severity));
392 findings.truncate(limit);
393 ToolFindingsLayer {
394 run,
395 findings: total,
396 omitted: total - findings.len(),
397 truncated: findings.len() < total,
398 page: findings,
399 }
400}
401
402/// The tool-surface `security status` result: **two scopes, never one blob**.
403///
404/// See this module's documentation for why the split is in the document rather
405/// than in a comment. In short: the asset half describes the machine the server
406/// runs on and the layer half describes the selected project, so a reader who
407/// cannot tell them apart will attribute one to the other.
408#[derive(Debug, Clone, Serialize)]
409pub struct ToolSecurityStatus {
410 /// Stable schema tag ([`TOOL_SECURITY_STATUS_SCHEMA`]).
411 pub schema: &'static str,
412 /// What this **machine** has provisioned. Identical for every project this
413 /// server hosts.
414 pub machine: MachineScope,
415 /// What has been analyzed in the **selected project**. Different for each.
416 pub repository: RepositoryScope,
417}
418
419/// The machine-global half of a status document.
420///
421/// Every field here is a property of the host — its asset cache under
422/// [`crate::asset_root`] and its `PATH` — and none of it is a property of any
423/// repository. A `ready` analyzer means this machine *could* run it; it says
424/// nothing at all about whether it has been run anywhere, which is the
425/// `repository` half's question.
426#[derive(Debug, Clone, Serialize)]
427pub struct MachineScope {
428 /// Always `"machine"`. Redundant with this section's name on purpose: a model
429 /// that quotes the section alone still carries its scope with it.
430 pub scope: &'static str,
431 /// The pinned-asset cache these digests describe.
432 pub asset_root: String,
433 /// What each shipped analyzer covers, read off the adapters rather than off a
434 /// document, and whether this machine can actually run it — **both** halves of
435 /// that, since they have different remedies (see [`Readiness`]).
436 pub analyzers: Vec<AnalyzerCoverage>,
437 /// Every pinned asset, its digest, its age, and whether the bytes on disk
438 /// still match what was recorded.
439 pub assets: Vec<AssetStatus>,
440}
441
442/// The per-repository half of a status document.
443///
444/// Everything here is a property of one project's graph. It carries the same
445/// [`Coverage`] discriminator as [`ToolSecurityList`], for the same reason: an
446/// empty `layers` array would read as a clean repository.
447#[derive(Debug, Clone, Serialize)]
448pub struct RepositoryScope {
449 /// Always `"repository"`. Redundant on purpose — see [`MachineScope::scope`].
450 pub scope: &'static str,
451 /// The project these layers belong to, as the workspace resolved it
452 /// (ADR-0008). Named here rather than at the top level so it cannot be read
453 /// as qualifying the machine half.
454 pub project: String,
455 /// Whether any analyzer result is on record for this project. Always present.
456 pub coverage: Coverage,
457 /// The live layers and how stale the advisory data behind each one is.
458 /// **Absent unless an analyzer result is on record.**
459 #[serde(skip_serializing_if = "Option::is_none")]
460 pub layers: Option<Vec<LayerStaleness>>,
461 /// Why there is nothing to report, and what to run. Present exactly when
462 /// `coverage` is `no-analyzer-on-record`.
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub no_result_reason: Option<String>,
465}
466
467/// Whether one analyzer can actually be run **on this host**, as one word.
468///
469/// Three states rather than a `bool`, because the two things a host run needs have
470/// different remedies and only one of them is Roteiro's to perform (issue #464):
471///
472/// | state | what is missing | the fix |
473/// | --- | --- | --- |
474/// | `ready` | nothing | — |
475/// | `assets-not-provisioned` | a pinned asset, or its bytes no longer match | `roteiro security prefetch` |
476/// | `binary-not-found` | the analyzer's own program, on `PATH` | an install; **Roteiro never does this** |
477///
478/// # Precedence, and why both facts are still reported
479///
480/// A host can be missing both. This names the asset side first, because that is
481/// the step Roteiro can take and the one a caller should take first — but a
482/// one-word verdict that names one blocker would send a caller round twice, so
483/// [`AnalyzerCoverage`] carries `assets_provisioned` and `missing_programs`
484/// alongside it. Both are always present; this is a summary of them, never a
485/// substitute.
486///
487/// # What "on this host" excludes, and it is not a caveat on the word
488///
489/// The sandboxed backend runs the analyzer inside a digest-pinned OCI image
490/// (ADR-0014/ADR-0019), which supplies the program — so `binary-not-found` does
491/// **not** block a sandboxed run, and it is the only state where the two backends
492/// disagree. This says nothing about sandbox readiness: it does not inspect the
493/// local image store, and reporting a sandbox verdict it has not checked would be
494/// issue #464 committed a second time. `security run` still refuses, naming what
495/// is missing, when the sandbox cannot run.
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
497#[serde(rename_all = "kebab-case")]
498pub enum Readiness {
499 /// Every pinned asset is provisioned and verified, and every program this
500 /// analyzer needs is on `PATH`.
501 Ready,
502 /// A pinned asset is absent, or its bytes no longer match the recorded digest.
503 /// Fixed by `roteiro security prefetch`.
504 AssetsNotProvisioned,
505 /// The assets are fine and the analyzer's own program is not on `PATH`. Fixed
506 /// by installing it — which Roteiro will not do. This is the same fact
507 /// `SubprocessError::BinaryNotFound` reports, found before a run rather than
508 /// during one.
509 BinaryNotFound,
510}
511
512impl Readiness {
513 /// The token this serialises as, for a caller that renders it as text.
514 #[must_use]
515 pub fn as_str(self) -> &'static str {
516 match self {
517 Self::Ready => "ready",
518 Self::AssetsNotProvisioned => "assets not provisioned",
519 Self::BinaryNotFound => "binary not found",
520 }
521 }
522}
523
524/// The three-state verdict from the two facts it is built from.
525///
526/// A pure function so the precedence rule above is checkable without a
527/// provisioned asset cache or a controlled `PATH` — neither of which a test can
528/// arrange here, since `unsafe_code = "forbid"` rules out `std::env::set_var`.
529#[must_use]
530fn readiness(assets_provisioned: bool, missing_programs: &[&str]) -> Readiness {
531 if !assets_provisioned {
532 Readiness::AssetsNotProvisioned
533 } else if missing_programs.is_empty() {
534 Readiness::Ready
535 } else {
536 Readiness::BinaryNotFound
537 }
538}
539
540/// Whether `program` resolves to an executable file in any of `dirs`.
541///
542/// A **read**, and that is load-bearing on a tool surface: it stats candidate
543/// paths and never starts a process. Probing by running `<program> --version`
544/// would be executing a third-party binary because a model asked a question, which
545/// is the thing this whole surface refuses.
546///
547/// Split from [`on_path`] so the lookup is testable against a directory a test
548/// owns, rather than against the process environment it cannot change.
549#[must_use]
550fn program_in(dirs: &[std::path::PathBuf], program: &str) -> bool {
551 // A name containing a separator is a path rather than a `PATH` lookup — the
552 // same rule `std::process::Command::new` follows, so this agrees with what a
553 // run would actually do.
554 if std::path::Path::new(program).components().count() > 1 {
555 return is_executable_file(std::path::Path::new(program));
556 }
557 dirs.iter()
558 .any(|dir| is_executable_file(&dir.join(program)))
559}
560
561/// Whether `program` resolves to an executable file on this process's `PATH`.
562#[must_use]
563fn on_path(program: &str) -> bool {
564 let Some(var) = std::env::var_os("PATH") else {
565 return false;
566 };
567 let dirs: Vec<std::path::PathBuf> = std::env::split_paths(&var).collect();
568 program_in(&dirs, program)
569}
570
571/// Whether `path` is a file this host would execute.
572///
573/// Follows symlinks, because a symlinked binary is exactly as runnable as a real
574/// one and every package manager installs one.
575#[cfg(unix)]
576#[must_use]
577fn is_executable_file(path: &std::path::Path) -> bool {
578 use std::os::unix::fs::PermissionsExt as _;
579 std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
580}
581
582/// Whether `path` is a file this host would execute.
583///
584/// There are no mode bits to consult, so being a file is the whole check, and the
585/// `.exe` sibling is tried because that is what every program named by an adapter
586/// here ships as off Unix. The full `PATHEXT` set is deliberately **not** walked:
587/// none of these analyzers ships as a `.bat` or `.cmd`, and a probe that guessed
588/// wider would report a readiness it had not established — which is the defect
589/// [`Readiness`] exists to remove.
590#[cfg(not(unix))]
591#[must_use]
592fn is_executable_file(path: &std::path::Path) -> bool {
593 if path.is_file() {
594 return true;
595 }
596 match path.file_name().and_then(|n| n.to_str()) {
597 Some(name) => path.with_file_name(format!("{name}.exe")).is_file(),
598 None => false,
599 }
600}
601
602/// What one shipped analyzer covers — the coverage matrix, read off the code
603/// rather than off a document, so the two cannot drift apart unnoticed.
604///
605/// Every field is **machine-global**. Nothing here is a statement about any
606/// repository: it asks what this host has provisioned and what it has installed,
607/// and the answer is the same whichever project was selected.
608#[derive(Debug, Clone, Serialize)]
609pub struct AnalyzerCoverage {
610 /// The analyzer id.
611 pub analyzer: &'static str,
612 /// One line on what it looks for.
613 pub summary: &'static str,
614 /// The languages it produces findings for (ADR-0018's matrix).
615 pub languages: &'static [&'static str],
616 /// Whether this host could run it, and if not, which remedy applies. A
617 /// summary of the two fields below — see [`Readiness`].
618 pub host_readiness: Readiness,
619 /// Whether every pinned asset it needs is provisioned **on this machine** and
620 /// still matches its digest. Fixed by `roteiro security prefetch`.
621 pub assets_provisioned: bool,
622 /// Every program it needs on `PATH` to run on this host
623 /// ([`crate::Adapter::host_programs`]).
624 pub host_programs: &'static [&'static str],
625 /// Which of those are **not** on `PATH`. Empty exactly when all are present.
626 /// Named individually because the name is the actionable part: Roteiro does not
627 /// install these, so the reader has to know which one to go and get.
628 pub missing_programs: Vec<&'static str>,
629}
630
631/// The staleness of the advisory data behind one live findings layer.
632///
633/// Counts, never findings: this is the shape that lets a status document stay a
634/// fixed size while a listing needs a page bound.
635#[derive(Debug, Clone, Serialize)]
636pub struct LayerStaleness {
637 /// The layer key.
638 pub layer: String,
639 /// The analyzer that owns it.
640 pub analyzer: String,
641 /// How many findings it holds.
642 pub findings: usize,
643 /// Which backend produced it.
644 pub runner: RunnerKind,
645 /// The isolation boundary that run actually had.
646 pub isolation: Isolation,
647 /// The pinned advisory database it consulted, when it had one.
648 #[serde(skip_serializing_if = "Option::is_none")]
649 pub advisory_db: Option<AdvisoryDb>,
650 /// Days between the advisory database's publication and now.
651 #[serde(skip_serializing_if = "Option::is_none")]
652 pub advisory_db_age_days: Option<i64>,
653 /// `true` whenever an advisory database is involved at all. Never `false`
654 /// meaning "current" — only "this result has no advisory-data axis".
655 pub possibly_stale: bool,
656}
657
658/// The coverage matrix for `analyzer` (or every shipped analyzer), with each one's
659/// readiness resolved against the asset cache at `root` **and** this process's
660/// `PATH`.
661///
662/// Shared by the CLI's `security status` and both tool surfaces, so the readiness
663/// rule is one computation rather than three — the same reason
664/// [`layer_staleness`] is shared, and the reason issue #464 was one fix rather
665/// than three.
666#[must_use]
667pub fn coverage_matrix(root: &Path, analyzer: Option<&str>) -> Vec<AnalyzerCoverage> {
668 coverage_matrix_with(root, analyzer, on_path)
669}
670
671/// [`coverage_matrix`] with the `PATH` probe supplied by the caller.
672///
673/// The probe is an argument for the reason provisioning takes its fetcher as one:
674/// it keeps the decision testable without the ambient state it would otherwise
675/// depend on. A test cannot change this process's `PATH` — `unsafe_code =
676/// "forbid"` rules out `std::env::set_var` — so without this seam two of the three
677/// [`Readiness`] states would be unreachable from a test, on a machine where
678/// whether they are reachable at all depends on what happens to be installed.
679#[must_use]
680pub fn coverage_matrix_with(
681 root: &Path,
682 analyzer: Option<&str>,
683 on_path: impl Fn(&str) -> bool,
684) -> Vec<AnalyzerCoverage> {
685 ADAPTERS
686 .iter()
687 .filter(|a| analyzer.is_none_or(|name| a.analyzer() == name))
688 .map(|adapter| {
689 let host_programs = adapter.host_programs();
690 let missing_programs: Vec<&'static str> = host_programs
691 .iter()
692 .copied()
693 .filter(|program| !on_path(program))
694 .collect();
695 let assets_provisioned = resolve(root, adapter.analyzer()).is_ok();
696 AnalyzerCoverage {
697 analyzer: adapter.analyzer(),
698 summary: adapter.summary(),
699 languages: adapter.languages(),
700 host_readiness: readiness(assets_provisioned, &missing_programs),
701 assets_provisioned,
702 host_programs,
703 missing_programs,
704 }
705 })
706 .collect()
707}
708
709/// The advisory-staleness rows for `layers`, aged against `now` (an RFC 3339
710/// timestamp, as [`crate::rfc3339_utc`] renders one).
711///
712/// Shared by the CLI's `security status` and both tool surfaces. `possibly_stale`
713/// in particular is a judgement about evidence rather than a field to be copied:
714/// three implementations of it would be three chances for one to say "current".
715#[must_use]
716pub fn layer_staleness(layers: &[FindingsLayer], now: &str) -> Vec<LayerStaleness> {
717 layers
718 .iter()
719 .map(|layer| {
720 // Staleness comes from the *run*, because the advisory database's
721 // publication date is something the analyzer reported, not something
722 // provisioning could know.
723 let age = layer
724 .run
725 .advisory_db
726 .as_ref()
727 .and_then(|db| db.published_at.as_deref())
728 .and_then(|published| age_in_days(published, now));
729 LayerStaleness {
730 layer: layer.run.layer.clone(),
731 analyzer: layer.run.analyzer.clone(),
732 findings: layer.findings.len(),
733 runner: layer.run.runner,
734 isolation: layer.run.isolation,
735 advisory_db: layer.run.advisory_db.clone(),
736 advisory_db_age_days: age,
737 possibly_stale: layer.run.advisory_db.is_some(),
738 }
739 })
740 .collect()
741}
742
743/// Build the tool-surface `security status` document.
744///
745/// `root` and `analyzer` govern the machine half; `project` and `layers` govern
746/// the repository half. They are separate arguments because they are separate
747/// facts, and the caller has to supply them from separate places — the asset root
748/// from the host, the layers from the resolved project's store.
749#[must_use]
750pub fn security_status(
751 root: &Path,
752 analyzer: Option<&str>,
753 project: &str,
754 layers: &[FindingsLayer],
755 now: &str,
756) -> ToolSecurityStatus {
757 let staleness = layer_staleness(layers, now);
758 let (coverage, layers, reason) = if staleness.is_empty() {
759 (
760 Coverage::NoAnalyzerOnRecord,
761 None,
762 Some(NO_RESULT_REASON.to_owned()),
763 )
764 } else {
765 (Coverage::Analyzed, Some(staleness), None)
766 };
767
768 ToolSecurityStatus {
769 schema: TOOL_SECURITY_STATUS_SCHEMA,
770 machine: MachineScope {
771 scope: "machine",
772 asset_root: root.display().to_string(),
773 analyzers: coverage_matrix(root, analyzer),
774 assets: status(root, analyzer),
775 },
776 repository: RepositoryScope {
777 scope: "repository",
778 project: project.to_owned(),
779 coverage,
780 layers,
781 no_result_reason: reason,
782 },
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::{
789 Coverage, Readiness, TOOL_SECURITY_LIST_SCHEMA, TOOL_SECURITY_STATUS_SCHEMA,
790 coverage_matrix_with, layer_staleness, program_in, readiness, security_list,
791 security_status,
792 };
793 use rto_graph::{
794 AdvisoryDb, AnalysisRun, CommandPolicy, Finding, FindingKey, FindingsLayer, Isolation,
795 RunnerKind, Severity, SourceIdentity,
796 };
797
798 fn run(analyzer: &str, advisory_db: Option<AdvisoryDb>) -> AnalysisRun {
799 AnalysisRun {
800 layer: format!("security:{analyzer}:wt"),
801 analyzer: analyzer.to_owned(),
802 analyzer_version: "1.0.0".to_owned(),
803 runner: RunnerKind::Ingested,
804 isolation: Isolation::Ingested,
805 image_digest: None,
806 rules_digest: None,
807 advisory_db,
808 command_policy: CommandPolicy::default(),
809 source: SourceIdentity::default(),
810 started_at: "2026-08-01T00:00:00Z".to_owned(),
811 ended_at: "2026-08-01T00:00:01Z".to_owned(),
812 exit_status: 0,
813 report_digest: "deadbeef".to_owned(),
814 }
815 }
816
817 /// A finding on the **dependency axis**: `meta.package` and `meta.version` are
818 /// what `Candidate::of` requires before a finding can be cross-referenced at all.
819 fn dependency_finding(analyzer: &str, rule: &str, package: &str) -> Finding {
820 Finding {
821 meta: serde_json::json!({ "package": package, "version": "1.0.0" }),
822 ..finding(analyzer, rule, Severity::High)
823 }
824 }
825
826 fn finding(analyzer: &str, rule: &str, severity: Severity) -> Finding {
827 Finding {
828 key: FindingKey::new(analyzer, &[rule, crate::NO_SNIPPET]).expect("key"),
829 rule: rule.to_owned(),
830 severity,
831 title: format!("{rule} title"),
832 message: format!("{rule} message"),
833 path: None,
834 span: None,
835 meta: serde_json::Value::Null,
836 }
837 }
838
839 /// The trap the whole module is written against: an empty listing must not be
840 /// a document a reader can mistake for a clean one.
841 ///
842 /// The assertion is deliberately about the **serialised** document rather than
843 /// the struct: `findings` being `None` in Rust is worth nothing if serde still
844 /// emits `"findings": 0`, and it is the JSON a model reads.
845 #[test]
846 fn nothing_analyzed_carries_no_findings_field_at_all() {
847 let doc = security_list(Vec::new(), 20);
848 assert_eq!(doc.coverage, Coverage::NoAnalyzerOnRecord);
849 let json = serde_json::to_value(&doc).expect("serialise");
850 assert_eq!(json["schema"], TOOL_SECURITY_LIST_SCHEMA);
851 assert_eq!(json["coverage"], "no-analyzer-on-record");
852 assert!(
853 json.get("report").is_none(),
854 "a listing with nothing to list must carry no report: {json}"
855 );
856 // The two fields a caller would reach for are absent, not zero. `0` is the
857 // *good* answer for both, which is exactly why neither may appear here.
858 assert!(json.get("findings").is_none(), "{json}");
859 assert!(json.get("layers").is_none(), "{json}");
860 let reason = json["no_result_reason"].as_str().expect("reason");
861 assert!(reason.contains("NOT a clean result"), "{reason}");
862 }
863
864 /// The other half of the same trap, and the half that makes the first half
865 /// mean something: a run that found nothing is `analyzed` with `findings: 0`.
866 /// If both cases produced the same document the discriminator would be inert.
867 #[test]
868 fn a_clean_run_is_analyzed_with_zero_findings() {
869 let layers = vec![FindingsLayer {
870 run: run("semgrep", None),
871 findings: Vec::new(),
872 }];
873 let doc = security_list(layers, 20);
874 assert_eq!(doc.coverage, Coverage::Analyzed);
875 let json = serde_json::to_value(&doc).expect("serialise");
876 assert_eq!(json["coverage"], "analyzed");
877 assert_eq!(json["report"]["findings"], 0);
878 assert_eq!(json["report"]["layers"][0]["findings"], 0);
879 assert!(json.get("no_result_reason").is_none(), "{json}");
880 }
881
882 /// The page bound is per layer, and every layer keeps its true count.
883 ///
884 /// The second layer is what this is really about: a document-wide bound would
885 /// spend its budget on the first layer and report the second as empty, which
886 /// reads as "that analyzer found nothing".
887 #[test]
888 fn the_page_bound_is_per_layer_and_never_hides_a_layer() {
889 let layers = vec![
890 FindingsLayer {
891 run: run("cargo-audit", None),
892 findings: (0..5)
893 .map(|i| finding("cargo-audit", &format!("RUSTSEC-{i}"), Severity::High))
894 .collect(),
895 },
896 FindingsLayer {
897 run: run("semgrep", None),
898 findings: (0..5)
899 .map(|i| finding("semgrep", &format!("rule-{i}"), Severity::Medium))
900 .collect(),
901 },
902 ];
903 let doc = security_list(layers, 2);
904 let report = doc.report.expect("analyzed");
905 assert_eq!(report.findings, 10, "the true total survives the bound");
906 assert_eq!(report.returned, 4, "two per layer, both layers reached");
907 assert!(report.truncated);
908 for layer in &report.layers {
909 assert_eq!(layer.findings, 5, "true count per layer");
910 assert_eq!(layer.page.len(), 2);
911 assert_eq!(layer.omitted, 3);
912 assert!(layer.truncated);
913 }
914 }
915
916 /// A truncated page keeps the worst findings, not the alphabetically luckiest.
917 ///
918 /// The rule ids are ordered so that key order and severity order disagree:
919 /// under the store's key ordering the critical would be cut and the
920 /// informational kept.
921 #[test]
922 fn a_truncated_page_keeps_the_most_severe() {
923 let layers = vec![FindingsLayer {
924 run: run("semgrep", None),
925 findings: vec![
926 finding("semgrep", "aaa-info", Severity::Info),
927 finding("semgrep", "bbb-low", Severity::Low),
928 finding("semgrep", "zzz-critical", Severity::Critical),
929 ],
930 }];
931 let doc = security_list(layers, 1);
932 let report = doc.report.expect("analyzed");
933 assert_eq!(report.layers[0].page.len(), 1);
934 assert_eq!(report.layers[0].page[0].rule, "zzz-critical");
935 assert_eq!(report.layers[0].omitted, 2);
936 }
937
938 /// An unbounded page is not a special case: `returned == findings` and nothing
939 /// claims to be truncated.
940 #[test]
941 fn an_untruncated_listing_says_so() {
942 let layers = vec![FindingsLayer {
943 run: run("semgrep", None),
944 findings: vec![finding("semgrep", "rule-1", Severity::High)],
945 }];
946 let report = security_list(layers, 20).report.expect("analyzed");
947 assert_eq!(report.findings, 1);
948 assert_eq!(report.returned, 1);
949 assert!(!report.truncated);
950 assert!(!report.layers[0].truncated);
951 assert_eq!(report.layers[0].omitted, 0);
952 }
953
954 /// One dependency analyzer has nothing to be corroborated *by*, so the
955 /// cross-reference is empty — which is what `SecurityListReport::cross_reference`
956 /// says.
957 ///
958 /// Written to check the claim rather than to restate it: `crossref::cross_reference`
959 /// keys one `Correspondence` per advisory-and-package for every finding on the
960 /// dependency axis, and nothing in it counts analyzers. So whether the documented
961 /// behaviour is real depends on a suppression that has to exist somewhere, and
962 /// this is what says where.
963 #[test]
964 fn a_single_dependency_analyzer_yields_no_cross_reference() {
965 let layers = vec![FindingsLayer {
966 run: run("cargo-audit", None),
967 findings: vec![
968 dependency_finding("cargo-audit", "RUSTSEC-2024-0001", "openssl"),
969 dependency_finding("cargo-audit", "RUSTSEC-2024-0002", "time"),
970 ],
971 }];
972 let report = security_list(layers, 20).report.expect("analyzed");
973 assert!(
974 report.cross_reference.is_empty(),
975 "a table in which every row reads `confirmed_by: 1` is noise dressed as \
976 information: {:?}",
977 report.cross_reference
978 );
979 assert_eq!(report.cross_reference_total, 0);
980 // And the findings are untouched by the suppression — it hides a view, never
981 // a finding.
982 assert_eq!(report.findings, 2);
983 }
984
985 /// Two dependency analyzers reporting the same advisory is the case the
986 /// cross-reference exists for, and the one it must never suppress.
987 #[test]
988 fn two_dependency_analyzers_are_cross_referenced_and_counted() {
989 let layers = vec![
990 FindingsLayer {
991 run: run("cargo-audit", None),
992 findings: vec![dependency_finding(
993 "cargo-audit",
994 "RUSTSEC-2024-0001",
995 "openssl",
996 )],
997 },
998 FindingsLayer {
999 run: run("osv-scanner", None),
1000 findings: vec![dependency_finding(
1001 "osv-scanner",
1002 "RUSTSEC-2024-0001",
1003 "openssl",
1004 )],
1005 },
1006 ];
1007 let report = security_list(layers, 20).report.expect("analyzed");
1008 assert_eq!(report.cross_reference.len(), 1, "one advisory, two reports");
1009 assert_eq!(report.cross_reference_total, 1);
1010 assert_eq!(report.cross_reference[0].confirmed_by, 2);
1011 assert_eq!(
1012 report.findings, 2,
1013 "the count is unchanged by the view (ADR-0018 v1.1)"
1014 );
1015 }
1016
1017 /// The two halves of a status document are separately labelled, and each
1018 /// scope's identifying value sits inside its own half.
1019 ///
1020 /// This is the property the issue was filed for: on a CLI the asymmetry is
1021 /// invisible and harmless, and over a tool surface a model must be able to
1022 /// tell "these digests are this machine's" from "this staleness is that
1023 /// repository's".
1024 #[test]
1025 fn status_labels_its_two_scopes_in_the_document() {
1026 let root = std::path::Path::new("/nonexistent-asset-root");
1027 let doc = security_status(root, None, "spoke", &[], "2026-08-19T00:00:00Z");
1028 let json = serde_json::to_value(&doc).expect("serialise");
1029 assert_eq!(json["schema"], TOOL_SECURITY_STATUS_SCHEMA);
1030 assert_eq!(json["machine"]["scope"], "machine");
1031 assert_eq!(json["repository"]["scope"], "repository");
1032 // The asset root is inside `machine` and the project inside `repository`,
1033 // so neither half can be quoted without the scope it belongs to.
1034 assert!(json["machine"]["asset_root"].is_string(), "{json}");
1035 assert_eq!(json["repository"]["project"], "spoke");
1036 assert!(json["machine"].get("project").is_none(), "{json}");
1037 assert!(json["repository"].get("asset_root").is_none(), "{json}");
1038 }
1039
1040 /// The status document's repository half carries the same discriminator as the
1041 /// listing, so an unanalyzed project cannot read as a clean one there either.
1042 #[test]
1043 fn status_repository_half_distinguishes_unanalyzed_from_clean() {
1044 let root = std::path::Path::new("/nonexistent-asset-root");
1045 let empty = security_status(root, None, "p", &[], "2026-08-19T00:00:00Z");
1046 let json = serde_json::to_value(&empty).expect("serialise");
1047 assert_eq!(json["repository"]["coverage"], "no-analyzer-on-record");
1048 assert!(json["repository"].get("layers").is_none(), "{json}");
1049 assert!(
1050 json["repository"]["no_result_reason"]
1051 .as_str()
1052 .expect("reason")
1053 .contains("NOT a clean result")
1054 );
1055
1056 let layers = vec![FindingsLayer {
1057 run: run("semgrep", None),
1058 findings: Vec::new(),
1059 }];
1060 let clean = security_status(root, None, "p", &layers, "2026-08-19T00:00:00Z");
1061 let json = serde_json::to_value(&clean).expect("serialise");
1062 assert_eq!(json["repository"]["coverage"], "analyzed");
1063 assert_eq!(json["repository"]["layers"][0]["findings"], 0);
1064 }
1065
1066 /// The three states, and the precedence between them (issue #464).
1067 ///
1068 /// The table is exhaustive over the two facts on purpose: the defect being
1069 /// fixed is one `bool` standing in for two, so the test that matters is the one
1070 /// that walks all four combinations and shows that three distinct answers come
1071 /// out — and that the fourth, both-missing, is not silently the same as
1072 /// "binary missing".
1073 #[test]
1074 fn readiness_names_the_remedy_that_applies() {
1075 assert_eq!(readiness(true, &[]), Readiness::Ready);
1076 assert_eq!(
1077 readiness(false, &[]),
1078 Readiness::AssetsNotProvisioned,
1079 "assets missing, binary present"
1080 );
1081 assert_eq!(
1082 readiness(true, &["semgrep"]),
1083 Readiness::BinaryNotFound,
1084 "the state the old `ready: bool` could not express"
1085 );
1086 // Both missing names the asset side, because `prefetch` is the step Roteiro
1087 // itself performs and the one to take first. The other fact is not lost —
1088 // `AnalyzerCoverage` carries `missing_programs` alongside this verdict, which
1089 // `coverage_matrix_reports_both_facts_not_just_the_verdict` is about.
1090 assert_eq!(
1091 readiness(false, &["semgrep"]),
1092 Readiness::AssetsNotProvisioned,
1093 "both missing must not read as a binary-only problem"
1094 );
1095 }
1096
1097 /// `ready` must mean both things, so a provisioned host with the binary absent
1098 /// is `binary-not-found` and not `ready`.
1099 ///
1100 /// This is issue #464's actual defect, and it is **not reproducible on the
1101 /// machine most likely to look for it**: a developer working on Roteiro has the
1102 /// analyzers installed, so the old `ready` was accidentally true there. The
1103 /// `PATH` probe is therefore injected rather than read from the environment —
1104 /// `unsafe_code = "forbid"` rules out `std::env::set_var`, so a test cannot
1105 /// arrange the absence any other way, and a test that depended on what happens
1106 /// to be installed would pass or fail for reasons that have nothing to do with
1107 /// this code.
1108 #[test]
1109 fn a_provisioned_analyzer_with_no_binary_is_not_ready() {
1110 // An asset root that cannot resolve, so the asset axis is fixed and the only
1111 // thing varying is the probe.
1112 let root = std::path::Path::new("/nonexistent-asset-root");
1113
1114 // Every program present: the asset axis is what is left, and it decides.
1115 let all_present = coverage_matrix_with(root, Some("semgrep"), |_| true);
1116 assert_eq!(
1117 all_present[0].host_readiness,
1118 Readiness::AssetsNotProvisioned
1119 );
1120 assert!(all_present[0].missing_programs.is_empty());
1121
1122 // Nothing present: same asset state, and the verdict still names the asset
1123 // remedy first — but the missing program is reported rather than hidden.
1124 let none_present = coverage_matrix_with(root, Some("semgrep"), |_| false);
1125 assert_eq!(
1126 none_present[0].host_readiness,
1127 Readiness::AssetsNotProvisioned
1128 );
1129 assert_eq!(none_present[0].missing_programs, vec!["semgrep"]);
1130 }
1131
1132 /// All three states through the **public wiring**, on a genuinely provisioned
1133 /// asset cache — which is the only way `ready` and `binary-not-found` are
1134 /// reachable at all.
1135 ///
1136 /// Without this, every `coverage_matrix_with` test would run against an
1137 /// unprovisioned root, so `host_readiness` would be `assets-not-provisioned`
1138 /// whatever the probe said — and a `coverage_matrix_with` that ignored
1139 /// `missing_programs` entirely would pass the lot. That is a guard sampling the
1140 /// cheap projection instead of the claim.
1141 ///
1142 /// `semgrep-rules` is a *vendored* asset, so [`provision`] installs and digests
1143 /// it from bytes already compiled in: no network, no fetcher, and the same
1144 /// function `prefetch` calls, so what is provisioned here is what `resolve`
1145 /// accepts in earnest.
1146 #[test]
1147 fn all_three_states_are_reachable_on_a_provisioned_cache() {
1148 use crate::assets::{assets_for, provision};
1149
1150 let root = std::env::temp_dir().join(format!(
1151 "rto-exec-readiness-{}-{}",
1152 std::process::id(),
1153 line!()
1154 ));
1155 std::fs::remove_dir_all(&root).ok();
1156 for spec in assets_for("semgrep") {
1157 provision(&root, spec).expect("vendored asset provisions with no fetcher");
1158 }
1159
1160 // Assets provisioned, program present: `ready` now means both, which is the
1161 // whole of issue #464.
1162 let ready = coverage_matrix_with(&root, Some("semgrep"), |_| true);
1163 assert_eq!(ready[0].host_readiness, Readiness::Ready);
1164 assert!(ready[0].assets_provisioned);
1165 assert!(ready[0].missing_programs.is_empty());
1166
1167 // Same cache, program absent. This is the case the old `ready: bool`
1168 // reported as `ready`, and the run then failed with `analyzer binary not
1169 // found on PATH`.
1170 let no_binary = coverage_matrix_with(&root, Some("semgrep"), |_| false);
1171 assert_eq!(
1172 no_binary[0].host_readiness,
1173 Readiness::BinaryNotFound,
1174 "provisioned assets alone must not earn the word `ready`"
1175 );
1176 assert!(
1177 no_binary[0].assets_provisioned,
1178 "the asset half is still true, and still reported"
1179 );
1180 assert_eq!(no_binary[0].missing_programs, vec!["semgrep"]);
1181
1182 std::fs::remove_dir_all(&root).ok();
1183 }
1184
1185 /// The verdict is a summary of two published facts, never a replacement for
1186 /// them: a caller told only "not ready" would have to guess which remedy applies.
1187 #[test]
1188 fn coverage_matrix_reports_both_facts_not_just_the_verdict() {
1189 let root = std::path::Path::new("/nonexistent-asset-root");
1190 let rows = coverage_matrix_with(root, None, |_| false);
1191 assert_eq!(rows.len(), 3, "one row per shipped analyzer");
1192 for row in &rows {
1193 let json = serde_json::to_value(row).expect("serialise");
1194 assert_eq!(json["assets_provisioned"], false, "{json}");
1195 assert!(json["host_programs"].is_array(), "{json}");
1196 assert!(json["missing_programs"].is_array(), "{json}");
1197 assert_eq!(json["host_readiness"], "assets-not-provisioned", "{json}");
1198 // The boolean the old shape published is gone, not renamed alongside:
1199 // a consumer reading `ready` was reading a claim about running computed
1200 // from provisioning, and leaving it in place would keep that available.
1201 assert!(json.get("ready").is_none(), "{json}");
1202 }
1203 }
1204
1205 /// `cargo-audit` declares **both** `cargo` and `cargo-audit`, and the second is
1206 /// the one that decides.
1207 ///
1208 /// A probe built from `Invocation::program` would look for `cargo` alone, find it
1209 /// on any Rust developer's machine, and report `ready` in exactly the commonest
1210 /// failure — `cargo` installed, `cargo-audit` not. That is issue #464
1211 /// reintroduced one level down, which is why `Adapter::host_programs` is declared
1212 /// rather than derived.
1213 #[test]
1214 fn cargo_audit_is_not_ready_on_cargo_alone() {
1215 let root = std::path::Path::new("/nonexistent-asset-root");
1216 let rows = coverage_matrix_with(root, Some("cargo-audit"), |program| program == "cargo");
1217 assert_eq!(rows[0].host_programs, &["cargo", "cargo-audit"]);
1218 assert_eq!(
1219 rows[0].missing_programs,
1220 vec!["cargo-audit"],
1221 "`cargo` being present must not stand in for the subcommand binary"
1222 );
1223 }
1224
1225 /// The `PATH` lookup itself: an executable file resolves, a non-executable one
1226 /// does not, and an absent one does not.
1227 ///
1228 /// Against a directory the test owns, because it cannot change this process's
1229 /// `PATH`. The middle case is the point — a readable file with no execute bit is
1230 /// not something the host will run, and treating it as one would be a readiness
1231 /// claim that had not been established.
1232 #[test]
1233 fn the_path_probe_requires_an_executable_file() {
1234 let dir = std::env::temp_dir().join(format!(
1235 "rto-exec-path-probe-{}-{}",
1236 std::process::id(),
1237 line!()
1238 ));
1239 std::fs::create_dir_all(&dir).expect("temp dir");
1240 let exec = dir.join("runnable");
1241 std::fs::write(&exec, b"#!/bin/sh\ntrue\n").expect("write");
1242 let plain = dir.join("not-runnable");
1243 std::fs::write(&plain, b"data").expect("write");
1244 #[cfg(unix)]
1245 {
1246 use std::os::unix::fs::PermissionsExt as _;
1247 std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1248 std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o644))
1249 .expect("chmod");
1250 }
1251
1252 let dirs = vec![dir.clone()];
1253 assert!(program_in(&dirs, "runnable"), "an executable file resolves");
1254 assert!(!program_in(&dirs, "absent"), "a name with no file does not");
1255 #[cfg(unix)]
1256 assert!(
1257 !program_in(&dirs, "not-runnable"),
1258 "a file with no execute bit is not something this host runs"
1259 );
1260 // A name with a separator is a path rather than a lookup, matching what
1261 // `Command::new` would do with it.
1262 assert!(program_in(&[], exec.to_str().expect("utf-8")));
1263 assert!(!program_in(&[], "/nonexistent/runnable"));
1264
1265 std::fs::remove_dir_all(&dir).ok();
1266 }
1267
1268 /// `possibly_stale` is true whenever an advisory database is involved and
1269 /// false only when the result has no advisory-data axis at all — never
1270 /// "current". One computation, shared by the CLI and both tool surfaces.
1271 #[test]
1272 fn possibly_stale_tracks_the_presence_of_an_advisory_database() {
1273 let with_db = FindingsLayer {
1274 run: run(
1275 "cargo-audit",
1276 Some(AdvisoryDb {
1277 digest: "abc".to_owned(),
1278 published_at: Some("2026-08-09T00:00:00Z".to_owned()),
1279 }),
1280 ),
1281 findings: Vec::new(),
1282 };
1283 let without = FindingsLayer {
1284 run: run("semgrep", None),
1285 findings: Vec::new(),
1286 };
1287 let rows = layer_staleness(&[with_db, without], "2026-08-19T00:00:00Z");
1288 assert!(rows[0].possibly_stale);
1289 assert_eq!(rows[0].advisory_db_age_days, Some(10));
1290 assert!(!rows[1].possibly_stale);
1291 assert!(rows[1].advisory_db_age_days.is_none());
1292 }
1293}