use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use async_trait::async_trait;
use boundgraph_core::{GrantAtom, GrantSet};
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
pub const MAX_OUTPUT: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub program: String,
pub args: Vec<String>,
pub workspace: PathBuf,
pub timeout: Duration,
}
impl Command {
pub fn new(
program: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
workspace: impl Into<PathBuf>,
) -> Self {
Self {
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
workspace: workspace.into(),
timeout: DEFAULT_TIMEOUT,
}
}
#[must_use]
pub fn within(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn rendered(&self) -> String {
std::iter::once(self.program.clone())
.chain(self.args.iter().cloned())
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub exit: Option<i32>,
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
pub truncated: bool,
pub duration: Duration,
}
impl Outcome {
pub fn succeeded(&self) -> bool {
self.exit == Some(0) && !self.timed_out
}
pub fn diagnostics(&self) -> String {
match (self.stderr.trim(), self.stdout.trim()) {
("", "") => String::new(),
(err, "") => err.to_string(),
("", out) => out.to_string(),
(err, out) => format!("{err}\n{out}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SandboxError {
#[error("this sandbox cannot enforce `{atom}`, so it will not run {command}")]
Unenforceable {
atom: String,
command: String,
},
#[error("{command} could not be started: {detail}")]
Spawn {
command: String,
detail: String,
},
#[error("the sandbox is unavailable: {0}")]
Unavailable(String),
}
#[async_trait]
pub trait Sandbox: Send + Sync + fmt::Debug {
async fn run(&self, command: &Command, grant: &GrantSet) -> Result<Outcome, SandboxError>;
}
#[cfg(any(feature = "unconfined", test))]
fn cap(bytes: Vec<u8>) -> (String, bool) {
let truncated = bytes.len() > MAX_OUTPUT;
let kept = if truncated {
let mut end = MAX_OUTPUT;
while end > 0 && (bytes[end] & 0xc0) == 0x80 {
end -= 1;
}
&bytes[..end]
} else {
&bytes[..]
};
(String::from_utf8_lossy(kept).into_owned(), truncated)
}
#[cfg(any(feature = "unconfined", test))]
fn enforceable(atom: &GrantAtom, workspace: &Path) -> bool {
match (atom.domain.as_str(), atom.action.as_str()) {
("proc", "exec") => true,
("fs", "read" | "list" | "stat") => {
let root = normalise(workspace);
let resource = atom.resource.replace('\\', "/");
let resource = resource
.strip_suffix("**")
.unwrap_or(&resource)
.trim_end_matches('/');
resource == root || resource.starts_with(&format!("{root}/"))
}
_ => false,
}
}
pub fn normalise(path: &Path) -> String {
let text = path.to_string_lossy().replace('\\', "/");
text.strip_suffix('/').unwrap_or(&text).to_string()
}
pub fn workspace_grant(workspace: &Path, program: &str) -> GrantSet {
let root = normalise(workspace);
GrantSet::new([
GrantAtom::new("fs", "read", format!("{root}/**")),
GrantAtom::new("fs", "list", format!("{root}/**")),
GrantAtom::new("proc", "exec", program),
])
}
#[cfg(feature = "unconfined")]
pub use unconfined::Unconfined;
#[cfg(feature = "unconfined")]
mod unconfined {
use super::*;
#[derive(Debug, Clone, Default)]
pub struct Unconfined {
keep_env: Vec<String>,
}
const ESSENTIAL_ENV: &[&str] = &[
"PATH",
"HOME",
"SystemRoot",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERPROFILE",
"LANG",
"LC_ALL",
];
impl Unconfined {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn passing_through(mut self, name: impl Into<String>) -> Self {
self.keep_env.push(name.into());
self
}
pub(super) fn keeps(&self, name: &str) -> bool {
ESSENTIAL_ENV.contains(&name) || self.keep_env.iter().any(|k| k == name)
}
}
#[async_trait]
impl Sandbox for Unconfined {
async fn run(&self, command: &Command, grant: &GrantSet) -> Result<Outcome, SandboxError> {
if let Some(atom) = grant
.iter()
.find(|atom| !enforceable(atom, &command.workspace))
{
return Err(SandboxError::Unenforceable {
atom: atom.to_string(),
command: command.rendered(),
});
}
let mut child = tokio::process::Command::new(&command.program);
child
.args(&command.args)
.current_dir(&command.workspace)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.env_clear();
for (name, value) in std::env::vars() {
if self.keeps(&name) {
child.env(name, value);
}
}
let started = std::time::Instant::now();
let spawned = child.spawn().map_err(|e| SandboxError::Spawn {
command: command.rendered(),
detail: e.to_string(),
})?;
let finished = tokio::time::timeout(command.timeout, spawned.wait_with_output()).await;
let duration = started.elapsed();
match finished {
Ok(Ok(output)) => {
let (stdout, out_cut) = cap(output.stdout);
let (stderr, err_cut) = cap(output.stderr);
Ok(Outcome {
exit: output.status.code(),
stdout,
stderr,
timed_out: false,
truncated: out_cut || err_cut,
duration,
})
}
Ok(Err(e)) => Err(SandboxError::Spawn {
command: command.rendered(),
detail: e.to_string(),
}),
Err(_elapsed) => Ok(Outcome {
exit: None,
stdout: String::new(),
stderr: String::new(),
timed_out: true,
truncated: false,
duration,
}),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn workspace() -> PathBuf {
PathBuf::from("/work/repo")
}
#[test]
fn a_command_renders_as_it_would_be_typed() {
let c = Command::new("cargo", ["check", "--all-targets"], workspace());
assert_eq!(c.rendered(), "cargo check --all-targets");
assert_eq!(c.timeout, DEFAULT_TIMEOUT);
}
#[test]
fn a_timeout_can_be_narrowed() {
let c = Command::new("cargo", ["check"], workspace()).within(Duration::from_secs(5));
assert_eq!(c.timeout, Duration::from_secs(5));
}
#[test]
fn a_workspace_grant_reads_the_workspace_and_execs_one_program() {
let grant = workspace_grant(&workspace(), "cargo");
assert_eq!(grant.len(), 3);
assert!(grant.iter().all(|a| a.action != "write"));
assert!(grant.iter().any(|a| a.resource == "/work/repo/**"));
assert!(
grant
.iter()
.any(|a| a.domain == "proc" && a.resource == "cargo")
);
}
#[test]
fn a_workspace_grant_is_within_the_checker_ceiling() {
use boundgraph_grant::{Tri, set_leq};
let grant = workspace_grant(&workspace(), "/usr/bin/cargo");
assert_eq!(set_leq(&grant, &crate::checker_ceiling()), Tri::Yes);
}
#[test]
fn a_windows_path_still_yields_a_decidable_grant() {
use boundgraph_grant::{Tri, set_leq};
let grant = workspace_grant(Path::new(r"D:\work\repo"), "cargo.exe");
assert!(grant.iter().any(|a| a.resource == "D:/work/repo/**"));
assert_eq!(set_leq(&grant, &crate::checker_ceiling()), Tri::Yes);
}
#[test]
fn normalising_drops_a_trailing_separator() {
assert_eq!(normalise(Path::new("/work/repo/")), "/work/repo");
assert_eq!(normalise(Path::new(r"D:\work\repo\")), "D:/work/repo");
}
#[test]
fn reading_under_the_workspace_and_execing_are_enforceable() {
let root = workspace();
for atom in [
"fs:read:/work/repo/**",
"fs:list:/work/repo/src/**",
"fs:stat:/work/repo/Cargo.toml",
"proc:exec:cargo",
] {
let atom: GrantAtom = atom.parse().unwrap();
assert!(enforceable(&atom, &root), "{atom} should be accepted");
}
}
#[test]
fn anything_it_cannot_enforce_is_refused() {
let root = workspace();
for atom in [
"fs:write:/work/repo/**",
"fs:read:/etc/**",
"fs:read:/work/other/**",
"fs:read:/work/repository/**",
"fs:read:/work/repo-backup/secrets",
"net:egress:api.example.com:443",
"secret:use:stripe_key",
"env:read:AWS_SECRET_ACCESS_KEY",
] {
let atom: GrantAtom = atom.parse().unwrap();
assert!(!enforceable(&atom, &root), "{atom} should be refused");
}
}
#[test]
fn output_is_capped_on_a_character_boundary() {
let long = "é".repeat(MAX_OUTPUT);
let (kept, truncated) = cap(long.into_bytes());
assert!(truncated);
assert!(kept.len() <= MAX_OUTPUT);
assert!(!kept.contains('\u{fffd}'), "cut mid-character");
}
#[test]
fn short_output_is_kept_whole_and_not_flagged() {
let (kept, truncated) = cap(b"error: expected `;`".to_vec());
assert_eq!(kept, "error: expected `;`");
assert!(!truncated);
}
#[test]
fn diagnostics_read_both_streams() {
let outcome = |out: &str, err: &str| Outcome {
exit: Some(1),
stdout: out.into(),
stderr: err.into(),
timed_out: false,
truncated: false,
duration: Duration::ZERO,
};
assert_eq!(outcome("", "on stderr").diagnostics(), "on stderr");
assert_eq!(outcome("on stdout", "").diagnostics(), "on stdout");
assert_eq!(outcome("out", "err").diagnostics(), "err\nout");
assert!(outcome("", "").diagnostics().is_empty());
}
#[test]
fn a_timed_out_command_did_not_succeed_whatever_its_exit_status() {
let killed = Outcome {
exit: Some(0),
stdout: String::new(),
stderr: String::new(),
timed_out: true,
truncated: false,
duration: Duration::from_secs(300),
};
assert!(!killed.succeeded());
}
#[cfg(feature = "unconfined")]
mod unconfined {
use super::*;
fn command(succeed: bool) -> Command {
let workspace = std::env::temp_dir();
if cfg!(windows) {
let code = if succeed { "exit 0" } else { "exit 3" };
Command::new("cmd", ["/C", code], workspace)
} else {
let code = if succeed { "0" } else { "3" };
Command::new("sh", ["-c", &format!("exit {code}")], workspace)
}
}
fn grant(command: &Command) -> GrantSet {
workspace_grant(&command.workspace, &command.program)
}
#[tokio::test]
async fn a_successful_command_reports_a_zero_exit() {
let c = command(true);
let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
assert_eq!(outcome.exit, Some(0));
assert!(outcome.succeeded());
assert!(!outcome.timed_out);
}
#[tokio::test]
async fn a_failing_command_is_an_outcome_rather_than_an_error() {
let c = command(false);
let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
assert_eq!(outcome.exit, Some(3));
assert!(!outcome.succeeded());
}
#[tokio::test]
async fn output_comes_back_on_the_stream_it_was_written_to() {
let workspace = std::env::temp_dir();
let c = if cfg!(windows) {
Command::new("cmd", ["/C", "echo hello"], workspace)
} else {
Command::new("sh", ["-c", "echo hello"], workspace)
};
let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
assert!(outcome.stdout.contains("hello"), "{outcome:?}");
}
#[tokio::test]
async fn a_command_that_outlives_its_timeout_is_killed_and_says_so() {
let workspace = std::env::temp_dir();
let c = if cfg!(windows) {
Command::new(
"cmd",
["/C", "for /L %i in (1,1,200000000) do @rem"],
workspace,
)
} else {
Command::new("sh", ["-c", "sleep 30"], workspace)
}
.within(Duration::from_millis(250));
let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
assert!(outcome.timed_out);
assert_eq!(outcome.exit, None);
assert!(!outcome.succeeded());
assert!(outcome.duration < Duration::from_secs(10));
}
#[tokio::test]
async fn a_grant_it_cannot_enforce_is_refused_before_anything_runs() {
let c = command(true);
let over = GrantSet::parse(["net:egress:api.example.com:443"]).unwrap();
let err = Unconfined::new().run(&c, &over).await.unwrap_err();
assert!(matches!(err, SandboxError::Unenforceable { .. }), "{err:?}");
}
#[tokio::test]
async fn a_program_that_does_not_exist_is_a_spawn_error() {
let c = Command::new("boundgraph-no-such-program", [""; 0], std::env::temp_dir());
let err = Unconfined::new().run(&c, &grant(&c)).await.unwrap_err();
assert!(matches!(err, SandboxError::Spawn { .. }), "{err:?}");
}
fn a_non_essential_variable() -> Option<(String, String)> {
let runner = Unconfined::new();
std::env::vars().find(|(name, value)| {
!runner.keeps(name)
&& !value.is_empty()
&& value.len() > 3
&& value.chars().all(|c| c.is_ascii_alphanumeric())
})
}
fn echoing(name: &str) -> Command {
let workspace = std::env::temp_dir();
if cfg!(windows) {
Command::new(
"cmd",
["/C".to_string(), format!("echo [%{name}%]")],
workspace,
)
} else {
Command::new(
"sh",
["-c".to_string(), format!("echo [${name}]")],
workspace,
)
}
}
#[tokio::test]
async fn the_child_environment_is_cleared_except_for_the_essentials() {
let Some((name, value)) = a_non_essential_variable() else {
return; };
let c = echoing(&name);
let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
assert!(
!outcome.stdout.contains(&value),
"the child inherited `{name}`: {outcome:?}"
);
}
#[tokio::test]
async fn a_variable_can_be_passed_through_deliberately() {
let Some((name, value)) = a_non_essential_variable() else {
return;
};
let c = echoing(&name);
let outcome = Unconfined::new()
.passing_through(&name)
.run(&c, &grant(&c))
.await
.unwrap();
assert!(outcome.stdout.contains(&value), "{outcome:?}");
}
#[tokio::test]
async fn the_essentials_reach_the_child() {
let c = echoing("PATH");
let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
assert!(outcome.stdout.trim().len() > 3, "{outcome:?}");
assert!(!outcome.stdout.contains("[]"), "{outcome:?}");
}
}
}