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