Skip to main content

rto_exec/
tool_security.rs

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