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::guidance::{Guidance, Line};
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;
const INGEST_INSTEAD: Guidance = Guidance::new(&[Line::Note(&[
"Or run the analyzer elsewhere — CI, a colleague's machine — and read its",
"report in with `roteiro security ingest`, which needs nothing on PATH and",
"produces the same findings as a local run.",
])]);
const NO_HINT: Guidance = Guidance::new(&[Line::Note(&[
"Roteiro does not install analyzers, and has not installed this one; this",
"build knows no install command for that program.",
])]);
#[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}`), so nothing \
ran.{}{}",
.install.map_or(NO_HINT, |hint| hint),
INGEST_INSTEAD
)]
BinaryNotFound {
program: String,
analyzer: String,
install: Option<Guidance>,
},
#[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,
&ChildEnv::default(),
)?;
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),
worktree: Some(&request.worktree.path),
snippets: &snippets,
};
let report = self.adapter.normalize(&output.stdout, &ctx)?;
assemble(
report,
request,
self.kind(),
self.isolation(),
&output.stdout,
)
}
}
pub(crate) struct Captured {
pub(crate) stdout: Vec<u8>,
pub(crate) stderr: Vec<u8>,
pub(crate) status: i32,
}
pub(crate) fn execute(
invocation: &Invocation,
worktree: &std::path::Path,
analyzer: &str,
env: &ChildEnv<'_>,
) -> 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, env);
let output = command.output().map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
SubprocessError::BinaryNotFound {
program: invocation.program.clone(),
analyzer: analyzer.to_owned(),
install: crate::adapter::install_hint(&invocation.program),
}
} 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,
stderr: output.stderr,
status,
})
}
pub(crate) 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}")
}
pub(crate) use crate::child_env::{ChildEnv, scrub_environment};
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, &ChildEnv::default());
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::{
ChildEnv, 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, &ChildEnv::default());
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 extra_variables_are_passed_through_only_when_named() {
let base = [
"PATH",
"HOME",
"USERPROFILE",
"SystemRoot",
"TMPDIR",
"TEMP",
"LC_ALL",
"SEMGREP_SEND_METRICS",
];
let Some(candidate) = std::env::vars()
.map(|(key, _)| key)
.find(|key| !base.contains(&key.as_str()))
else {
return; };
let passed = |extra: &[&str]| -> Vec<String> {
let mut command = std::process::Command::new("true");
scrub_environment(
&mut command,
&ChildEnv {
inherit: extra,
..ChildEnv::default()
},
);
command
.get_envs()
.filter_map(|(k, v)| v.map(|_| k.to_string_lossy().into_owned()))
.collect()
};
assert!(
!passed(&[]).contains(&candidate),
"{candidate} reached the child without being named"
);
assert!(
passed(&[candidate.as_str()]).contains(&candidate),
"{candidate} was named and still did not reach the child"
);
assert!(
!passed(&["ROTEIRO_NO_SUCH_VARIABLE"]).contains(&"ROTEIRO_NO_SUCH_VARIABLE".to_owned())
);
}
#[test]
fn inheriting_a_name_cannot_set_a_value_and_setting_beats_inheriting() {
let value = |env: &ChildEnv<'_>| -> Option<std::ffi::OsString> {
let mut command = std::process::Command::new("true");
scrub_environment(&mut command, env);
command
.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new("ROTEIRO_SEAM_PROBE"))
.and_then(|(_, v)| v.map(std::ffi::OsStr::to_os_string))
};
assert_eq!(
value(&ChildEnv {
inherit: &["ROTEIRO_SEAM_PROBE"],
..ChildEnv::default()
}),
None,
"inheriting an unset name invented a value"
);
let chosen = [("ROTEIRO_SEAM_PROBE", std::ffi::OsString::from("/chosen"))];
assert_eq!(
value(&ChildEnv {
set: &chosen,
..ChildEnv::default()
}),
Some(std::ffi::OsString::from("/chosen"))
);
let home = [("HOME", std::ffi::OsString::from("/chosen-home"))];
let mut command = std::process::Command::new("true");
scrub_environment(
&mut command,
&ChildEnv {
inherit: &["HOME"],
set: &home,
},
);
let passed: Vec<(std::ffi::OsString, Option<std::ffi::OsString>)> = command
.get_envs()
.filter(|(k, _)| *k == std::ffi::OsStr::new("HOME"))
.map(|(k, v)| (k.to_os_string(), v.map(std::ffi::OsStr::to_os_string)))
.collect();
assert_eq!(
passed,
vec![(
std::ffi::OsString::from("HOME"),
Some(std::ffi::OsString::from("/chosen-home"))
)],
"an inherited name overrode a value this process chose"
);
}
#[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);
}
#[test]
fn a_missing_binary_names_the_install_and_keeps_the_ingest_alternative() {
let message = SubprocessError::BinaryNotFound {
program: "semgrep".to_owned(),
analyzer: "semgrep".to_owned(),
install: crate::adapter::install_hint("semgrep"),
}
.to_string();
assert!(message.contains("not found on PATH"), "{message}");
assert!(message.contains("so nothing ran"), "{message}");
assert!(message.contains("pipx install semgrep"), "{message}");
assert!(
message.contains("https://docs.semgrep.dev/getting-started/quickstart"),
"a command ages; the page it came from does not: {message}"
);
assert!(
message.contains("roteiro security ingest"),
"seam (c) is the best part of the old message and must survive: {message}"
);
assert!(message.contains("has not installed this one"), "{message}");
}
#[test]
fn the_hint_follows_the_missing_program_not_the_analyzer() {
let message = SubprocessError::BinaryNotFound {
program: "cargo".to_owned(),
analyzer: "cargo-audit".to_owned(),
install: crate::adapter::install_hint("cargo"),
}
.to_string();
assert!(message.contains("https://rustup.rs"), "{message}");
assert!(
!message.contains("cargo install cargo-audit"),
"a reader with no cargo cannot run a cargo subcommand install: {message}"
);
}
#[test]
fn the_document_quotes_this_refusal_as_it_is_now() {
let doc = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/OFFLINE_SETUP.md")
.canonicalize()
.expect("the document that quotes the refusal must exist");
let text = std::fs::read_to_string(&doc).expect("readable");
let rendered = SubprocessError::BinaryNotFound {
program: "semgrep".to_owned(),
analyzer: "semgrep".to_owned(),
install: crate::adapter::install_hint("semgrep"),
}
.to_string();
let quoted = text
.split("```")
.find(|block| block.trim_start().starts_with("Error: analyzer binary"))
.unwrap_or_else(|| {
panic!(
"{} no longer quotes this refusal at all — the document that tells \
someone how to prepare must show what they will actually see",
doc.display()
)
})
.trim();
assert_eq!(
quoted,
format!("Error: {rendered}").trim(),
"the quote in {} has drifted from the message",
doc.display()
);
assert!(
!text.contains("install it yourself"),
"{} still carries the refusal wording #430 replaced",
doc.display()
);
}
#[test]
fn an_unknown_program_admits_it_rather_than_inventing_a_command() {
let message = SubprocessError::BinaryNotFound {
program: "some-future-analyzer".to_owned(),
analyzer: "future".to_owned(),
install: crate::adapter::install_hint("some-future-analyzer"),
}
.to_string();
assert!(message.contains("knows no install command"), "{message}");
assert!(message.contains("roteiro security ingest"), "{message}");
}
}