Skip to main content

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 osv_scanner;
41pub mod semgrep;
42
43/// Everything an adapter may need that is *not* in the analyzer's own output.
44///
45/// Native analyzer output is missing things the evidence chain requires — no
46/// mainstream analyzer stamps its report with the wall-clock window it ran in,
47/// and `cargo audit` does not even record its own version. Rather than let an
48/// adapter invent them, the caller supplies what it actually knows, and an
49/// adapter that has nothing better says so ([`UNKNOWN_VERSION`]).
50#[derive(Clone)]
51pub struct NativeContext<'a> {
52    /// When the run started, RFC 3339 UTC. A subprocess run measures it; an
53    /// ingest of a report file uses the file's modification time, which is the
54    /// only timestamp evidence a bare report carries.
55    pub started_at: String,
56    /// When the run ended, RFC 3339 UTC.
57    pub ended_at: String,
58    /// The analyzer's version, where the caller learned it out of band (a
59    /// subprocess run asks the binary). `None` leaves the adapter to use
60    /// whatever the report itself carries.
61    pub analyzer_version: Option<String>,
62    /// The analyzer's process exit status, where the caller observed it.
63    pub exit_status: i32,
64    /// The source identity the run was against. Some identity recipes need it —
65    /// `cargo-audit` keys findings by lockfile blob, so a finding stays distinct
66    /// when the lockfile changes underneath the same advisory.
67    pub source: &'a SourceIdentity,
68    /// Digest of the rule set the analyzer ran with, where one applies.
69    pub rules_digest: Option<String>,
70    /// The pinned advisory database the caller provisioned, where one applies.
71    ///
72    /// A fallback, not an override: an adapter prefers what the analyzer's own
73    /// report says about the database it consulted, and uses this only when the
74    /// report says nothing. `cargo audit` says nothing whenever it is pointed at
75    /// a database with `--db`, which is every pinned run — so without this, the
76    /// reproducible configuration would be the one with no staleness evidence.
77    pub advisory_db: Option<rto_graph::AdvisoryDb>,
78    /// The checkout the report describes, where the caller knows it.
79    ///
80    /// Only an adapter whose analyzer reports **absolute** paths needs this, and
81    /// `osv-scanner` is that adapter: it returns a full filesystem path for every
82    /// manifest even when it is told to scan `.`. Without the worktree there is
83    /// nothing to relativise against, so an absolute path would be stored
84    /// verbatim — user-identifying data in a persisted finding key, and a key
85    /// that differs between two machines running the identical scan.
86    ///
87    /// `None` is the honest answer for a report about a tree this checkout does
88    /// not have; an adapter must then say the location is unknown rather than
89    /// guess at one.
90    pub worktree: Option<&'a std::path::Path>,
91    /// Where to read the source a finding points at, for identity recipes that
92    /// include a snippet hash.
93    ///
94    /// It is here rather than inside an adapter because the *caller* knows which
95    /// checkout the report describes, and because both execution paths must read
96    /// the same one — that is what makes a subprocess run and an ingest of its
97    /// output produce identical finding keys.
98    pub snippets: &'a dyn SnippetSource,
99}
100
101// Hand-written because `&dyn SnippetSource` is not `Debug` and does not need to
102// be: what a debug print of a context should show is the evidence it carries,
103// not the identity of the thing that reads files.
104impl std::fmt::Debug for NativeContext<'_> {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.debug_struct("NativeContext")
107            .field("started_at", &self.started_at)
108            .field("ended_at", &self.ended_at)
109            .field("analyzer_version", &self.analyzer_version)
110            .field("exit_status", &self.exit_status)
111            .field("source", self.source)
112            .field("rules_digest", &self.rules_digest)
113            .field("advisory_db", &self.advisory_db)
114            .field("worktree", &self.worktree)
115            .finish_non_exhaustive()
116    }
117}
118
119impl NativeContext<'_> {
120    /// The version to record: what the caller learned, else what the report
121    /// carried, else [`UNKNOWN_VERSION`].
122    ///
123    /// Never empty — [`crate::IngestRunner`] refuses a report that cannot say
124    /// what version produced it, and "unknown" is a truthful answer where an
125    /// empty string is a missing one.
126    #[must_use]
127    pub fn version_or(&self, from_report: Option<&str>) -> String {
128        self.analyzer_version
129            .as_deref()
130            .or(from_report)
131            .map(str::trim)
132            .filter(|v| !v.is_empty())
133            .unwrap_or(UNKNOWN_VERSION)
134            .to_owned()
135    }
136}
137
138/// Recorded as an analyzer's version when neither the caller nor the report
139/// knows it — which is the ordinary case for a `cargo audit` report ingested
140/// from CI, since its JSON has no version field.
141pub const UNKNOWN_VERSION: &str = "unknown";
142
143/// How an analyzer is invoked as a child process.
144///
145/// Returned by [`Adapter::command`] and consumed by the subprocess runner, so
146/// the argv lives beside the parser that understands its output rather than in
147/// the runner, which knows no analyzer.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Invocation {
150    /// The program to execute, looked up on `PATH` unless it is a path.
151    pub program: String,
152    /// Its arguments, in order.
153    pub args: Vec<String>,
154    /// Exit statuses that mean "the analyzer ran and produced a report".
155    ///
156    /// Analyzers overload the exit status: `semgrep` exits `1` when it found
157    /// something, `cargo audit` exits `1` on a vulnerability. Treating non-zero
158    /// as failure would discard exactly the runs that matter, so each adapter
159    /// declares which statuses carry a usable report and every other status is a
160    /// hard failure.
161    pub success_statuses: Vec<i32>,
162}
163
164/// One analyzer's native output format and invocation.
165pub trait Adapter: Sync + std::fmt::Debug {
166    /// The analyzer id — the value that appears in every layer key and finding
167    /// key this adapter produces.
168    fn analyzer(&self) -> &'static str;
169
170    /// A one-line description of what it looks for, for `roteiro security
171    /// status` and `--help`.
172    fn summary(&self) -> &'static str;
173
174    /// The languages this adapter produces findings for, as the coverage matrix
175    /// in ADR-0018 states them. Reported by the CLI so the claim is inspectable
176    /// rather than only documented.
177    fn languages(&self) -> &'static [&'static str];
178
179    /// Which pinned assets the analyzer needs before it can run offline (see
180    /// [`crate::assets`]). An empty slice means it needs none.
181    fn asset_ids(&self) -> &'static [&'static str];
182
183    /// The argv that makes the analyzer emit the native format
184    /// [`Adapter::normalize`] parses, with egress configured off.
185    ///
186    /// `assets` maps an id from [`Adapter::asset_ids`] to the verified local
187    /// path it was provisioned to.
188    fn command(&self, assets: &AssetPaths<'_>) -> Invocation;
189
190    /// Parse native output into a normalized report.
191    ///
192    /// # Errors
193    /// Returns [`ExecError::MalformedReport`] when the bytes are not this
194    /// analyzer's format, or [`ExecError::Json`] when they are not JSON at all.
195    /// A partially-parsed report is never returned: either the whole thing
196    /// converts or the run fails.
197    fn normalize(
198        &self,
199        native: &[u8],
200        ctx: &NativeContext<'_>,
201    ) -> Result<NormalizedReport, ExecError>;
202}
203
204/// Verified local paths of an analyzer's provisioned assets, keyed by asset id.
205#[derive(Debug, Clone, Copy, Default)]
206pub struct AssetPaths<'a> {
207    entries: &'a [(&'a str, std::path::PathBuf)],
208}
209
210impl<'a> AssetPaths<'a> {
211    /// Wrap a resolved id → path list.
212    #[must_use]
213    pub fn new(entries: &'a [(&'a str, std::path::PathBuf)]) -> Self {
214        Self { entries }
215    }
216
217    /// The path provisioned for `id`, or `None` if it was not resolved.
218    ///
219    /// An adapter that asked for an asset in [`Adapter::asset_ids`] will always
220    /// find it here, because the runner refuses to start otherwise — see
221    /// [`ExecError::AssetsUnavailableOffline`].
222    #[must_use]
223    pub fn get(&self, id: &str) -> Option<&std::path::Path> {
224        self.entries
225            .iter()
226            .find(|(key, _)| *key == id)
227            .map(|(_, path)| path.as_path())
228    }
229
230    /// The path provisioned for `id` as a string, or an empty string. Adapters
231    /// build argv from this; an unresolved asset cannot reach here.
232    #[must_use]
233    pub fn arg(&self, id: &str) -> String {
234        self.get(id)
235            .map(|p| p.to_string_lossy().into_owned())
236            .unwrap_or_default()
237    }
238}
239
240/// Every analyzer this build knows how to normalise.
241///
242/// Ingest consults this table, so a report from any of them can be read in from
243/// CI whether or not this build can *execute* the analyzer — which is the whole
244/// point of ADR-0014's "ingest is always available".
245pub static ADAPTERS: &[&dyn Adapter] = &[
246    &semgrep::Semgrep,
247    &cargo_audit::CargoAudit,
248    &osv_scanner::OsvScanner,
249];
250
251/// The adapter for `analyzer`, or `None` if this build has none.
252#[must_use]
253pub fn adapter_for(analyzer: &str) -> Option<&'static dyn Adapter> {
254    ADAPTERS.iter().copied().find(|a| a.analyzer() == analyzer)
255}
256
257/// Every analyzer id this build can normalise, sorted — for error messages that
258/// tell a caller what it *could* have asked for.
259#[must_use]
260pub fn known_analyzers() -> Vec<&'static str> {
261    let mut ids: Vec<&'static str> = ADAPTERS.iter().map(|a| a.analyzer()).collect();
262    ids.sort_unstable();
263    ids
264}
265
266/// Recorded in place of a snippet hash when the source could not be read — an
267/// ingested report about a tree this checkout does not have.
268///
269/// A named marker rather than a hash of the empty string, so a reader of a
270/// finding key can tell "the code was empty" from "the code was unavailable".
271pub const NO_SNIPPET: &str = "no-snippet";
272
273/// Short SHA-256 prefix of a snippet, used by identity recipes that need to
274/// notice that the *code* at a location changed even though the location did
275/// not.
276///
277/// Sixteen hex characters is 64 bits — far more than enough to keep two
278/// snippets at the same rule and offset distinct, and short enough that a
279/// rendered key stays readable in a terminal. Leading and trailing whitespace is
280/// stripped first, so a reformat that only moved indentation is not a new
281/// finding.
282#[must_use]
283pub fn snippet_hash(snippet: &str) -> String {
284    crate::sha256_hex(snippet.trim().as_bytes())[..16].to_owned()
285}
286
287/// [`snippet_hash`] of what `snippets` holds for the span, or [`NO_SNIPPET`].
288#[must_use]
289pub fn snippet_hash_at(snippets: &dyn SnippetSource, path: &str, start: u32, end: u32) -> String {
290    snippets
291        .snippet(path, start, end)
292        .map_or_else(|| NO_SNIPPET.to_owned(), |text| snippet_hash(&text))
293}
294
295#[cfg(test)]
296mod tests {
297    use super::{
298        AssetPaths, NO_SNIPPET, NativeContext, UNKNOWN_VERSION, adapter_for, known_analyzers,
299        snippet_hash, snippet_hash_at,
300    };
301    use rto_graph::SourceIdentity;
302
303    fn ctx(version: Option<&str>) -> NativeContext<'static> {
304        static SOURCE: std::sync::LazyLock<SourceIdentity> =
305            std::sync::LazyLock::new(SourceIdentity::default);
306        NativeContext {
307            started_at: "2026-08-15T09:00:00Z".to_owned(),
308            ended_at: "2026-08-15T09:00:04Z".to_owned(),
309            analyzer_version: version.map(str::to_owned),
310            exit_status: 0,
311            source: &SOURCE,
312            rules_digest: None,
313            advisory_db: None,
314            worktree: None,
315            snippets: &crate::snippet::NoSnippets,
316        }
317    }
318
319    #[test]
320    fn the_registry_answers_for_every_analyzer_it_lists() {
321        for id in known_analyzers() {
322            assert_eq!(adapter_for(id).expect("registered").analyzer(), id);
323        }
324        assert!(adapter_for("no-such-analyzer").is_none());
325    }
326
327    /// Every shipped adapter claims at least one language and a summary, because
328    /// `roteiro security status` prints the coverage matrix from this table —
329    /// an adapter that claims nothing would silently shrink the reported
330    /// coverage.
331    #[test]
332    fn every_adapter_states_its_coverage() {
333        for id in known_analyzers() {
334            let adapter = adapter_for(id).expect("registered");
335            assert!(!adapter.languages().is_empty(), "{id} claims no language");
336            assert!(!adapter.summary().is_empty(), "{id} has no summary");
337        }
338    }
339
340    #[test]
341    fn a_version_is_taken_from_the_caller_then_the_report_then_unknown() {
342        assert_eq!(ctx(Some("1.2.3")).version_or(Some("0.0.1")), "1.2.3");
343        assert_eq!(ctx(None).version_or(Some("0.0.1")), "0.0.1");
344        assert_eq!(ctx(None).version_or(None), UNKNOWN_VERSION);
345        // Whitespace is not a version: an all-blank field would be refused
346        // downstream as missing evidence, so it is treated as absent here.
347        assert_eq!(ctx(Some("  ")).version_or(None), UNKNOWN_VERSION);
348    }
349
350    #[test]
351    fn snippet_hashes_are_short_stable_and_whitespace_insensitive() {
352        let hash = snippet_hash("eval(user_input)");
353        assert_eq!(hash.len(), 16);
354        assert_eq!(hash, snippet_hash("  eval(user_input)\n"));
355        assert_ne!(hash, snippet_hash("eval(other_input)"));
356    }
357
358    /// A report about a tree this checkout does not have still yields a
359    /// well-formed identity, and one that says why it is weaker.
360    #[test]
361    fn an_unavailable_snippet_is_named_not_hashed_as_empty() {
362        let hash = snippet_hash_at(&crate::snippet::NoSnippets, "a.py", 0, 4);
363        assert_eq!(hash, NO_SNIPPET);
364        assert_ne!(hash, snippet_hash(""));
365    }
366
367    #[test]
368    fn asset_paths_resolve_only_what_was_provisioned() {
369        let entries = [("semgrep-rules", std::path::PathBuf::from("/cache/r.yaml"))];
370        let paths = AssetPaths::new(&entries);
371        assert_eq!(paths.arg("semgrep-rules"), "/cache/r.yaml");
372        assert!(paths.get("advisory-db").is_none());
373        assert!(paths.arg("advisory-db").is_empty());
374    }
375}