use std::path::{Component, Path, PathBuf};
use rto_graph::{
AnalysisRun, Finding, FindingsError, Isolation, NetworkPolicy, RunnerKind, SourceIdentity,
WorktreeAccess, WorktreeId, analyzer_id_error, is_valid_analyzer_id,
};
use crate::sha256_hex;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ExecError {
#[error("analyzer run requires explicit user consent")]
ConsentRequired,
#[error("unsupported network policy: this runner only accepts `deny`")]
UnsupportedNetworkPolicy,
#[error("the analyzed worktree must be read-only")]
WorktreeNotReadOnly,
#[error("{}", analyzer_id_error(.0))]
InvalidAnalyzerId(String),
#[error("report is from analyzer {reported:?}, but {requested:?} was requested")]
AnalyzerMismatch {
requested: String,
reported: String,
},
#[error("unsupported report schema: {found:?} (expected {expected:?})")]
UnsupportedSchema {
found: String,
expected: &'static str,
},
#[error("malformed report: {0}")]
MalformedReport(String),
#[error("report declares {count} findings, more than the {max} accepted in one run")]
TooManyFindings {
count: usize,
max: usize,
},
#[error("duplicate finding identity in report: {0}")]
DuplicateFinding(String),
#[error("finding path escapes the worktree: {0:?}")]
PathEscapesWorktree(String),
#[error("finding identity: {0}")]
Identity(#[from] FindingsError),
#[cfg(any(feature = "exec-subprocess", feature = "exec-boxlite"))]
#[error(
"assets-unavailable-offline: {analyzer} cannot run because its pinned inputs are not \
provisioned\n missing: {}\n fix it with: {command}\n \
(roteiro never fetches analyzer assets during a run, and never falls back to whatever \
the host has installed)",
.missing.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n ")
)]
AssetsUnavailableOffline {
analyzer: String,
missing: Vec<crate::assets::MissingAsset>,
command: String,
},
#[cfg(feature = "exec-subprocess")]
#[error(transparent)]
Subprocess(#[from] crate::subprocess::SubprocessError),
#[cfg(any(feature = "exec-subprocess", feature = "exec-boxlite"))]
#[error(transparent)]
Asset(#[from] crate::assets::AssetError),
#[cfg(feature = "exec-boxlite")]
#[error(transparent)]
Sandbox(#[from] crate::boxlite::SandboxError),
#[error("no adapter for analyzer {requested:?} in this build (known: {known})")]
UnknownAnalyzer {
requested: String,
known: String,
},
#[error("report is not valid JSON: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Consent {
Granted,
Withheld,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Worktree {
pub path: PathBuf,
pub id: WorktreeId,
pub access: WorktreeAccess,
}
impl Worktree {
pub fn read_only(path: &Path) -> Result<Self, ExecError> {
Ok(Self {
path: path.to_path_buf(),
id: worktree_id(path)?,
access: WorktreeAccess::ReadOnly,
})
}
}
pub fn worktree_id(path: &Path) -> Result<WorktreeId, ExecError> {
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let digest = sha256_hex(absolute.to_string_lossy().as_bytes());
Ok(WorktreeId::new(&digest[..16])?)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnalysisRequest {
pub analyzer: String,
pub worktree: Worktree,
pub network: NetworkPolicy,
pub consent: Consent,
pub source: SourceIdentity,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnalysisResponse {
pub run: AnalysisRun,
pub findings: Vec<Finding>,
}
pub trait AnalyzerRunner {
fn kind(&self) -> RunnerKind;
fn isolation(&self) -> Isolation;
fn run(&self, request: &AnalysisRequest) -> Result<AnalysisResponse, ExecError>;
}
pub fn check_request(request: &AnalysisRequest) -> Result<(), ExecError> {
if request.consent != Consent::Granted {
return Err(ExecError::ConsentRequired);
}
if request.network != NetworkPolicy::Deny {
return Err(ExecError::UnsupportedNetworkPolicy);
}
if request.worktree.access != WorktreeAccess::ReadOnly {
return Err(ExecError::WorktreeNotReadOnly);
}
if !is_valid_analyzer_id(&request.analyzer) {
return Err(ExecError::InvalidAnalyzerId(request.analyzer.clone()));
}
Ok(())
}
pub fn check_reported_path(path: &str) -> Result<(), ExecError> {
let escapes = path.is_empty()
|| Path::new(path).components().any(|c| {
matches!(
c,
Component::RootDir | Component::Prefix(_) | Component::ParentDir
)
});
if escapes {
return Err(ExecError::PathEscapesWorktree(path.to_owned()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
AnalysisRequest, Consent, ExecError, Worktree, check_reported_path, check_request,
worktree_id,
};
use rto_graph::{NetworkPolicy, SourceIdentity, WorktreeAccess};
fn request() -> AnalysisRequest {
AnalysisRequest {
analyzer: "cargo-audit".to_owned(),
worktree: Worktree::read_only("/repo".as_ref()).expect("worktree"),
network: NetworkPolicy::Deny,
consent: Consent::Granted,
source: SourceIdentity::default(),
}
}
#[test]
fn a_well_formed_request_passes_preflight() {
check_request(&request()).expect("preflight");
}
#[test]
fn preflight_refuses_a_run_without_consent() {
let mut req = request();
req.consent = Consent::Withheld;
assert!(matches!(
check_request(&req),
Err(ExecError::ConsentRequired)
));
}
#[test]
fn preflight_refuses_a_writable_worktree() {
let mut req = request();
req.worktree.access = WorktreeAccess::ReadWrite;
assert!(matches!(
check_request(&req),
Err(ExecError::WorktreeNotReadOnly)
));
}
#[test]
fn preflight_refuses_a_malformed_analyzer_id() {
let mut req = request();
req.analyzer = "Cargo Audit".to_owned();
assert!(matches!(
check_request(&req),
Err(ExecError::InvalidAnalyzerId(_))
));
}
#[test]
fn preflight_refuses_an_over_long_analyzer_id_and_says_why() {
let mut req = request();
req.analyzer = "a".repeat(rto_graph::MAX_ANALYZER_ID + 1);
let err = check_request(&req).expect_err("an over-long id must be refused");
assert!(matches!(err, ExecError::InvalidAnalyzerId(_)));
let message = err.to_string();
assert!(
message.contains("over the 64-character limit"),
"the rejection must name the length rule: {message}"
);
assert!(
message.contains("1 to 64 characters of lowercase [a-z0-9._-]"),
"and state the whole contract: {message}"
);
}
#[test]
fn the_two_layers_word_a_rejection_identically() {
for id in [
"",
"Semgrep",
"a:b",
&"a".repeat(rto_graph::MAX_ANALYZER_ID + 1),
] {
let seam = ExecError::InvalidAnalyzerId(id.to_owned()).to_string();
let store = rto_graph::FindingsError::InvalidAnalyzerId(id.to_owned()).to_string();
assert_eq!(seam, store, "{id:?} reads differently in the two layers");
assert_eq!(seam, rto_graph::analyzer_id_error(id));
}
}
#[test]
fn worktree_ids_are_opaque_stable_and_path_scoped() {
let a = worktree_id("/repo/one".as_ref()).expect("a");
let b = worktree_id("/repo/two".as_ref()).expect("b");
assert_ne!(a, b, "different checkouts get different layers");
assert_eq!(a, worktree_id("/repo/one".as_ref()).expect("again"));
assert_eq!(a.as_str().len(), 16);
assert!(
!a.as_str().contains("repo"),
"the id must not embed the path"
);
}
#[test]
fn reported_paths_must_stay_inside_the_worktree() {
check_reported_path("src/tls.rs").expect("relative path is fine");
for bad in ["", "/etc/shadow", "../../secrets", "src/../../etc/passwd"] {
assert!(
matches!(
check_reported_path(bad),
Err(ExecError::PathEscapesWorktree(_))
),
"{bad:?} should be refused"
);
}
}
}