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//! `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}