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 let digest = sha256_hex(absolute.to_string_lossy().as_bytes());
185 Ok(WorktreeId::new(&digest[..16])?)
186}
187
188/// What a caller asks a backend to do.
189///
190/// The same request shape serves every backend, which is the whole point of the
191/// seam: a caller that ingests a CI report today and runs a sandboxed analyzer
192/// tomorrow builds the identical value.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct AnalysisRequest {
195 /// Which analyzer to run.
196 pub analyzer: String,
197 /// The read-only worktree to analyze.
198 pub worktree: Worktree,
199 /// Egress policy for the run.
200 pub network: NetworkPolicy,
201 /// Explicit user consent.
202 pub consent: Consent,
203 /// The source identity the run is against (commit / tree / lockfile blob),
204 /// as far as the caller knows it. A backend may fill in more.
205 pub source: SourceIdentity,
206}
207
208/// What a backend returns: normalized findings plus the evidence for the run
209/// that produced them.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct AnalysisResponse {
212 /// The run record, ready to persist.
213 pub run: AnalysisRun,
214 /// The findings it produced, ordered by their stable identity key.
215 pub findings: Vec<Finding>,
216}
217
218/// One analyzer backend.
219///
220/// Implementations differ only in *where* the analyzer ran; the request and the
221/// response are the same, so CI ingestion and a local sandboxed run are the same
222/// code path from a caller's point of view. Every implementation must call
223/// [`check_request`] before doing any work, so the consent, network and
224/// worktree-access guarantees hold uniformly rather than per-backend.
225pub trait AnalyzerRunner {
226 /// Which backend this is — recorded on every run it produces.
227 fn kind(&self) -> RunnerKind;
228
229 /// The isolation boundary this backend actually provides. Recorded honestly:
230 /// a backend with no boundary reports [`Isolation::None`], never something
231 /// stronger.
232 fn isolation(&self) -> Isolation;
233
234 /// Execute the request.
235 ///
236 /// # Errors
237 /// Returns [`ExecError`] if the request violates the shared contract (see
238 /// [`check_request`]) or the backend cannot produce a usable result. A failed
239 /// run yields no partial result: either a complete [`AnalysisResponse`] or an
240 /// error.
241 fn run(&self, request: &AnalysisRequest) -> Result<AnalysisResponse, ExecError>;
242}
243
244/// The preflight every backend shares: explicit consent, denied egress, a
245/// read-only worktree, and a well-formed analyzer id.
246///
247/// It lives outside the trait so the guarantees are stated once and cannot drift
248/// between backends — a subprocess backend that forgot the consent check would
249/// otherwise be a one-line omission.
250///
251/// # Errors
252/// Returns [`ExecError::ConsentRequired`], [`ExecError::UnsupportedNetworkPolicy`],
253/// [`ExecError::WorktreeNotReadOnly`], or [`ExecError::InvalidAnalyzerId`] — the
254/// last when the analyzer id is not 1..=[`rto_graph::MAX_ANALYZER_ID`]
255/// characters of lowercase `[a-z0-9._-]`.
256pub fn check_request(request: &AnalysisRequest) -> Result<(), ExecError> {
257 if request.consent != Consent::Granted {
258 return Err(ExecError::ConsentRequired);
259 }
260 if request.network != NetworkPolicy::Deny {
261 return Err(ExecError::UnsupportedNetworkPolicy);
262 }
263 if request.worktree.access != WorktreeAccess::ReadOnly {
264 return Err(ExecError::WorktreeNotReadOnly);
265 }
266 if !is_valid_analyzer_id(&request.analyzer) {
267 return Err(ExecError::InvalidAnalyzerId(request.analyzer.clone()));
268 }
269 Ok(())
270}
271
272/// Reject a reported path that is absolute or climbs out of the worktree.
273///
274/// A finding is a claim about a file *in the analyzed tree*. A report that names
275/// `/etc/shadow` or `../../secrets` is either broken or hostile, and either way
276/// its claim cannot be checked, so it is refused rather than stored.
277///
278/// # Errors
279/// Returns [`ExecError::PathEscapesWorktree`] for an empty, absolute, prefixed or
280/// parent-climbing path.
281pub fn check_reported_path(path: &str) -> Result<(), ExecError> {
282 let escapes = path.is_empty()
283 || Path::new(path).components().any(|c| {
284 matches!(
285 c,
286 Component::RootDir | Component::Prefix(_) | Component::ParentDir
287 )
288 });
289 if escapes {
290 return Err(ExecError::PathEscapesWorktree(path.to_owned()));
291 }
292 Ok(())
293}
294
295#[cfg(test)]
296mod tests {
297 use super::{
298 AnalysisRequest, Consent, ExecError, Worktree, check_reported_path, check_request,
299 worktree_id,
300 };
301 use rto_graph::{NetworkPolicy, SourceIdentity, WorktreeAccess};
302
303 fn request() -> AnalysisRequest {
304 AnalysisRequest {
305 analyzer: "cargo-audit".to_owned(),
306 worktree: Worktree::read_only("/repo".as_ref()).expect("worktree"),
307 network: NetworkPolicy::Deny,
308 consent: Consent::Granted,
309 source: SourceIdentity::default(),
310 }
311 }
312
313 #[test]
314 fn a_well_formed_request_passes_preflight() {
315 check_request(&request()).expect("preflight");
316 }
317
318 #[test]
319 fn preflight_refuses_a_run_without_consent() {
320 let mut req = request();
321 req.consent = Consent::Withheld;
322 assert!(matches!(
323 check_request(&req),
324 Err(ExecError::ConsentRequired)
325 ));
326 }
327
328 #[test]
329 fn preflight_refuses_a_writable_worktree() {
330 let mut req = request();
331 req.worktree.access = WorktreeAccess::ReadWrite;
332 assert!(matches!(
333 check_request(&req),
334 Err(ExecError::WorktreeNotReadOnly)
335 ));
336 }
337
338 #[test]
339 fn preflight_refuses_a_malformed_analyzer_id() {
340 let mut req = request();
341 req.analyzer = "Cargo Audit".to_owned();
342 assert!(matches!(
343 check_request(&req),
344 Err(ExecError::InvalidAnalyzerId(_))
345 ));
346 }
347
348 /// The preflight enforces a length limit as well as a character set, so the
349 /// rejection has to say so. Being told an over-long id must be "non-empty" —
350 /// which it plainly was — is no help at all.
351 #[test]
352 fn preflight_refuses_an_over_long_analyzer_id_and_says_why() {
353 let mut req = request();
354 req.analyzer = "a".repeat(rto_graph::MAX_ANALYZER_ID + 1);
355 let err = check_request(&req).expect_err("an over-long id must be refused");
356 assert!(matches!(err, ExecError::InvalidAnalyzerId(_)));
357 let message = err.to_string();
358 assert!(
359 message.contains("over the 64-character limit"),
360 "the rejection must name the length rule: {message}"
361 );
362 assert!(
363 message.contains("1 to 64 characters of lowercase [a-z0-9._-]"),
364 "and state the whole contract: {message}"
365 );
366 }
367
368 /// One rejection, one wording. Both layers format through
369 /// `rto_graph::analyzer_id_error`, so an id refused at the seam reads exactly
370 /// as it would had the store caught it — a caller cannot be told two stories
371 /// about the same input depending on how deep the check happened to run.
372 #[test]
373 fn the_two_layers_word_a_rejection_identically() {
374 for id in [
375 "",
376 "Semgrep",
377 "a:b",
378 &"a".repeat(rto_graph::MAX_ANALYZER_ID + 1),
379 ] {
380 let seam = ExecError::InvalidAnalyzerId(id.to_owned()).to_string();
381 let store = rto_graph::FindingsError::InvalidAnalyzerId(id.to_owned()).to_string();
382 assert_eq!(seam, store, "{id:?} reads differently in the two layers");
383 assert_eq!(seam, rto_graph::analyzer_id_error(id));
384 }
385 }
386
387 #[test]
388 fn worktree_ids_are_opaque_stable_and_path_scoped() {
389 let a = worktree_id("/repo/one".as_ref()).expect("a");
390 let b = worktree_id("/repo/two".as_ref()).expect("b");
391 assert_ne!(a, b, "different checkouts get different layers");
392 assert_eq!(a, worktree_id("/repo/one".as_ref()).expect("again"));
393 assert_eq!(a.as_str().len(), 16);
394 assert!(
395 !a.as_str().contains("repo"),
396 "the id must not embed the path"
397 );
398 }
399
400 #[test]
401 fn reported_paths_must_stay_inside_the_worktree() {
402 check_reported_path("src/tls.rs").expect("relative path is fine");
403 for bad in ["", "/etc/shadow", "../../secrets", "src/../../etc/passwd"] {
404 assert!(
405 matches!(
406 check_reported_path(bad),
407 Err(ExecError::PathEscapesWorktree(_))
408 ),
409 "{bad:?} should be refused"
410 );
411 }
412 }
413}