use std::path::PathBuf;
use anyhow::{Context, Result};
use super::ContainmentMode;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerRequest {
pub workspace: PathBuf,
pub cwd: PathBuf,
pub temp: PathBuf,
pub mode: ContainmentMode,
pub writable_roots: Vec<PathBuf>,
pub script: String,
}
impl WorkerRequest {
pub fn parse<I, S>(arguments: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString>,
{
let mut workspace = None;
let mut cwd = None;
let mut temp = None;
let mut mode = None;
let mut writable_roots = Vec::new();
let mut script = None;
let mut arguments = arguments.into_iter().map(Into::into);
while let Some(flag) = arguments.next() {
let flag = flag.to_string_lossy().into_owned();
let mut value = || {
arguments
.next()
.with_context(|| format!("{flag} expects a value"))
};
match flag.as_str() {
"--workspace" => workspace = Some(PathBuf::from(value()?)),
"--cwd" => cwd = Some(PathBuf::from(value()?)),
"--temp" => temp = Some(PathBuf::from(value()?)),
"--writable" => writable_roots.push(PathBuf::from(value()?)),
"--mode" => {
let raw = value()?.to_string_lossy().into_owned();
mode = Some(
ContainmentMode::parse(&raw)
.with_context(|| format!("unknown containment mode: {raw}"))?,
);
}
"--script" => script = Some(value()?.to_string_lossy().into_owned()),
other => anyhow::bail!("unknown sandbox worker argument: {other}"),
}
}
let request = Self {
workspace: workspace.context("--workspace is required")?,
cwd: cwd.context("--cwd is required")?,
temp: temp.context("--temp is required")?,
mode: mode.context("--mode is required")?,
writable_roots,
script: script.context("--script is required")?,
};
if request.mode.is_full_access() {
anyhow::bail!("the sandbox worker cannot run danger-full-access");
}
Ok(request)
}
}
pub fn run_from_args<I, S>(arguments: I) -> Result<std::convert::Infallible>
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString>,
{
run(&WorkerRequest::parse(arguments)?)
}
#[cfg(target_os = "linux")]
pub fn run(request: &WorkerRequest) -> Result<std::convert::Infallible> {
use landlock::{
ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
RulesetStatus,
};
use seccompiler::{
BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
SeccompRule,
};
use std::convert::TryInto;
use std::os::unix::process::CommandExt;
let workspace = open_directory_no_symlinks(&request.workspace, None)?;
let relative_cwd = request
.cwd
.strip_prefix(&request.workspace)
.with_context(|| {
format!(
"working directory is outside workspace: {}",
request.cwd.display()
)
})?;
let cwd = open_directory_no_symlinks(relative_cwd, Some(&workspace))?;
unsafe {
if libc::fchdir(std::os::fd::AsRawFd::as_raw_fd(&cwd)) != 0 {
return Err(std::io::Error::last_os_error()).context("enter sandbox working directory");
}
}
let abi = ABI::V3;
let mut ruleset = Ruleset::default()
.handle_access(AccessFs::from_all(abi))?
.create()?
.add_rule(PathBeneath::new(
PathFd::new("/")?,
AccessFs::from_read(abi),
))?
.add_rule(PathBeneath::new(
PathFd::new(&request.temp)?,
AccessFs::from_all(abi),
))?
.add_rule(PathBeneath::new(
PathFd::new("/dev/null")?,
AccessFs::WriteFile | AccessFs::ReadFile,
))?;
if request.mode == ContainmentMode::WorkspaceWrite {
ruleset = ruleset
.add_rule(PathBeneath::new(
PathFd::new(format!(
"/proc/self/fd/{}",
std::os::fd::AsRawFd::as_raw_fd(&workspace)
))?,
AccessFs::from_all(abi),
))?
.add_rule(PathBeneath::new(
PathFd::new("/tmp")?,
AccessFs::from_all(abi),
))?;
for root in &request.writable_roots {
ruleset = ruleset.add_rule(PathBeneath::new(
PathFd::new(root)?,
AccessFs::from_all(abi),
))?;
}
}
let status = ruleset.restrict_self()?;
if status.ruleset != RulesetStatus::FullyEnforced || !status.no_new_privs {
anyhow::bail!(
"native containment unavailable: Landlock ABI v3 is not fully enforced by this Linux \
kernel; refusing to run uncontained. Select `danger-full-access` only inside an \
already isolated environment"
);
}
let socket_rules = [libc::AF_INET, libc::AF_INET6, libc::AF_PACKET]
.into_iter()
.map(|family| {
SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
family as u64,
)?])
})
.collect::<std::result::Result<Vec<_>, _>>()?;
let filter: BpfProgram = SeccompFilter::new(
[(libc::SYS_socket, socket_rules)].into_iter().collect(),
SeccompAction::Allow,
SeccompAction::Errno(libc::EACCES as u32),
std::env::consts::ARCH.try_into()?,
)?
.try_into()?;
seccompiler::apply_filter(&filter)?;
Err(std::process::Command::new("/bin/bash")
.arg("-lc")
.arg(&request.script)
.exec()
.into())
}
#[cfg(target_os = "linux")]
fn open_directory_no_symlinks(
path: &std::path::Path,
relative_to: Option<&std::fs::File>,
) -> Result<std::fs::File> {
use std::ffi::CString;
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::ffi::OsStrExt;
let mut current = relative_to.map(std::fs::File::try_clone).transpose()?;
for component in path.components() {
use std::path::Component;
let segment = match component {
Component::RootDir if current.is_none() => {
let root = CString::new("/").expect("static path");
let fd = unsafe {
libc::open(
root.as_ptr(),
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("open filesystem root");
}
current = Some(unsafe { std::fs::File::from_raw_fd(fd) });
continue;
}
Component::CurDir => continue,
Component::Normal(segment) => segment,
_ => anyhow::bail!("working directory contains an invalid path component"),
};
let segment = CString::new(segment.as_bytes()).context("path contains NUL")?;
let parent = current.as_ref().map_or(libc::AT_FDCWD, AsRawFd::as_raw_fd);
let fd = unsafe {
libc::openat(
parent,
segment.as_ptr(),
libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error()).with_context(|| {
format!(
"open directory without following symlinks: {}",
path.display()
)
});
}
current = Some(unsafe { std::fs::File::from_raw_fd(fd) });
}
current.context("directory path is empty")
}
#[cfg(not(target_os = "linux"))]
pub fn run(_request: &WorkerRequest) -> Result<std::convert::Infallible> {
anyhow::bail!("the sandbox worker runs only on Linux")
}
#[cfg(test)]
mod tests {
use super::*;
fn arguments() -> Vec<String> {
[
"--workspace",
"/work",
"--cwd",
"/work",
"--temp",
"/tmp/everruns-sandbox-1",
"--mode",
"workspace-write",
"--writable",
"/cache",
"--script",
"cargo test",
]
.iter()
.map(|value| value.to_string())
.collect()
}
#[test]
fn a_launcher_argument_list_round_trips_into_a_request() {
let request = WorkerRequest::parse(arguments()).expect("parses");
assert_eq!(request.cwd, PathBuf::from("/work"));
assert_eq!(request.workspace, PathBuf::from("/work"));
assert_eq!(request.mode, ContainmentMode::WorkspaceWrite);
assert_eq!(request.writable_roots, vec![PathBuf::from("/cache")]);
assert_eq!(request.script, "cargo test");
}
#[test]
fn a_script_that_looks_like_a_flag_stays_a_script() {
let mut arguments = arguments();
let last = arguments.len() - 1;
arguments[last] = "--mode read-only".to_string();
let request = WorkerRequest::parse(arguments).expect("parses");
assert_eq!(request.script, "--mode read-only");
assert_eq!(request.mode, ContainmentMode::WorkspaceWrite);
}
#[test]
fn a_missing_field_is_an_error_rather_than_a_default() {
for dropped in ["--workspace", "--cwd", "--temp", "--mode", "--script"] {
let mut kept = Vec::new();
let mut arguments = arguments().into_iter();
while let Some(flag) = arguments.next() {
let value = arguments.next().expect("paired");
if flag != dropped {
kept.push(flag);
kept.push(value);
}
}
assert!(
WorkerRequest::parse(kept).is_err(),
"{dropped} must be required"
);
}
}
#[test]
fn the_worker_refuses_to_run_an_uncontained_command() {
let mut arguments = arguments();
let mode = arguments
.iter()
.position(|value| value == "--mode")
.unwrap()
+ 1;
arguments[mode] = "danger-full-access".to_string();
let error = WorkerRequest::parse(arguments).expect_err("refused");
assert!(error.to_string().contains("danger-full-access"));
}
#[test]
fn an_unknown_flag_fails_instead_of_being_ignored() {
let mut arguments = arguments();
arguments.push("--allow-everything".to_string());
assert!(WorkerRequest::parse(arguments).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn working_directory_resolution_rejects_a_symlink_escape() {
use std::os::unix::fs::symlink;
let workspace = tempfile::tempdir().expect("workspace");
let outside = tempfile::tempdir().expect("outside");
symlink(outside.path(), workspace.path().join("escape")).expect("create escape symlink");
let root = open_directory_no_symlinks(workspace.path(), None).expect("open workspace");
let error = open_directory_no_symlinks(std::path::Path::new("escape"), Some(&root))
.expect_err("symlink is not followed");
assert!(error.to_string().contains("without following symlinks"));
}
}