rto_exec/lib.rs
1//! The analyzer execution seam: one contract, interchangeable backends.
2//!
3//! Running an external analyzer (`cargo-audit`, `semgrep`, successors) can happen
4//! in CI, on a developer's machine, or — later — locally inside a sandbox. This
5//! crate exists so those stop being competing architectures: every backend
6//! implements one [`AnalyzerRunner`] trait, takes one [`AnalysisRequest`], and
7//! returns one [`AnalysisResponse`] of normalized findings plus run evidence. A
8//! caller never learns which backend produced a result, so adding the sandboxed
9//! and subprocess backends later changes no call site.
10//!
11//! Today there is exactly one implementation, [`IngestRunner`], which consumes a
12//! normalized report produced elsewhere. It is the zero-install default, not a
13//! fallback: it needs no container runtime and adds no isolation surface, and
14//! what it produces is byte-for-byte the shape a sandboxed run will produce.
15//!
16//! # What this crate does not do
17//!
18//! It does not *always* produce something to store, either. [`lint`] runs a
19//! linter and returns a report the caller prints: no [`AnalysisRun`], no layer,
20//! no store. A lint name is a symbol in a compiler rather than an assigned
21//! identifier, so it is an opinion about the code as it stands today rather than
22//! a durable fact about the repository (ADR-0020 v1.1) — and everything below
23//! about persistence simply does not apply to it.
24//!
25//! [`AnalysisRun`]: rto_graph::AnalysisRun
26//! [`lint`]: crate::lint
27//!
28//! It does not decide how results are *stored*. Persistence lives in `rto-graph`,
29//! which files findings in their own tables — never `nodes`/`edges`, never a
30//! provenance class, never in the exported graph artifact (ADR-0012). Nothing
31//! here can move the published `GraphArtifact` by a byte, and that is checked by
32//! test rather than assumed.
33//!
34//! No analyzer is implemented here, and no sandbox dependency is pulled in; the
35//! backends arrive behind their own features (ADR-0014).
36//!
37//! @rto:0014
38//! @rto:0012
39//!
40//! # Example
41//!
42//! ```
43//! use rto_exec::{AnalysisRequest, AnalyzerRunner, Consent, IngestRunner, Worktree};
44//! use rto_graph::SourceIdentity;
45//!
46//! let report = br#"{
47//! "schema": "roteiro.findings/v1",
48//! "analyzer": "cargo-audit",
49//! "analyzer_version": "0.21.0",
50//! "started_at": "2026-08-15T09:00:00Z",
51//! "ended_at": "2026-08-15T09:00:04Z",
52//! "exit_status": 1,
53//! "findings": [{
54//! "identity": ["RUSTSEC-2024-0001", "openssl", "0.10.5", "lock123"],
55//! "rule": "RUSTSEC-2024-0001",
56//! "severity": "high",
57//! "title": "openssl is vulnerable",
58//! "message": "upgrade to 0.10.66"
59//! }]
60//! }"#;
61//!
62//! let request = AnalysisRequest {
63//! analyzer: "cargo-audit".to_owned(),
64//! worktree: Worktree::read_only("/repo".as_ref()).expect("worktree"),
65//! network: rto_graph::NetworkPolicy::Deny,
66//! consent: Consent::Granted,
67//! source: SourceIdentity::default(),
68//! };
69//! let response = IngestRunner::new(report.to_vec()).run(&request).expect("ingest");
70//! assert_eq!(response.findings.len(), 1);
71//! assert_eq!(response.run.isolation, rto_graph::Isolation::Ingested);
72//! ```
73
74pub mod adapter;
75/// Where the pinned-asset cache lives, and the precedence that decides it.
76///
77/// Its source carries no `//!` header because `build.rs` pulls the same file in
78/// with `include!`, where an inner doc comment is a syntax error — so the module
79/// documentation lives here instead. `build.rs` needs it to find the sandbox
80/// runtime `roteiro security prefetch` installed, which is the same cache
81/// [`asset_paths::asset_root`] names; read the file's own comments for why that
82/// is shared rather than copied.
83pub mod asset_paths;
84// Asset provisioning is **always compiled**, behind no feature at all.
85//
86// It used to be `cfg(any(exec-subprocess, exec-boxlite))`, on the reading that
87// provisioning belongs to whichever backend consumes the assets. That was the
88// wrong shape and this module half-said so already: it is shared between the
89// backends and owned by neither, and the note on `SANDBOX_RUNTIME_NOTICE` below
90// records that an `exec-subprocess`-only build provisions *for a later
91// `exec-boxlite` build* — provisioning already served a backend that was not
92// compiled in.
93//
94// The bootstrap argument settles it. `AGENTS.md` tells a contributor to run
95// `roteiro security prefetch --allow-download` *before* building
96// `--features exec-boxlite`, because that build script requires the verified
97// archive at compile time. If prefetch lived behind an execution feature, you
98// would need a build with a *different* execution backend compiled in before you
99// could provision the one you actually wanted. That is circular.
100//
101// Nothing here executes anything: it downloads, digests, pins and reports. Every
102// `Command::new` in this crate is in `subprocess.rs` or `boxlite.rs`, and both
103// stay behind their features. Provisioning is not execution.
104pub mod assets;
105#[cfg(feature = "exec-boxlite")]
106pub mod boxlite;
107/// What an analyzer's environment is — for **both** backends, in one place.
108///
109/// Private because it is a seam between this crate's backends rather than a
110/// contract with a caller. It exists as its own module because it used to exist
111/// as two: a `ChildEnv` in [`subprocess`] and a hand-rolled list in [`boxlite`],
112/// which is how `CARGO_TARGET_DIR` came to be listed as a name to *inherit*
113/// under a promise that it was *set*. Read the module for why a guest has no
114/// `inherit` half at all.
115#[cfg(any(feature = "exec-subprocess", feature = "exec-boxlite"))]
116mod child_env;
117pub mod crossref;
118/// Emitting a `file://` URL for a local path, and reading one back.
119///
120/// Its source carries no `//!` header because `build.rs` pulls the same file in
121/// with `include!`, where an inner doc comment is a syntax error — so the module
122/// documentation lives here instead. `build.rs` is this crate's only emitter: it
123/// prints the `BOXLITE_RUNTIME_URL=` recipe an operator pastes, and parses that
124/// variable back when it is set. What reads the URL in between is `boxlite`'s
125/// own `curl`, which percent-decodes and rejects an unencoded space outright —
126/// read the file's own comments for the measurements, and for why the encoder
127/// and the decoder have to be one file rather than two.
128pub mod file_url;
129/// How a refusal is written, so that a way forward stays one.
130///
131/// Ungated, like [`lint_grant`], and for the same kind of reason: what a refusal
132/// owes its reader is not a property of which backends were compiled in. Read
133/// the module for the failure it makes unrepresentable — three of this crate's
134/// refusals leaked source indentation into shipped output at once, which says
135/// the way they were written invited it.
136pub mod guidance;
137/// Whether an image reference is pinned by a digest — the one place that decides.
138///
139/// Ungated, like [`guidance`], and for a reason of the same shape: the rule is
140/// about a string somebody wrote in a config file, not about which backends were
141/// compiled in. `roteiro config` reports an unpinned reference in a build with no
142/// sandbox at all, and a second copy of the check written for that purpose is how
143/// one of the two ends up laxer than the other.
144pub mod image_ref;
145mod ingest;
146/// Running a linter and **reporting** it, with no store anywhere in the path.
147///
148/// The other half of this crate produces artifacts; this module deliberately
149/// does not (ADR-0020 v1.1). It has no [`AnalyzerRunner`] implementation, takes
150/// no [`Consent`], and cannot reach [`rto_graph::Store`] — read its own
151/// documentation for why a lint is not a finding, and why relaxing
152/// [`check_request`] to fit a builder through the reader-class preflight is the
153/// conversion ADR-0014 warns against rather than a refactor.
154#[cfg(feature = "exec-subprocess")]
155pub mod lint;
156/// ADR-0020 §6's grant: may a linter run on **this host**?
157///
158/// Ungated, unlike [`lint`] itself. A policy that existed only where the
159/// capability does would be the conversion ADR-0014 warns about, so the answer
160/// is the same in a build that cannot run a linter as in one that can — see the
161/// module's own documentation.
162pub mod lint_grant;
163/// ADR-0020 conditions 1-2: the **sandboxed builder** — `roteiro lint`'s default.
164///
165/// The boundary half of [`lint`]. It adds one writable mount to what
166/// [`boxlite`] already does and removes nothing: the worktree stays read-only,
167/// [`check_request`]'s preflight is untouched, and the package cache is a
168/// read-only mount of this machine's own rather than a vendored copy. Read its
169/// documentation for why the image is supplied rather than pinned here, and why
170/// a `$CARGO_HOME` root is not what gets mounted.
171///
172/// Gated on **both** backends. The boundary does not imply the escape hatch —
173/// `exec-boxlite` still does not enable `exec-subprocess`, and enabling one must
174/// never switch on the other. This module needs both because it shares
175/// [`lint`]'s report shape and its one host-side `cargo locate-project`, which
176/// is how it learns what to mount.
177#[cfg(all(feature = "exec-boxlite", feature = "exec-subprocess"))]
178pub mod lint_sandbox;
179mod runner;
180/// The per-file digests of the extracted sandbox runtime — **generated**.
181///
182/// Derived from the archives in [`runtime_pins`] by
183/// `scripts/derive-runtime-file-pins.py`, and verified by `build.rs` against
184/// what `boxlite` actually extracted, since those files rather than the archive
185/// are what `include_bytes!` puts in the binary. Same `include!` arrangement,
186/// and so the same standalone constraint; its module documentation lives here
187/// for the same reason [`runtime_pins`]'s does.
188pub mod runtime_file_pins;
189/// The pinned sandbox-runtime archives, and the host-platform selection.
190///
191/// Its source carries no `//!` header because `build.rs` pulls the same file in
192/// with `include!`, where an inner doc comment is a syntax error — so the module
193/// documentation lives here instead. Read the file's own comments for what is
194/// pinned and why it has to be.
195pub mod runtime_pins;
196/// The **sandbox image store**: what it is holding, and dropping it safely.
197///
198/// Ungated, like [`assets`], and the argument is the same one that moved
199/// provisioning off the backend features: reclaiming the bytes a previous build
200/// cached must not require rebuilding with the backend that cached them. Nothing
201/// here executes anything — it reads an index, measures files, and removes what
202/// a pinned digest re-obtains (ADR-0014 v1.6).
203pub mod sandbox_store;
204pub mod snippet;
205#[cfg(feature = "exec-subprocess")]
206pub mod subprocess;
207/// The **read-only documents** `security list` / `security status` return over a
208/// model-facing tool surface.
209///
210/// Ungated, like [`guidance`] and [`lint_grant`], and for a related reason: what
211/// a read owes its reader is not a property of which backends were compiled in.
212/// It is also the one place either document is built — the CLI's `security
213/// status` shares its coverage matrix and staleness rows from here, so
214/// `possibly_stale` and `ready` are one computation rather than three. Read the
215/// module for the two hazards it exists to remove: an empty listing that reads as
216/// a clean one, and a status blob whose two halves have different scopes.
217pub mod tool_security;
218
219pub use adapter::{
220 ADAPTERS, Adapter, AssetPaths, Invocation, LINT_ANALYZERS, NO_SNIPPET, NativeContext,
221 UNKNOWN_VERSION, adapter_for, known_analyzers, snippet_hash, snippet_hash_at,
222};
223// The linter's adapter is re-exported like any other, and — unlike any other —
224// is **not** in [`ADAPTERS`], so `ingest` cannot resolve it and nothing can file
225// its output as a layer. See [`adapter::clippy`].
226pub use adapter::clippy::{Clippy, FeatureSet};
227pub use assets::{
228 ASSETS, AssetKind, AssetSource, AssetSpec, AssetStatus, DownloadFile, Fetcher, InstalledAsset,
229 MissingAsset, SANDBOX, asset, asset_path, asset_root, assets_for, provision, provision_with,
230 resolve, status,
231};
232#[cfg(feature = "exec-boxlite")]
233pub use boxlite::{BoxliteRunner, SandboxError, SandboxProbe, sandbox_probe};
234// The RFC 3339 UTC formatter moved to `rto-graph` in #667: `rto-exec` is an
235// optional dependency of the CLI, and the CLI's render path — which is gated on
236// nothing — needs the same function to stamp a bundle's timestamps. It is
237// `okf_instant` that needs it today; the workspace-vault renderer that needed it
238// when #667 was filed was deleted by #671, whose OKF replacement reintroduced the
239// same unlinked-crate error at a new line. The move is aimed at that recurrence,
240// not at either call site.
241//
242// Re-exported under the names it has always had so this crate's own callers, and
243// `rto_exec::rfc3339_utc` in the CLI's `execution`/`remote` paths, did not have
244// to move with it.
245pub use crossref::{
246 Correspondence, Report, across_analyzers as cross_reference_across_analyzers, cross_reference,
247};
248pub use guidance::{Guidance, Line as GuidanceLine};
249pub use image_ref::{NotPinned, PinDefect, pinned_digest as image_pinned_digest};
250pub use ingest::{
251 IngestRunner, MAX_REPORT_FINDINGS, NormalizedReport, REPORT_SCHEMA, ReportFinding,
252 normalize_native,
253};
254#[cfg(feature = "exec-subprocess")]
255pub use lint::{LintError, LintOutcome, Toolchain, invocation as lint_invocation, run as run_lint};
256pub use rto_graph::{age_in_days, rfc3339_from_unix, rfc3339_utc, unix_from_rfc3339};
257// ADR-0020 §6's grant. Re-exported under `lint_`-prefixed names because the
258// concepts have twins in `rto-remote` (ADR-0019 §3) and a reader who meets
259// `ConfigGrant` in the binary must be able to see which of the two it is.
260pub use lint_grant::{
261 Backend as LintBackend, ConfigGrant as LintConfigGrant, Decision as LintDecision,
262 Reason as LintReason, Requested as LintRequested, decide as decide_lint_host,
263};
264#[cfg(all(feature = "exec-boxlite", feature = "exec-subprocess"))]
265pub use lint_sandbox::BuilderError as LintBuilderError;
266pub use runner::{
267 AnalysisRequest, AnalysisResponse, AnalyzerRunner, Consent, ExecError, Worktree,
268 check_reported_path, check_request, worktree_id,
269};
270pub use runtime_file_pins::{
271 PinnedFile, PinnedRuntimeFiles, RUNTIME_FILES, RUNTIME_FILES_VERSION, runtime_files_for,
272};
273pub use runtime_pins::{
274 PinnedArchive, RUNTIME_ARCHIVES, RUNTIME_ASSET, RUNTIME_FILE, RUNTIME_VERSION, archive_for,
275 runtime_target,
276};
277pub use sandbox_store::{
278 Attribution, CachedImage, ClearReport, ImageBytes, Objects, Preserved, RemovedImage,
279 SANDBOX_CLEAR_SCHEMA, SANDBOX_STATUS_SCHEMA, SANDBOX_STORE_DIR, SandboxStatus, Scope,
280 StoreError, Unattributed, VerifiedImage, clear as sandbox_clear, plan as sandbox_plan,
281 status as sandbox_status, store_root as sandbox_store_root,
282};
283pub use snippet::{NoSnippets, SnippetSource, WorktreeSnippets};
284#[cfg(feature = "exec-subprocess")]
285pub use subprocess::{SubprocessError, SubprocessRunner};
286pub use tool_security::{
287 AnalyzerCoverage, Coverage, CrossReference, CrossReferenceReport, LayerStaleness, MachineScope,
288 Readiness, RepositoryScope, SecurityListReport, TOOL_SECURITY_LIST_SCHEMA,
289 TOOL_SECURITY_STATUS_SCHEMA, ToolFindingsLayer, ToolSecurityList, ToolSecurityStatus,
290 coverage_matrix, coverage_matrix_with, layer_staleness, security_list, security_status,
291};
292
293/// The licence notice for the third-party binaries an `exec-boxlite` build
294/// embeds, compiled in so it cannot be separated from what it describes.
295///
296/// `roteiro security prefetch` prints it before installing the sandbox runtime,
297/// which is the same disclose-then-consent shape `roteiro model pull` uses. It
298/// is compiled into every build, because every build can provision the runtime —
299/// including one with no execution backend at all, which prefetches it for a
300/// later `exec-boxlite` build — so the obligations travel with the artifact
301/// rather than living only in the repository.
302pub const SANDBOX_RUNTIME_NOTICE: &str = include_str!("../NOTICE-boxlite-runtime.md");
303
304/// Lowercase hex SHA-256 of `bytes`.
305///
306/// Used for the report digest that ties an `AnalysisRun` to the exact bytes it
307/// was derived from, and for deriving an opaque worktree id from a path.
308#[must_use]
309pub fn sha256_hex(bytes: &[u8]) -> String {
310 use sha2::{Digest, Sha256};
311 let digest = Sha256::digest(bytes);
312 let mut out = String::with_capacity(64);
313 for byte in digest {
314 use std::fmt::Write as _;
315 let _ = write!(out, "{byte:02x}");
316 }
317 out
318}
319
320#[cfg(test)]
321mod tests {
322 use super::sha256_hex;
323
324 #[test]
325 fn hashes_the_known_vector() {
326 assert_eq!(
327 sha256_hex(b"abc"),
328 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
329 );
330 }
331}