use std::{
path::{Path, PathBuf},
sync::{
atomic::{AtomicI32, Ordering},
mpsc, Arc,
},
time::{Duration, Instant},
};
use serde::{Deserialize, Serialize};
use crate::{
sandbox::{HostProbe, ShellEnforcement},
shell::{ShellExecError, ShellResult},
types::{CapabilityPolicy, StagedSession},
workdir_fs::WorkdirRoot,
};
pub const CHECK_OUTPUT_TAIL_BYTES: usize = 4096;
pub const CHECK_LOG_FILE: &str = "logs/check.log";
const KILLED_OUTPUT_WAIT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
pub struct SessionCheck {
command: String,
binary: String,
args: Vec<String>,
files: Option<PathBuf>,
timeout: Duration,
}
impl SessionCheck {
pub fn new(command: &str, files: Option<PathBuf>, timeout: Duration) -> Result<Self, String> {
let mut words = crate::shell::split_shell_words(command);
if words.is_empty() {
return Err("the check command is empty".to_string());
}
if timeout.is_zero() {
return Err("the check timeout is zero".to_string());
}
let binary = words.remove(0);
Ok(Self {
command: command.to_string(),
binary,
args: words,
files,
timeout,
})
}
#[must_use]
pub fn binary(&self) -> &str {
&self.binary
}
#[must_use]
pub fn args(&self) -> &[String] {
&self.args
}
#[must_use]
pub fn command(&self) -> &str {
&self.command
}
#[must_use]
pub fn files(&self) -> Option<&Path> {
self.files.as_deref()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckResult {
Pass,
Fail,
TimedOut,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckOutcome {
pub result: CheckResult,
pub exit_code: Option<i32>,
pub duration_ms: u64,
pub resource_limit: Option<String>,
pub output_tail: String,
pub output_log: Option<PathBuf>,
pub error: Option<String>,
pub containment: String,
}
#[derive(Debug, Clone)]
pub struct CheckRunner {
session_id: String,
policy: CapabilityPolicy,
containment: crate::containment::ContainmentDecision,
host_probe: HostProbe,
temp_mapping: crate::temp_dir::TempMapping,
accessible_root: WorkdirRoot,
records_root: WorkdirRoot,
records_dir: PathBuf,
sessions_dir: Option<PathBuf>,
}
impl StagedSession {
#[must_use]
pub fn check_runner(&self) -> CheckRunner {
CheckRunner {
session_id: self.session_id.clone(),
policy: self.capability_policy.clone(),
containment: self.containment,
host_probe: self.host_probe.clone(),
temp_mapping: self.temp_mapping,
accessible_root: self.accessible_root.clone(),
records_root: self.records_root.clone(),
records_dir: self.records_dir.clone(),
sessions_dir: self
.runtime_dir
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf),
}
}
}
impl CheckRunner {
pub fn run(&self, check: &SessionCheck) -> CheckOutcome {
let containment = self.containment.achieved().as_str().to_string();
let error = |error: String| CheckOutcome {
result: CheckResult::Error,
exit_code: None,
duration_ms: 0,
resource_limit: None,
output_tail: String::new(),
output_log: None,
error: Some(error),
containment: containment.clone(),
};
if let Some(files) = check.files() {
if let Err(refusal) = copy_tree_into(files, &self.accessible_root) {
return error(format!("could not copy the check's files: {refusal}"));
}
}
let checked = match self.enforcement() {
Ok((enforcement, runtime_dir)) => {
let checked =
run_with_timeout(check, &self.accessible_root, &self.policy, &enforcement);
enforcement.end_session();
drop(runtime_dir);
checked
}
Err(reason) => return error(reason),
};
let (result, exit_code, duration_ms, shell) = match checked {
Checked::Exited(shell) => {
let result = if shell.exit_code == 0 {
CheckResult::Pass
} else {
CheckResult::Fail
};
(
result,
Some(shell.exit_code),
shell.duration_ms,
Some(shell),
)
}
Checked::TimedOut { elapsed, shell } => (
CheckResult::TimedOut,
None,
elapsed.as_millis().try_into().unwrap_or(u64::MAX),
shell,
),
Checked::Failed(reason) => return error(reason),
};
let (stdout, stderr) = shell
.as_ref()
.map(|shell| (shell.stdout.as_str(), shell.stderr.as_str()))
.unwrap_or_default();
let mut output = Vec::with_capacity(stdout.len() + stderr.len());
output.extend_from_slice(stdout.as_bytes());
output.extend_from_slice(stderr.as_bytes());
let (output_log, log_error) = match self.records_root.write_file_exact_mode(
CHECK_LOG_FILE,
&output,
crate::murmur_home::PRIVATE_FILE_MODE,
) {
Ok(()) => (Some(self.records_dir.join(CHECK_LOG_FILE)), None),
Err(error) => (
None,
Some(format!("could not write {CHECK_LOG_FILE}: {error}")),
),
};
let tail_start = output.len().saturating_sub(CHECK_OUTPUT_TAIL_BYTES);
CheckOutcome {
result,
exit_code,
duration_ms,
resource_limit: shell
.as_ref()
.and_then(|shell| shell.resource_limit_hit.clone()),
output_tail: String::from_utf8_lossy(&output[tail_start..]).into_owned(),
output_log,
error: log_error,
containment,
}
}
fn enforcement(&self) -> Result<(ShellEnforcement, crate::runtime_dir::RuntimeDir), String> {
let check_id = format!("{}-check", self.session_id);
let prepared = crate::runtime::prepare_launch_scope(
&self.policy,
false,
&check_id,
self.accessible_root.path(),
)
.map_err(|error| error.to_string())?;
let temp = self
.records_root
.open_beneath(crate::temp_dir::SESSION_TEMP_DIR_NAME)
.map_err(|error| error.to_string())?;
let home = self
.records_root
.open_beneath(crate::records_dir::HOME_SUBDIR)
.map_err(|error| error.to_string())?;
let workdir_guard = crate::resources::WorkdirGuard::spawn(
&self.accessible_root,
temp.as_ref(),
home.as_ref(),
Some(&self.records_root),
self.policy.resources.workdir_max_bytes,
);
let sessions_dir = match &self.sessions_dir {
Some(dir) => dir.clone(),
None => crate::runtime_dir::sessions_dir().map_err(|error| error.to_string())?,
};
let runtime_dir =
crate::runtime_dir::RuntimeDir::create(&sessions_dir, &check_id, &self.accessible_root)
.map_err(|reason| format!("the check's runtime directory: {reason}"))?;
let mut roots = runtime_dir
.roots()
.clone()
.with_records(self.records_root.clone());
if let Some(temp) = temp {
roots = roots.with_temp(temp);
}
if let Some(home) = home {
roots = roots.with_home(home);
}
let enforcement =
ShellEnforcement::resolve(&self.policy, &self.containment, self.host_probe.clone())?
.with_host_bounding(prepared.scope, Some(workdir_guard))
.with_runtime_dir(roots, None)
.with_temp_mapping(self.temp_mapping);
Ok((enforcement, runtime_dir))
}
}
enum Checked {
Exited(ShellResult),
TimedOut {
elapsed: Duration,
shell: Option<ShellResult>,
},
Failed(String),
}
fn run_with_timeout(
check: &SessionCheck,
workdir: &WorkdirRoot,
policy: &CapabilityPolicy,
enforcement: &ShellEnforcement,
) -> Checked {
let (sender, receiver) = mpsc::channel::<Result<ShellResult, ShellExecError>>();
let group = Arc::new(AtomicI32::new(0));
let started = Instant::now();
{
let binary = check.binary.clone();
let args = check.args.clone();
let workdir = workdir.clone();
let policy = policy.clone();
let enforcement = enforcement.clone();
let group = Arc::clone(&group);
let spawned = std::thread::Builder::new()
.name("session-check".to_string())
.spawn(move || {
crate::shell::report_spawned_group_to(group);
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
let result = crate::shell::execute_shell(
&binary,
&args,
&[],
&workdir,
&policy,
&enforcement,
);
let _ = sender.send(result);
});
if let Err(error) = spawned {
return Checked::Failed(format!("could not start the check: {error}"));
}
}
let settled = |result: Result<ShellResult, ShellExecError>| match result {
Ok(shell) => Checked::Exited(shell),
Err(error) => Checked::Failed(error.to_string()),
};
match receiver.recv_timeout(check.timeout) {
Ok(result) => settled(result),
Err(mpsc::RecvTimeoutError::Disconnected) => {
Checked::Failed("the check's thread ended without a result".to_string())
}
Err(mpsc::RecvTimeoutError::Timeout) => {
let elapsed = started.elapsed();
enforcement.kill_session_processes();
let pgid = group.load(Ordering::SeqCst);
if pgid > 0 {
crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL);
}
let shell = match receiver.recv_timeout(KILLED_OUTPUT_WAIT) {
Ok(Ok(shell)) => Some(shell),
_ => None,
};
Checked::TimedOut { elapsed, shell }
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub relative: PathBuf,
pub is_dir: bool,
pub mode: u32,
}
pub fn walk_tree(root: &Path) -> Result<Vec<TreeEntry>, String> {
use std::os::unix::{ffi::OsStrExt as _, fs::PermissionsExt as _};
fn walk(dir: &Path, relative: &Path, out: &mut Vec<TreeEntry>) -> Result<(), String> {
let read = |path: &Path, error: std::io::Error| format!("{}: {error}", path.display());
for entry in std::fs::read_dir(dir).map_err(|error| read(dir, error))? {
let entry = entry.map_err(|error| read(dir, error))?;
let path = entry.path();
let rel = relative.join(entry.file_name());
let meta = std::fs::symlink_metadata(&path).map_err(|error| read(&path, error))?;
let mode = meta.permissions().mode() & 0o7777;
let file_type = meta.file_type();
if file_type.is_symlink() {
return Err(format!("{} is a symbolic link", path.display()));
}
if file_type.is_dir() {
out.push(TreeEntry {
relative: rel.clone(),
is_dir: true,
mode,
});
walk(&path, &rel, out)?;
} else if file_type.is_file() {
out.push(TreeEntry {
relative: rel,
is_dir: false,
mode,
});
} else {
return Err(format!("{} is a special file", path.display()));
}
}
Ok(())
}
let mut entries = Vec::new();
walk(root, Path::new(""), &mut entries)?;
entries.sort_by(|a, b| {
a.relative
.as_os_str()
.as_bytes()
.cmp(b.relative.as_os_str().as_bytes())
});
Ok(entries)
}
pub fn copy_tree_into(source: &Path, workdir: &WorkdirRoot) -> Result<(), String> {
const FILLING_DIR_MODE: u32 = 0o700;
let entries = walk_tree(source)?;
for entry in &entries {
let rel = entry.relative.to_string_lossy();
if entry.is_dir {
workdir
.create_dir_all(rel.as_ref(), FILLING_DIR_MODE)
.and_then(|()| workdir.set_dir_mode(rel.as_ref(), FILLING_DIR_MODE))
.map_err(|error| error.to_string())?;
} else {
let path = source.join(&entry.relative);
let bytes =
std::fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
workdir
.write_file_exact_mode(rel.as_ref(), &bytes, entry.mode)
.map_err(|error| error.to_string())?;
}
}
for entry in entries.iter().rev().filter(|entry| entry.is_dir) {
let rel = entry.relative.to_string_lossy();
workdir
.set_dir_mode(rel.as_ref(), entry.mode)
.map_err(|error| error.to_string())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_command_splits_as_the_shell_tool_splits_it() {
let command = r#"grep -q "fixed value" 'a b'/c\ d.txt"#;
let check = SessionCheck::new(command, None, Duration::from_secs(1)).unwrap();
let mut words = crate::shell::split_shell_words(command);
assert_eq!(check.binary(), words.remove(0));
assert_eq!(check.args(), words.as_slice());
assert_eq!(check.binary(), "grep");
assert_eq!(check.args(), ["-q", "fixed value", "a b/c d.txt"]);
assert_eq!(check.command(), command);
}
#[test]
fn an_empty_command_is_refused() {
for command in ["", " ", "\t\n"] {
let refused = SessionCheck::new(command, None, Duration::from_secs(1)).unwrap_err();
assert!(refused.contains("empty"), "{refused}");
}
}
#[test]
fn a_zero_timeout_is_refused() {
let refused = SessionCheck::new("true", None, Duration::ZERO).unwrap_err();
assert!(refused.contains("timeout"), "{refused}");
}
#[test]
fn outcomes_serialize_in_snake_case() {
let outcome = CheckOutcome {
result: CheckResult::TimedOut,
exit_code: None,
duration_ms: 2000,
resource_limit: None,
output_tail: String::new(),
output_log: None,
error: None,
containment: "scoped".to_string(),
};
let json = serde_json::to_value(&outcome).unwrap();
assert_eq!(json["result"], "timed_out");
assert_eq!(json["containment"], "scoped");
let back: CheckOutcome = serde_json::from_value(json).unwrap();
assert_eq!(back, outcome);
}
#[test]
fn check_files_with_a_link_are_refused_before_anything_is_written() {
let source = tempfile::tempdir().unwrap();
std::fs::write(source.path().join("a.txt"), "a").unwrap();
std::os::unix::fs::symlink("/etc/passwd", source.path().join("z-link")).unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = WorkdirRoot::open(workdir.path()).unwrap();
let refused = copy_tree_into(source.path(), &root).unwrap_err();
assert!(refused.contains("z-link"), "{refused}");
assert!(!workdir.path().join("a.txt").exists());
}
#[test]
fn check_files_keep_their_mode_and_overwrite_what_is_there() {
use std::os::unix::fs::PermissionsExt as _;
let source = tempfile::tempdir().unwrap();
std::fs::create_dir(source.path().join("sub")).unwrap();
std::fs::write(source.path().join("sub/run.sh"), "new").unwrap();
std::fs::set_permissions(
source.path().join("sub/run.sh"),
std::fs::Permissions::from_mode(0o751),
)
.unwrap();
let workdir = tempfile::tempdir().unwrap();
std::fs::create_dir(workdir.path().join("sub")).unwrap();
std::fs::write(workdir.path().join("sub/run.sh"), "old").unwrap();
let root = WorkdirRoot::open(workdir.path()).unwrap();
copy_tree_into(source.path(), &root).unwrap();
let copied = workdir.path().join("sub/run.sh");
assert_eq!(std::fs::read_to_string(&copied).unwrap(), "new");
assert_eq!(
std::fs::metadata(&copied).unwrap().permissions().mode() & 0o7777,
0o751
);
}
#[test]
fn a_read_only_directory_in_check_files_is_filled_then_closed() {
use std::os::unix::fs::PermissionsExt as _;
let source = tempfile::tempdir().unwrap();
std::fs::create_dir(source.path().join("ro")).unwrap();
std::fs::write(source.path().join("ro/t.txt"), "t").unwrap();
std::fs::set_permissions(
source.path().join("ro"),
std::fs::Permissions::from_mode(0o555),
)
.unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = WorkdirRoot::open(workdir.path()).unwrap();
copy_tree_into(source.path(), &root).unwrap();
let copied = workdir.path().join("ro");
assert_eq!(std::fs::read_to_string(copied.join("t.txt")).unwrap(), "t");
assert_eq!(
std::fs::metadata(&copied).unwrap().permissions().mode() & 0o7777,
0o555
);
copy_tree_into(source.path(), &root).unwrap();
for dir in [source.path().join("ro"), copied] {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[test]
fn a_link_the_agent_left_is_not_followed() {
let source = tempfile::tempdir().unwrap();
std::fs::create_dir(source.path().join("hidden")).unwrap();
std::fs::write(source.path().join("hidden/check.txt"), "x").unwrap();
let outside = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), workdir.path().join("hidden")).unwrap();
let root = WorkdirRoot::open(workdir.path()).unwrap();
let refused = copy_tree_into(source.path(), &root).unwrap_err();
assert!(refused.contains("hidden"), "{refused}");
assert!(std::fs::read_dir(outside.path()).unwrap().next().is_none());
}
}