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