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