use std::path::PathBuf;
use std::process::{Command, Stdio};
use rto_graph::{Isolation, RunnerKind};
use crate::adapter::{Adapter, AssetPaths, Invocation, NativeContext};
use crate::assets;
use crate::clock::rfc3339_utc;
use crate::ingest::assemble;
use crate::runner::{AnalysisRequest, AnalysisResponse, AnalyzerRunner, ExecError, check_request};
use crate::snippet::WorktreeSnippets;
pub const MAX_OUTPUT_BYTES: usize = 256 << 20;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SubprocessError {
#[error(
"running `{analyzer}` as a subprocess provides no isolation: the analyzer executes on \
this host with access to it. Pass --allow-unsandboxed to accept that; the run's evidence \
will record isolation=none."
)]
UnsandboxedNotAllowed {
analyzer: String,
},
#[error(
"analyzer binary `{program}` not found on PATH (needed to run `{analyzer}`). Roteiro does \
not install analyzers; install it yourself, or produce the report elsewhere and use \
`roteiro security ingest`."
)]
BinaryNotFound {
program: String,
analyzer: String,
},
#[error("could not execute `{program}`: {source}")]
Spawn {
program: String,
source: std::io::Error,
},
#[error(
"`{program}` exited with status {status}, which it does not use for a completed scan \
(expected one of: {expected}). A scan that failed is not a clean result, so nothing was \
stored.{stderr}"
)]
UnexpectedStatus {
program: String,
status: i32,
expected: String,
stderr: String,
},
#[error("`{program}` produced more than {max} bytes of output; refusing to read it")]
OutputTooLarge {
program: String,
max: usize,
},
}
#[derive(Debug)]
pub struct SubprocessRunner {
adapter: &'static dyn Adapter,
assets: Vec<(&'static str, PathBuf)>,
assets_root: PathBuf,
allow_unsandboxed: bool,
}
impl SubprocessRunner {
pub fn new(
analyzer: &str,
assets_root: &std::path::Path,
allow_unsandboxed: bool,
) -> Result<Self, ExecError> {
let adapter =
crate::adapter::adapter_for(analyzer).ok_or_else(|| ExecError::UnknownAnalyzer {
requested: analyzer.to_owned(),
known: crate::adapter::known_analyzers().join(", "),
})?;
if !allow_unsandboxed {
return Err(SubprocessError::UnsandboxedNotAllowed {
analyzer: analyzer.to_owned(),
}
.into());
}
Ok(Self {
adapter,
assets: assets::resolve(assets_root, analyzer)?,
assets_root: assets_root.to_path_buf(),
allow_unsandboxed,
})
}
#[must_use]
pub fn adapter(&self) -> &'static dyn Adapter {
self.adapter
}
#[must_use]
pub fn invocation(&self) -> Invocation {
self.adapter.command(&AssetPaths::new(&self.assets))
}
fn rules_digest(&self, root: &std::path::Path) -> Option<String> {
self.assets.iter().find_map(|(id, _)| {
let spec = assets::asset(id)?;
(spec.kind == assets::AssetKind::Rules)
.then(|| assets::installed(root, spec).map(|record| record.digest))
.flatten()
})
}
}
impl AnalyzerRunner for SubprocessRunner {
fn kind(&self) -> RunnerKind {
RunnerKind::Subprocess
}
fn isolation(&self) -> Isolation {
Isolation::None
}
fn run(&self, request: &AnalysisRequest) -> Result<AnalysisResponse, ExecError> {
check_request(request)?;
if !self.allow_unsandboxed {
return Err(SubprocessError::UnsandboxedNotAllowed {
analyzer: request.analyzer.clone(),
}
.into());
}
let invocation = self.invocation();
let started_at = rfc3339_utc(std::time::SystemTime::now());
let output = execute(&invocation, &request.worktree.path, &request.analyzer)?;
let ended_at = rfc3339_utc(std::time::SystemTime::now());
let snippets = WorktreeSnippets::new(&request.worktree.path);
let ctx = NativeContext {
started_at,
ended_at,
analyzer_version: analyzer_version(&invocation, &request.worktree.path),
exit_status: output.status,
source: &request.source,
rules_digest: self.rules_digest(&self.assets_root),
advisory_db: assets::advisory_db_evidence(&self.assets_root, &request.analyzer),
snippets: &snippets,
};
let report = self.adapter.normalize(&output.stdout, &ctx)?;
assemble(
report,
request,
self.kind(),
self.isolation(),
&output.stdout,
)
}
}
struct Captured {
stdout: Vec<u8>,
status: i32,
}
fn execute(
invocation: &Invocation,
worktree: &std::path::Path,
analyzer: &str,
) -> Result<Captured, SubprocessError> {
let mut command = Command::new(&invocation.program);
command
.args(&invocation.args)
.current_dir(worktree)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
scrub_environment(&mut command);
let output = command.output().map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
SubprocessError::BinaryNotFound {
program: invocation.program.clone(),
analyzer: analyzer.to_owned(),
}
} else {
SubprocessError::Spawn {
program: invocation.program.clone(),
source,
}
}
})?;
if output.stdout.len() > MAX_OUTPUT_BYTES {
return Err(SubprocessError::OutputTooLarge {
program: invocation.program.clone(),
max: MAX_OUTPUT_BYTES,
});
}
let status = output.status.code().unwrap_or(-1);
if !invocation.success_statuses.contains(&status) {
return Err(SubprocessError::UnexpectedStatus {
program: invocation.program.clone(),
status,
expected: invocation
.success_statuses
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "),
stderr: stderr_tail(&output.stderr),
});
}
Ok(Captured {
stdout: output.stdout,
status,
})
}
fn stderr_tail(stderr: &[u8]) -> String {
const MAX_LINES: usize = 8;
const MAX_BYTES: usize = 4_000;
let text = String::from_utf8_lossy(stderr);
let trimmed = text.trim_end();
if trimmed.is_empty() {
return String::new();
}
let tail: Vec<&str> = trimmed
.lines()
.rev()
.take(MAX_LINES)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let mut joined = tail.join("\n ");
if joined.len() > MAX_BYTES {
joined.truncate(MAX_BYTES);
joined.push('…');
}
format!("\n its stderr ended:\n {joined}")
}
fn scrub_environment(command: &mut Command) {
command.env_clear();
for key in [
"PATH",
"HOME",
"USERPROFILE",
"SystemRoot",
"TMPDIR",
"TEMP",
] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
}
}
command.env("LC_ALL", "C");
command.env("SEMGREP_SEND_METRICS", "off");
}
fn analyzer_version(invocation: &Invocation, worktree: &std::path::Path) -> Option<String> {
let mut args: Vec<&String> = invocation
.args
.iter()
.take_while(|a| !a.starts_with('-'))
.collect();
let version_flag = "--version".to_owned();
args.push(&version_flag);
let mut command = Command::new(&invocation.program);
command
.args(&args)
.current_dir(worktree)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
scrub_environment(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text.lines().find(|l| !l.trim().is_empty())?.trim();
let version = line.split_whitespace().next_back().unwrap_or(line);
(!version.is_empty()).then(|| version.to_owned())
}
#[cfg(test)]
mod tests {
use super::{
MAX_OUTPUT_BYTES, SubprocessError, SubprocessRunner, scrub_environment, stderr_tail,
};
use crate::assets;
use crate::runner::ExecError;
use std::path::PathBuf;
struct Cache(PathBuf);
impl Cache {
fn warm(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("rto-exec-subprocess-{name}"));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("create");
let cache = Self(dir);
assets::provision(&cache.0, assets::asset("semgrep-rules").expect("spec"))
.expect("provision");
cache
}
fn cold(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("rto-exec-subprocess-{name}"));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("create");
Self(dir)
}
}
impl Drop for Cache {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
#[test]
fn refuses_to_exist_without_the_unsandboxed_flag() {
let cache = Cache::warm("no-flag");
let err = SubprocessRunner::new("semgrep", &cache.0, false)
.expect_err("must refuse without the flag");
assert!(matches!(
err,
ExecError::Subprocess(SubprocessError::UnsandboxedNotAllowed { .. })
));
let message = err.to_string();
assert!(message.contains("--allow-unsandboxed"), "{message}");
assert!(message.contains("isolation=none"), "{message}");
}
#[test]
fn refuses_a_cold_cache_before_executing_anything() {
let cache = Cache::cold("cold");
let err = SubprocessRunner::new("semgrep", &cache.0, true).expect_err("cold cache");
assert!(matches!(err, ExecError::AssetsUnavailableOffline { .. }));
assert!(err.to_string().contains("assets-unavailable-offline"));
}
#[test]
fn refuses_an_analyzer_this_build_cannot_run() {
let cache = Cache::warm("unknown");
let err = SubprocessRunner::new("no-such-analyzer", &cache.0, true).expect_err("unknown");
let ExecError::UnknownAnalyzer { known, .. } = &err else {
panic!("expected UnknownAnalyzer, got {err:?}");
};
assert!(known.contains("semgrep"), "{known}");
}
#[test]
fn labels_itself_as_a_subprocess_with_no_isolation() {
use crate::runner::AnalyzerRunner;
let cache = Cache::warm("labels");
let runner = SubprocessRunner::new("semgrep", &cache.0, true).expect("runner");
assert_eq!(runner.kind(), rto_graph::RunnerKind::Subprocess);
assert_eq!(runner.isolation(), rto_graph::Isolation::None);
}
#[test]
fn the_invocation_points_at_the_provisioned_rules() {
let cache = Cache::warm("invocation");
let runner = SubprocessRunner::new("semgrep", &cache.0, true).expect("runner");
let invocation = runner.invocation();
let config = invocation
.args
.iter()
.position(|a| a == "--config")
.map(|i| invocation.args[i + 1].clone())
.expect("a --config argument");
assert_eq!(
PathBuf::from(config),
assets::asset_path(&cache.0, assets::asset("semgrep-rules").expect("spec"))
);
}
#[test]
fn the_child_environment_carries_no_ambient_credentials() {
let mut command = std::process::Command::new("true");
scrub_environment(&mut command);
let passed: Vec<String> = command
.get_envs()
.filter_map(|(k, v)| v.map(|_| k.to_string_lossy().into_owned()))
.collect();
for secret in [
"GITHUB_TOKEN",
"AWS_ACCESS_KEY_ID",
"SEMGREP_APP_TOKEN",
"SSH_AUTH_SOCK",
] {
assert!(
!passed.contains(&secret.to_owned()),
"{secret} was passed through"
);
}
assert!(
passed.contains(&"PATH".to_owned()),
"the child still needs PATH"
);
assert!(passed.contains(&"LC_ALL".to_owned()));
}
#[test]
fn a_failure_message_carries_a_bounded_tail_of_stderr() {
assert_eq!(stderr_tail(b""), "");
assert_eq!(stderr_tail(b" \n "), "");
let tail = stderr_tail(b"line1\nline2\nline3");
assert!(tail.contains("line3"), "{tail}");
let noisy: Vec<String> = (0..500).map(|i| format!("line {i}")).collect();
let tail = stderr_tail(noisy.join("\n").as_bytes());
assert!(tail.contains("line 499"), "the tail must be the end");
assert!(
!tail.contains("line 100"),
"and must not be the whole thing"
);
}
#[test]
fn the_output_ceiling_is_a_ceiling_not_a_target() {
assert_eq!(MAX_OUTPUT_BYTES, 256 << 20);
}
}