Skip to main content

rto_exec/
runner.rs

1//! The contract every analyzer backend satisfies.
2
3use std::path::{Component, Path, PathBuf};
4
5use rto_graph::{
6    AnalysisRun, Finding, FindingsError, Isolation, NetworkPolicy, RunnerKind, SourceIdentity,
7    WorktreeAccess, WorktreeId, analyzer_id_error, is_valid_analyzer_id,
8};
9
10use crate::sha256_hex;
11
12/// Errors an analyzer backend can raise.
13#[derive(Debug, thiserror::Error)]
14#[non_exhaustive]
15pub enum ExecError {
16    /// The request did not carry explicit user consent. Running an analyzer is
17    /// never implicit, whatever the backend.
18    #[error("analyzer run requires explicit user consent")]
19    ConsentRequired,
20    /// The request asked for a network policy this backend will not honour.
21    /// Egress is denied; an analyzer's inputs are pre-provisioned, never fetched
22    /// mid-run.
23    #[error("unsupported network policy: this runner only accepts `deny`")]
24    UnsupportedNetworkPolicy,
25    /// The request asked for a writable worktree. Analyzers parse source,
26    /// manifests and lockfiles; none of them needs to write to the tree.
27    #[error("the analyzed worktree must be read-only")]
28    WorktreeNotReadOnly,
29    /// The requested analyzer id is not well-formed: an analyzer id is
30    /// 1..=`MAX_ANALYZER_ID` characters of lowercase `[a-z0-9._-]`.
31    ///
32    /// The message is produced by [`rto_graph::analyzer_id_error`], the same
33    /// function `rto-graph`'s own rejection uses, so an id refused here reads
34    /// exactly as it would had the store caught it — and it names the rule that
35    /// was broken, not just the contract.
36    #[error("{}", analyzer_id_error(.0))]
37    InvalidAnalyzerId(String),
38    /// The report describes a different analyzer than the one requested — a
39    /// mixed-up file, or a report substituted for another.
40    #[error("report is from analyzer {reported:?}, but {requested:?} was requested")]
41    AnalyzerMismatch {
42        /// The analyzer the caller asked for.
43        requested: String,
44        /// The analyzer the report claims to be from.
45        reported: String,
46    },
47    /// The report's schema tag is not one this build understands.
48    #[error("unsupported report schema: {found:?} (expected {expected:?})")]
49    UnsupportedSchema {
50        /// The tag the report carried.
51        found: String,
52        /// The tag this build accepts.
53        expected: &'static str,
54    },
55    /// The report is structurally valid JSON but does not describe a usable run.
56    #[error("malformed report: {0}")]
57    MalformedReport(String),
58    /// The report declares more findings than will be accepted in one run.
59    #[error("report declares {count} findings, more than the {max} accepted in one run")]
60    TooManyFindings {
61        /// How many the report declared.
62        count: usize,
63        /// The accepted ceiling.
64        max: usize,
65    },
66    /// Two findings in one report share an identity, so one would silently
67    /// shadow the other.
68    #[error("duplicate finding identity in report: {0}")]
69    DuplicateFinding(String),
70    /// A finding claimed a path outside the analyzed worktree.
71    #[error("finding path escapes the worktree: {0:?}")]
72    PathEscapesWorktree(String),
73    /// A finding's identity components were not usable as a stable key.
74    #[error("finding identity: {0}")]
75    Identity(#[from] FindingsError),
76    /// The analyzer's pinned inputs are not provisioned, and Roteiro will not
77    /// fetch them mid-run.
78    ///
79    /// This is ADR-0014's named cold-cache failure. The message carries
80    /// everything needed to act on it without a second command: which analyzer,
81    /// which assets, the digest pinned for each, why each one could not be used,
82    /// and the exact `prefetch` invocation. The `assets-unavailable-offline`
83    /// token is part of the message so the failure is greppable and scriptable
84    /// rather than merely readable.
85    #[error(
86        "assets-unavailable-offline: {analyzer} cannot run because its pinned inputs are not \
87         provisioned\n  missing: {}\n  fix it with: {command}\n  \
88         (roteiro never fetches analyzer assets during a run, and never falls back to whatever \
89         the host has installed)",
90        .missing.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n           ")
91    )]
92    AssetsUnavailableOffline {
93        /// The analyzer whose run was refused.
94        analyzer: String,
95        /// Every asset that was missing, unverifiable, or changed underneath its
96        /// record.
97        missing: Vec<crate::assets::MissingAsset>,
98        /// The exact command that provisions them.
99        command: String,
100    },
101    /// The analyzer binary could not be executed, or exited with a status that
102    /// does not carry a usable report.
103    #[cfg(feature = "exec-subprocess")]
104    #[error(transparent)]
105    Subprocess(#[from] crate::subprocess::SubprocessError),
106    /// Provisioning an asset failed.
107    #[error(transparent)]
108    Asset(#[from] crate::assets::AssetError),
109    /// The sandboxed backend could not run the analyzer.
110    #[cfg(feature = "exec-boxlite")]
111    #[error(transparent)]
112    Sandbox(#[from] crate::boxlite::SandboxError),
113    /// This build has no adapter for the requested analyzer, so it can neither
114    /// run it nor read its native output.
115    #[error("no adapter for analyzer {requested:?} in this build (known: {known})")]
116    UnknownAnalyzer {
117        /// The analyzer the caller asked for.
118        requested: String,
119        /// The analyzer ids this build does know, comma-separated.
120        known: String,
121    },
122    /// The report was not valid JSON.
123    #[error("report is not valid JSON: {0}")]
124    Json(#[from] serde_json::Error),
125}
126
127/// Explicit user consent to run an analyzer.
128///
129/// Consent is part of the *request*, not of a backend, so no backend can be
130/// wired up in a way that skips it. For `roteiro security ingest` the user's
131/// invocation naming a report file **is** the consent; a backend that fetches
132/// assets or executes a container will need an interactive grant instead.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum Consent {
135    /// The user explicitly asked for this run.
136    Granted,
137    /// No consent was given; the run must not proceed.
138    Withheld,
139}
140
141/// The worktree an analyzer is pointed at.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Worktree {
144    /// Filesystem location of the checkout.
145    pub path: PathBuf,
146    /// The opaque id that scopes this checkout's findings layer.
147    pub id: WorktreeId,
148    /// How the tree is exposed to the analyzer.
149    pub access: WorktreeAccess,
150}
151
152impl Worktree {
153    /// A read-only worktree at `path`, with its id derived from that path by
154    /// [`worktree_id`].
155    ///
156    /// # Errors
157    /// Returns [`ExecError::Identity`] if the derived id is not well-formed,
158    /// which cannot happen for a hex digest but is surfaced rather than
159    /// unwrapped.
160    pub fn read_only(path: &Path) -> Result<Self, ExecError> {
161        Ok(Self {
162            path: path.to_path_buf(),
163            id: worktree_id(path)?,
164            access: WorktreeAccess::ReadOnly,
165        })
166    }
167}
168
169/// Derive a stable, opaque id for the checkout at `path`.
170///
171/// The id is the first 16 hex characters of the SHA-256 of the path in absolute
172/// form. It is deliberately *not* the path itself: a layer key is stored and
173/// printed, and a local filesystem path is user-identifying data that has no
174/// business in a persisted record. Resolution is lexical (`std::path::absolute`),
175/// so the id is stable and does not depend on the checkout existing.
176///
177/// # Errors
178/// Returns [`ExecError::Identity`] if the derived token is somehow not a
179/// well-formed [`WorktreeId`].
180pub fn worktree_id(path: &Path) -> Result<WorktreeId, ExecError> {
181    // A path that cannot be made absolute (no working directory) still has a
182    // usable lexical form; fall back to it rather than failing the run.
183    let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
184    // The **bytes**, not `to_string_lossy`. A path is bytes on Unix, and lossy
185    // conversion maps every invalid sequence to `U+FFFD` — so two worktrees whose
186    // paths differ only in those bytes hashed to one id, and the second silently
187    // overwrote the first's findings. Recorded as `lossy-identity` in the review
188    // corpus (issue #438) and caught by the rule of that name.
189    let digest = sha256_hex(absolute.as_os_str().as_encoded_bytes());
190    Ok(WorktreeId::new(&digest[..16])?)
191}
192
193/// What a caller asks a backend to do.
194///
195/// The same request shape serves every backend, which is the whole point of the
196/// seam: a caller that ingests a CI report today and runs a sandboxed analyzer
197/// tomorrow builds the identical value.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct AnalysisRequest {
200    /// Which analyzer to run.
201    pub analyzer: String,
202    /// The read-only worktree to analyze.
203    pub worktree: Worktree,
204    /// Egress policy for the run.
205    pub network: NetworkPolicy,
206    /// Explicit user consent.
207    pub consent: Consent,
208    /// The source identity the run is against (commit / tree / lockfile blob),
209    /// as far as the caller knows it. A backend may fill in more.
210    pub source: SourceIdentity,
211}
212
213/// What a backend returns: normalized findings plus the evidence for the run
214/// that produced them.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct AnalysisResponse {
217    /// The run record, ready to persist.
218    pub run: AnalysisRun,
219    /// The findings it produced, ordered by their stable identity key.
220    pub findings: Vec<Finding>,
221}
222
223/// One analyzer backend.
224///
225/// Implementations differ only in *where* the analyzer ran; the request and the
226/// response are the same, so CI ingestion and a local sandboxed run are the same
227/// code path from a caller's point of view. Every implementation must call
228/// [`check_request`] before doing any work, so the consent, network and
229/// worktree-access guarantees hold uniformly rather than per-backend.
230pub trait AnalyzerRunner {
231    /// Which backend this is — recorded on every run it produces.
232    fn kind(&self) -> RunnerKind;
233
234    /// The isolation boundary this backend actually provides. Recorded honestly:
235    /// a backend with no boundary reports [`Isolation::None`], never something
236    /// stronger.
237    fn isolation(&self) -> Isolation;
238
239    /// Execute the request.
240    ///
241    /// # Errors
242    /// Returns [`ExecError`] if the request violates the shared contract (see
243    /// [`check_request`]) or the backend cannot produce a usable result. A failed
244    /// run yields no partial result: either a complete [`AnalysisResponse`] or an
245    /// error.
246    fn run(&self, request: &AnalysisRequest) -> Result<AnalysisResponse, ExecError>;
247}
248
249/// The preflight every backend shares: explicit consent, denied egress, a
250/// read-only worktree, and a well-formed analyzer id.
251///
252/// It lives outside the trait so the guarantees are stated once and cannot drift
253/// between backends — a subprocess backend that forgot the consent check would
254/// otherwise be a one-line omission.
255///
256/// # Errors
257/// Returns [`ExecError::ConsentRequired`], [`ExecError::UnsupportedNetworkPolicy`],
258/// [`ExecError::WorktreeNotReadOnly`], or [`ExecError::InvalidAnalyzerId`] — the
259/// last when the analyzer id is not 1..=[`rto_graph::MAX_ANALYZER_ID`]
260/// characters of lowercase `[a-z0-9._-]`.
261pub fn check_request(request: &AnalysisRequest) -> Result<(), ExecError> {
262    if request.consent != Consent::Granted {
263        return Err(ExecError::ConsentRequired);
264    }
265    if request.network != NetworkPolicy::Deny {
266        return Err(ExecError::UnsupportedNetworkPolicy);
267    }
268    if request.worktree.access != WorktreeAccess::ReadOnly {
269        return Err(ExecError::WorktreeNotReadOnly);
270    }
271    if !is_valid_analyzer_id(&request.analyzer) {
272        return Err(ExecError::InvalidAnalyzerId(request.analyzer.clone()));
273    }
274    Ok(())
275}
276
277/// Reject a reported path that is absolute or climbs out of the worktree.
278///
279/// A finding is a claim about a file *in the analyzed tree*. A report that names
280/// `/etc/shadow` or `../../secrets` is either broken or hostile, and either way
281/// its claim cannot be checked, so it is refused rather than stored.
282///
283/// # Errors
284/// Returns [`ExecError::PathEscapesWorktree`] for an empty, absolute, prefixed or
285/// parent-climbing path.
286pub fn check_reported_path(path: &str) -> Result<(), ExecError> {
287    let escapes = path.is_empty()
288        || Path::new(path).components().any(|c| {
289            matches!(
290                c,
291                Component::RootDir | Component::Prefix(_) | Component::ParentDir
292            )
293        });
294    if escapes {
295        return Err(ExecError::PathEscapesWorktree(path.to_owned()));
296    }
297    Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302    use super::{
303        AnalysisRequest, Consent, ExecError, Worktree, check_reported_path, check_request,
304        worktree_id,
305    };
306    use rto_graph::{NetworkPolicy, SourceIdentity, WorktreeAccess};
307
308    fn request() -> AnalysisRequest {
309        AnalysisRequest {
310            analyzer: "cargo-audit".to_owned(),
311            worktree: Worktree::read_only("/repo".as_ref()).expect("worktree"),
312            network: NetworkPolicy::Deny,
313            consent: Consent::Granted,
314            source: SourceIdentity::default(),
315        }
316    }
317
318    #[test]
319    fn a_well_formed_request_passes_preflight() {
320        check_request(&request()).expect("preflight");
321    }
322
323    #[test]
324    fn preflight_refuses_a_run_without_consent() {
325        let mut req = request();
326        req.consent = Consent::Withheld;
327        assert!(matches!(
328            check_request(&req),
329            Err(ExecError::ConsentRequired)
330        ));
331    }
332
333    #[test]
334    fn preflight_refuses_a_writable_worktree() {
335        let mut req = request();
336        req.worktree.access = WorktreeAccess::ReadWrite;
337        assert!(matches!(
338            check_request(&req),
339            Err(ExecError::WorktreeNotReadOnly)
340        ));
341    }
342
343    #[test]
344    fn preflight_refuses_a_malformed_analyzer_id() {
345        let mut req = request();
346        req.analyzer = "Cargo Audit".to_owned();
347        assert!(matches!(
348            check_request(&req),
349            Err(ExecError::InvalidAnalyzerId(_))
350        ));
351    }
352
353    /// The preflight enforces a length limit as well as a character set, so the
354    /// rejection has to say so. Being told an over-long id must be "non-empty" —
355    /// which it plainly was — is no help at all.
356    #[test]
357    fn preflight_refuses_an_over_long_analyzer_id_and_says_why() {
358        let mut req = request();
359        req.analyzer = "a".repeat(rto_graph::MAX_ANALYZER_ID + 1);
360        let err = check_request(&req).expect_err("an over-long id must be refused");
361        assert!(matches!(err, ExecError::InvalidAnalyzerId(_)));
362        let message = err.to_string();
363        assert!(
364            message.contains("over the 64-character limit"),
365            "the rejection must name the length rule: {message}"
366        );
367        assert!(
368            message.contains("1 to 64 characters of lowercase [a-z0-9._-]"),
369            "and state the whole contract: {message}"
370        );
371    }
372
373    /// One rejection, one wording. Both layers format through
374    /// `rto_graph::analyzer_id_error`, so an id refused at the seam reads exactly
375    /// as it would had the store caught it — a caller cannot be told two stories
376    /// about the same input depending on how deep the check happened to run.
377    #[test]
378    fn the_two_layers_word_a_rejection_identically() {
379        for id in [
380            "",
381            "Semgrep",
382            "a:b",
383            &"a".repeat(rto_graph::MAX_ANALYZER_ID + 1),
384        ] {
385            let seam = ExecError::InvalidAnalyzerId(id.to_owned()).to_string();
386            let store = rto_graph::FindingsError::InvalidAnalyzerId(id.to_owned()).to_string();
387            assert_eq!(seam, store, "{id:?} reads differently in the two layers");
388            assert_eq!(seam, rto_graph::analyzer_id_error(id));
389        }
390    }
391
392    #[test]
393    fn worktree_ids_are_opaque_stable_and_path_scoped() {
394        let a = worktree_id("/repo/one".as_ref()).expect("a");
395        let b = worktree_id("/repo/two".as_ref()).expect("b");
396        assert_ne!(a, b, "different checkouts get different layers");
397        assert_eq!(a, worktree_id("/repo/one".as_ref()).expect("again"));
398        assert_eq!(a.as_str().len(), 16);
399        assert!(
400            !a.as_str().contains("repo"),
401            "the id must not embed the path"
402        );
403    }
404
405    #[test]
406    fn reported_paths_must_stay_inside_the_worktree() {
407        check_reported_path("src/tls.rs").expect("relative path is fine");
408        for bad in ["", "/etc/shadow", "../../secrets", "src/../../etc/passwd"] {
409            assert!(
410                matches!(
411                    check_reported_path(bad),
412                    Err(ExecError::PathEscapesWorktree(_))
413                ),
414                "{bad:?} should be refused"
415            );
416        }
417    }
418}