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