rto_exec/adapter.rs
1//! Per-analyzer adapters: native analyzer output in, a [`NormalizedReport`] out.
2//!
3//! An adapter is the **only** analyzer-specific code in this crate. It knows one
4//! tool's native JSON, how to name that tool's findings so they are recognisable
5//! across runs, and which argv produces that JSON. Everything downstream — the
6//! validation, the identity keys, the ordering, the store — is shared.
7//!
8//! # Why this is the seam, and not the runner
9//!
10//! ADR-0012 requires that "a finding is the same artifact whether it was produced
11//! locally in a sandbox or ingested from a CI report". The cheap way to satisfy
12//! that is to write the conversion twice and add a test comparing the two. This
13//! crate does the other thing: **there is one conversion**, and both paths call
14//! it. A subprocess run captures the analyzer's stdout and hands those bytes to
15//! the adapter; `roteiro security ingest` reads a file of the same native bytes
16//! and hands them to the same adapter. Equality of the resulting [`Finding`]s is
17//! therefore a property of the code, and the tests that assert it are guarding
18//! against a future refactor rather than establishing the invariant.
19//!
20//! [`Finding`]: rto_graph::Finding
21//!
22//! # Adding an analyzer needs no migration
23//!
24//! [`rto_graph::FindingKey`] is `finding:<analyzer>:<that analyzer's own ordered
25//! identity components>`. An adapter chooses the recipe; the schema never learns
26//! what the components mean. So a new analyzer is a new file in `adapters/`, an
27//! entry in [`ADAPTERS`], and nothing else — no schema change, no migration.
28//!
29//! @rto:0012
30//! @rto:0014
31//! @rto:0018
32
33use rto_graph::SourceIdentity;
34
35use crate::ingest::NormalizedReport;
36use crate::runner::ExecError;
37use crate::snippet::SnippetSource;
38
39pub mod cargo_audit;
40pub mod clippy;
41pub mod osv_scanner;
42pub mod semgrep;
43
44/// Everything an adapter may need that is *not* in the analyzer's own output.
45///
46/// Native analyzer output is missing things the evidence chain requires — no
47/// mainstream analyzer stamps its report with the wall-clock window it ran in,
48/// and `cargo audit` does not even record its own version. Rather than let an
49/// adapter invent them, the caller supplies what it actually knows, and an
50/// adapter that has nothing better says so ([`UNKNOWN_VERSION`]).
51#[derive(Clone)]
52pub struct NativeContext<'a> {
53 /// When the run started, RFC 3339 UTC. A subprocess run measures it; an
54 /// ingest of a report file uses the file's modification time, which is the
55 /// only timestamp evidence a bare report carries.
56 pub started_at: String,
57 /// When the run ended, RFC 3339 UTC.
58 pub ended_at: String,
59 /// The analyzer's version, where the caller learned it out of band (a
60 /// subprocess run asks the binary). `None` leaves the adapter to use
61 /// whatever the report itself carries.
62 pub analyzer_version: Option<String>,
63 /// The analyzer's process exit status, where the caller observed it.
64 pub exit_status: i32,
65 /// The source identity the run was against. Some identity recipes need it —
66 /// `cargo-audit` keys findings by lockfile blob, so a finding stays distinct
67 /// when the lockfile changes underneath the same advisory.
68 pub source: &'a SourceIdentity,
69 /// Digest of the rule set the analyzer ran with, where one applies.
70 pub rules_digest: Option<String>,
71 /// The pinned advisory database the caller provisioned, where one applies.
72 ///
73 /// A fallback, not an override: an adapter prefers what the analyzer's own
74 /// report says about the database it consulted, and uses this only when the
75 /// report says nothing. `cargo audit` says nothing whenever it is pointed at
76 /// a database with `--db`, which is every pinned run — so without this, the
77 /// reproducible configuration would be the one with no staleness evidence.
78 pub advisory_db: Option<rto_graph::AdvisoryDb>,
79 /// The checkout the report describes, where the caller knows it.
80 ///
81 /// Only an adapter whose analyzer reports **absolute** paths needs this, and
82 /// `osv-scanner` is that adapter: it returns a full filesystem path for every
83 /// manifest even when it is told to scan `.`. Without the worktree there is
84 /// nothing to relativise against, so an absolute path would be stored
85 /// verbatim — user-identifying data in a persisted finding key, and a key
86 /// that differs between two machines running the identical scan.
87 ///
88 /// `None` is the honest answer for a report about a tree this checkout does
89 /// not have; an adapter must then say the location is unknown rather than
90 /// guess at one.
91 pub worktree: Option<&'a std::path::Path>,
92 /// Where to read the source a finding points at, for identity recipes that
93 /// include a snippet hash.
94 ///
95 /// It is here rather than inside an adapter because the *caller* knows which
96 /// checkout the report describes, and because both execution paths must read
97 /// the same one — that is what makes a subprocess run and an ingest of its
98 /// output produce identical finding keys.
99 pub snippets: &'a dyn SnippetSource,
100}
101
102// Hand-written because `&dyn SnippetSource` is not `Debug` and does not need to
103// be: what a debug print of a context should show is the evidence it carries,
104// not the identity of the thing that reads files.
105impl std::fmt::Debug for NativeContext<'_> {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 f.debug_struct("NativeContext")
108 .field("started_at", &self.started_at)
109 .field("ended_at", &self.ended_at)
110 .field("analyzer_version", &self.analyzer_version)
111 .field("exit_status", &self.exit_status)
112 .field("source", self.source)
113 .field("rules_digest", &self.rules_digest)
114 .field("advisory_db", &self.advisory_db)
115 .field("worktree", &self.worktree)
116 .finish_non_exhaustive()
117 }
118}
119
120impl NativeContext<'_> {
121 /// The version to record: what the caller learned, else what the report
122 /// carried, else [`UNKNOWN_VERSION`].
123 ///
124 /// Never empty — [`crate::IngestRunner`] refuses a report that cannot say
125 /// what version produced it, and "unknown" is a truthful answer where an
126 /// empty string is a missing one.
127 #[must_use]
128 pub fn version_or(&self, from_report: Option<&str>) -> String {
129 self.analyzer_version
130 .as_deref()
131 .or(from_report)
132 .map(str::trim)
133 .filter(|v| !v.is_empty())
134 .unwrap_or(UNKNOWN_VERSION)
135 .to_owned()
136 }
137}
138
139/// Recorded as an analyzer's version when neither the caller nor the report
140/// knows it — which is the ordinary case for a `cargo audit` report ingested
141/// from CI, since its JSON has no version field.
142pub const UNKNOWN_VERSION: &str = "unknown";
143
144/// How an analyzer is invoked as a child process.
145///
146/// Returned by [`Adapter::command`] and consumed by the subprocess runner, so
147/// the argv lives beside the parser that understands its output rather than in
148/// the runner, which knows no analyzer.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct Invocation {
151 /// The program to execute, looked up on `PATH` unless it is a path.
152 pub program: String,
153 /// Its arguments, in order.
154 pub args: Vec<String>,
155 /// Exit statuses that mean "the analyzer ran and produced a report".
156 ///
157 /// Analyzers overload the exit status: `semgrep` exits `1` when it found
158 /// something, `cargo audit` exits `1` on a vulnerability. Treating non-zero
159 /// as failure would discard exactly the runs that matter, so each adapter
160 /// declares which statuses carry a usable report and every other status is a
161 /// hard failure.
162 pub success_statuses: Vec<i32>,
163}
164
165/// One analyzer's native output format and invocation.
166pub trait Adapter: Sync + std::fmt::Debug {
167 /// The analyzer id — the value that appears in every layer key and finding
168 /// key this adapter produces.
169 fn analyzer(&self) -> &'static str;
170
171 /// A one-line description of what it looks for, for `roteiro security
172 /// status` and `--help`.
173 fn summary(&self) -> &'static str;
174
175 /// The languages this adapter produces findings for, as the coverage matrix
176 /// in ADR-0018 states them. Reported by the CLI so the claim is inspectable
177 /// rather than only documented.
178 fn languages(&self) -> &'static [&'static str];
179
180 /// Which pinned assets the analyzer needs before it can run offline (see
181 /// [`crate::assets`]). An empty slice means it needs none.
182 fn asset_ids(&self) -> &'static [&'static str];
183
184 /// Which programs must be on `PATH` for this analyzer to run **on this host**,
185 /// in the order a reader would install them.
186 ///
187 /// The counterpart of [`Adapter::asset_ids`], and the reason both exist:
188 /// asset ids are what Roteiro *provisions*, and these are what it
189 /// deliberately **never installs** (ADR-0014). `roteiro security status`
190 /// reports the two separately because their remedies differ — `prefetch` for
191 /// the first, an install the host owner performs for the second — and
192 /// collapsing them into one word is issue #464.
193 ///
194 /// # Why this is declared and not read off [`Adapter::command`]
195 ///
196 /// Because [`Invocation::program`] is not always the thing to look for.
197 /// `cargo-audit`'s program is `cargo`, and `cargo audit` dispatches to a
198 /// separate `cargo-audit` binary on `PATH` — so probing `Invocation::program`
199 /// would find `cargo` on any Rust developer's machine and report *ready* in
200 /// precisely the commonest failure, `cargo` installed and `cargo-audit` not.
201 /// That is the defect #464 is about, reintroduced one level down. An adapter
202 /// therefore states its own requirement.
203 ///
204 /// Empty means the analyzer needs nothing on `PATH`.
205 fn host_programs(&self) -> &'static [&'static str];
206
207 /// The argv that makes the analyzer emit the native format
208 /// [`Adapter::normalize`] parses, with egress configured off.
209 ///
210 /// `assets` maps an id from [`Adapter::asset_ids`] to the verified local
211 /// path it was provisioned to.
212 fn command(&self, assets: &AssetPaths<'_>) -> Invocation;
213
214 /// Parse native output into a normalized report.
215 ///
216 /// # Errors
217 /// Returns [`ExecError::MalformedReport`] when the bytes are not this
218 /// analyzer's format, or [`ExecError::Json`] when they are not JSON at all.
219 /// A partially-parsed report is never returned: either the whole thing
220 /// converts or the run fails.
221 fn normalize(
222 &self,
223 native: &[u8],
224 ctx: &NativeContext<'_>,
225 ) -> Result<NormalizedReport, ExecError>;
226}
227
228/// Verified local paths of an analyzer's provisioned assets, keyed by asset id.
229#[derive(Debug, Clone, Copy, Default)]
230pub struct AssetPaths<'a> {
231 entries: &'a [(&'a str, std::path::PathBuf)],
232}
233
234impl<'a> AssetPaths<'a> {
235 /// Wrap a resolved id → path list.
236 #[must_use]
237 pub fn new(entries: &'a [(&'a str, std::path::PathBuf)]) -> Self {
238 Self { entries }
239 }
240
241 /// The path provisioned for `id`, or `None` if it was not resolved.
242 ///
243 /// An adapter that asked for an asset in [`Adapter::asset_ids`] will always
244 /// find it here, because the runner refuses to start otherwise — see
245 /// [`ExecError::AssetsUnavailableOffline`].
246 #[must_use]
247 pub fn get(&self, id: &str) -> Option<&std::path::Path> {
248 self.entries
249 .iter()
250 .find(|(key, _)| *key == id)
251 .map(|(_, path)| path.as_path())
252 }
253
254 /// The path provisioned for `id` as a string, or an empty string. Adapters
255 /// build argv from this; an unresolved asset cannot reach here.
256 #[must_use]
257 pub fn arg(&self, id: &str) -> String {
258 self.get(id)
259 .map(|p| p.to_string_lossy().into_owned())
260 .unwrap_or_default()
261 }
262}
263
264/// Every analyzer whose findings this build can **store**.
265///
266/// Ingest consults this table, so a report from any of them can be read in from
267/// CI whether or not this build can *execute* the analyzer — which is the whole
268/// point of ADR-0014's "ingest is always available".
269///
270/// # `clippy` is an adapter and is deliberately not here
271///
272/// [`clippy::Clippy`] implements the trait above and is reached only by
273/// `roteiro lint`, which reports and stores nothing. Membership of this table is
274/// what makes an analyzer storable — it is how `ingest` resolves `--analyzer`,
275/// and everything it resolves ends at
276/// [`rto_graph::Store::replace_findings_layer`]. Leaving a linter out is
277/// therefore the mechanism, not a note: there is no `--analyzer clippy` to
278/// accept and no layer key for two runs at different toolchains to collide over.
279/// ADR-0020 v1.1 is the decision, and [`clippy`]'s module documentation is the
280/// reasoning. **Adding it here would silently make lint output an artifact.**
281pub static ADAPTERS: &[&dyn Adapter] = &[
282 &semgrep::Semgrep,
283 &cargo_audit::CargoAudit,
284 &osv_scanner::OsvScanner,
285];
286
287/// Every analyzer `roteiro lint` can run.
288///
289/// A separate list from [`known_analyzers`], which answers a different question
290/// — *what can be stored* — and would name `semgrep` and `cargo-audit` here,
291/// sending a caller off to ask for a lint from an analyzer that files layers.
292/// It sits beside [`ADAPTERS`] rather than in [`crate::lint`] so that the two
293/// lists are read together: they are the same shape and deliberately disjoint,
294/// and a name that drifted into both would make a lint storable by accident.
295///
296/// Ungated, unlike the linter itself, for [`crate::lint_grant`]'s reason: what
297/// `roteiro lint` *could* run is a question a build that cannot run it still has
298/// to answer, and `roteiro security prefetch --analyzer clippy` is one of the
299/// callers that asks.
300pub const LINT_ANALYZERS: &[&str] = &[clippy::ANALYZER];
301
302/// The adapter for `analyzer`, or `None` if this build has none.
303#[must_use]
304pub fn adapter_for(analyzer: &str) -> Option<&'static dyn Adapter> {
305 ADAPTERS.iter().copied().find(|a| a.analyzer() == analyzer)
306}
307
308/// Every analyzer id this build can normalise, sorted — for error messages that
309/// tell a caller what it *could* have asked for.
310#[must_use]
311pub fn known_analyzers() -> Vec<&'static str> {
312 let mut ids: Vec<&'static str> = ADAPTERS.iter().map(|a| a.analyzer()).collect();
313 ids.sort_unstable();
314 ids
315}
316
317/// Recorded in place of a snippet hash when the source could not be read — an
318/// ingested report about a tree this checkout does not have.
319///
320/// A named marker rather than a hash of the empty string, so a reader of a
321/// finding key can tell "the code was empty" from "the code was unavailable".
322pub const NO_SNIPPET: &str = "no-snippet";
323
324/// Short SHA-256 prefix of a snippet, used by identity recipes that need to
325/// notice that the *code* at a location changed even though the location did
326/// not.
327///
328/// Sixteen hex characters is 64 bits — far more than enough to keep two
329/// snippets at the same rule and offset distinct, and short enough that a
330/// rendered key stays readable in a terminal. Leading and trailing whitespace is
331/// stripped first, so a reformat that only moved indentation is not a new
332/// finding.
333#[must_use]
334pub fn snippet_hash(snippet: &str) -> String {
335 crate::sha256_hex(snippet.trim().as_bytes())[..16].to_owned()
336}
337
338/// [`snippet_hash`] of what `snippets` holds for the span, or [`NO_SNIPPET`].
339#[must_use]
340pub fn snippet_hash_at(snippets: &dyn SnippetSource, path: &str, start: u32, end: u32) -> String {
341 snippets
342 .snippet(path, start, end)
343 .map_or_else(|| NO_SNIPPET.to_owned(), |text| snippet_hash(&text))
344}
345
346#[cfg(test)]
347mod tests {
348 use super::{
349 Adapter as _, AssetPaths, NO_SNIPPET, NativeContext, UNKNOWN_VERSION, adapter_for,
350 known_analyzers, snippet_hash, snippet_hash_at,
351 };
352 use rto_graph::SourceIdentity;
353
354 fn ctx(version: Option<&str>) -> NativeContext<'static> {
355 static SOURCE: std::sync::LazyLock<SourceIdentity> =
356 std::sync::LazyLock::new(SourceIdentity::default);
357 NativeContext {
358 started_at: "2026-08-15T09:00:00Z".to_owned(),
359 ended_at: "2026-08-15T09:00:04Z".to_owned(),
360 analyzer_version: version.map(str::to_owned),
361 exit_status: 0,
362 source: &SOURCE,
363 rules_digest: None,
364 advisory_db: None,
365 worktree: None,
366 snippets: &crate::snippet::NoSnippets,
367 }
368 }
369
370 #[test]
371 fn the_registry_answers_for_every_analyzer_it_lists() {
372 for id in known_analyzers() {
373 assert_eq!(adapter_for(id).expect("registered").analyzer(), id);
374 }
375 assert!(adapter_for("no-such-analyzer").is_none());
376 }
377
378 /// Every registered analyzer is one whose findings are **stored**, so each
379 /// one must have a pinned rule set or database to decide the answer. A
380 /// linter has neither — its rules are the toolchain — which is why clippy
381 /// has an adapter and no registry entry, and why this asserts the property
382 /// rather than the name: a future storable analyzer with no asset would fail
383 /// here and have to argue its case.
384 #[test]
385 fn every_storable_analyzer_pins_what_decides_its_answer() {
386 for id in known_analyzers() {
387 let adapter = adapter_for(id).expect("registered");
388 assert!(
389 !adapter.asset_ids().is_empty(),
390 "{id} is stored but pins nothing that decides its findings"
391 );
392 }
393 assert!(
394 super::clippy::Clippy.asset_ids().is_empty(),
395 "a linter has no pinned rule set — that is why it is not stored"
396 );
397 }
398
399 /// Every shipped adapter claims at least one language and a summary, because
400 /// `roteiro security status` prints the coverage matrix from this table —
401 /// an adapter that claims nothing would silently shrink the reported
402 /// coverage.
403 #[test]
404 fn every_adapter_states_its_coverage() {
405 for id in known_analyzers() {
406 let adapter = adapter_for(id).expect("registered");
407 assert!(!adapter.languages().is_empty(), "{id} claims no language");
408 assert!(!adapter.summary().is_empty(), "{id} has no summary");
409 }
410 }
411
412 #[test]
413 fn a_version_is_taken_from_the_caller_then_the_report_then_unknown() {
414 assert_eq!(ctx(Some("1.2.3")).version_or(Some("0.0.1")), "1.2.3");
415 assert_eq!(ctx(None).version_or(Some("0.0.1")), "0.0.1");
416 assert_eq!(ctx(None).version_or(None), UNKNOWN_VERSION);
417 // Whitespace is not a version: an all-blank field would be refused
418 // downstream as missing evidence, so it is treated as absent here.
419 assert_eq!(ctx(Some(" ")).version_or(None), UNKNOWN_VERSION);
420 }
421
422 #[test]
423 fn snippet_hashes_are_short_stable_and_whitespace_insensitive() {
424 let hash = snippet_hash("eval(user_input)");
425 assert_eq!(hash.len(), 16);
426 assert_eq!(hash, snippet_hash(" eval(user_input)\n"));
427 assert_ne!(hash, snippet_hash("eval(other_input)"));
428 }
429
430 /// A report about a tree this checkout does not have still yields a
431 /// well-formed identity, and one that says why it is weaker.
432 #[test]
433 fn an_unavailable_snippet_is_named_not_hashed_as_empty() {
434 let hash = snippet_hash_at(&crate::snippet::NoSnippets, "a.py", 0, 4);
435 assert_eq!(hash, NO_SNIPPET);
436 assert_ne!(hash, snippet_hash(""));
437 }
438
439 #[test]
440 fn asset_paths_resolve_only_what_was_provisioned() {
441 let entries = [("semgrep-rules", std::path::PathBuf::from("/cache/r.yaml"))];
442 let paths = AssetPaths::new(&entries);
443 assert_eq!(paths.arg("semgrep-rules"), "/cache/r.yaml");
444 assert!(paths.get("advisory-db").is_none());
445 assert!(paths.arg("advisory-db").is_empty());
446 }
447}