#[cfg(target_os = "linux")]
mod sandbox_linux;
#[cfg(target_os = "macos")]
mod sandbox_macos;
#[cfg(target_os = "netbsd")]
mod sandbox_netbsd;
#[cfg(any(target_os = "illumos", target_os = "solaris"))]
mod sandbox_sunos;
use crate::action::{Action, ActionContext, ActionType, FSType};
use crate::config::{Config, PkgsrcEnv};
use crate::try_println;
use crate::{Interrupted, RunState};
use anyhow::{Context, Result, bail};
use rayon::prelude::*;
use std::fs;
use std::io::Write;
use std::os::unix::process::CommandExt;
use std::os::unix::process::ExitStatusExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Output, Stdio};
use std::sync::mpsc::RecvTimeoutError;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use tracing::{debug, info, info_span, warn};
pub trait CommandSetsid {
fn new_session(&mut self) -> &mut Self;
}
impl CommandSetsid for Command {
fn new_session(&mut self) -> &mut Self {
unsafe {
self.pre_exec(|| {
libc::setsid();
Ok(())
});
}
self
}
}
#[cfg(target_os = "illumos")]
unsafe extern "C" {
fn wait4(
pid: libc::pid_t,
status: *mut libc::c_int,
options: libc::c_int,
rusage: *mut libc::rusage,
) -> libc::pid_t;
}
pub(crate) const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
pub(crate) const KILL_PROCESSES_MAX_RETRIES: u32 = 5;
pub(crate) const KILL_PROCESSES_INITIAL_DELAY_MS: u64 = 64;
pub fn wait_with_shutdown(child: &mut Child, state: &RunState) -> Result<(ExitStatus, Duration)> {
let pid = child.id() as libc::pid_t;
loop {
if state.is_shutdown() {
let _ = child.kill();
let _ = child.wait();
bail!("Interrupted by shutdown");
}
let mut status: libc::c_int = 0;
let mut rusage: libc::rusage = unsafe { std::mem::zeroed() };
#[cfg(target_os = "illumos")]
let ret = unsafe { wait4(pid, &mut status, libc::WNOHANG, &mut rusage) };
#[cfg(not(target_os = "illumos"))]
let ret = unsafe { libc::wait4(pid, &mut status, libc::WNOHANG, &mut rusage) };
if ret < 0 {
let err = std::io::Error::last_os_error();
bail!("wait4 failed for pid {}: {}", pid, err);
}
if ret == 0 {
std::thread::sleep(SHUTDOWN_POLL_INTERVAL);
continue;
}
let utime = Duration::new(
rusage.ru_utime.tv_sec as u64,
rusage.ru_utime.tv_usec as u32 * 1000,
);
let stime = Duration::new(
rusage.ru_stime.tv_sec as u64,
rusage.ru_stime.tv_usec as u32 * 1000,
);
return Ok((ExitStatus::from_raw(status), utime + stime));
}
}
pub fn wait_output_with_shutdown(child: Child, state: &RunState) -> Result<Output> {
let pid = child.id();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(child.wait_with_output());
});
loop {
if state.is_shutdown() {
unsafe {
libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
}
let _ = rx.recv();
bail!("Interrupted by shutdown");
}
match rx.recv_timeout(SHUTDOWN_POLL_INTERVAL) {
Ok(result) => return result.map_err(Into::into),
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => {
bail!("wait thread disconnected unexpectedly");
}
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Sandbox {
config: Config,
context: ActionContext,
pkgsrc_env: Arc<OnceLock<PkgsrcEnv>>,
}
impl Sandbox {
pub fn new(config: &Config) -> Sandbox {
Self::with_context(config, ActionContext::Build)
}
pub fn new_dev(config: &Config) -> Sandbox {
Self::with_context(config, ActionContext::Dev)
}
fn with_context(config: &Config, context: ActionContext) -> Sandbox {
Sandbox {
config: config.clone(),
context,
pkgsrc_env: Arc::new(OnceLock::new()),
}
}
pub fn set_pkgsrc_env(&self, env: PkgsrcEnv) {
let _ = self.pkgsrc_env.set(env);
}
pub fn pkgsrc_env(&self) -> Option<&PkgsrcEnv> {
self.pkgsrc_env.get()
}
pub fn script_env(&self) -> Vec<(String, String)> {
let mut envs = vec![
(
"bob_logdir".to_string(),
format!("{}", self.config.logdir().display()),
),
(
"bob_make".to_string(),
format!("{}", self.config.make().display()),
),
(
"bob_pkgsrc".to_string(),
format!("{}", self.config.pkgsrc().display()),
),
];
if let Some(env) = self.pkgsrc_env.get() {
envs.push((
"bob_packages".to_string(),
env.packages.display().to_string(),
));
envs.push((
"bob_pkgtools".to_string(),
env.pkgtools.display().to_string(),
));
envs.push(("bob_prefix".to_string(), env.prefix.display().to_string()));
envs.push((
"bob_pkg_dbdir".to_string(),
env.pkg_dbdir.display().to_string(),
));
envs.push((
"bob_pkg_refcount_dbdir".to_string(),
env.pkg_refcount_dbdir.display().to_string(),
));
if let Some(varbase) = env.metadata.get("VARBASE") {
envs.push(("bob_varbase".to_string(), varbase.clone()));
}
for (key, value) in &env.cachevars {
envs.push((key.clone(), value.clone()));
}
}
if let Some(build_user) = self.config.build_user() {
envs.push(("bob_build_user".to_string(), build_user.to_string()));
}
if let Some(home) = self.config.build_user_home() {
envs.push((
"bob_build_user_home".to_string(),
home.display().to_string(),
));
}
if let Some(bootstrap) = self.config.bootstrap() {
envs.push((
"bob_bootstrap".to_string(),
format!("{}", bootstrap.display()),
));
}
envs
}
pub fn enabled(&self) -> bool {
self.config.sandboxes().is_some()
}
fn basedir(&self) -> Option<&PathBuf> {
self.config.sandboxes().as_ref().map(|s| &s.basedir)
}
pub fn path(&self, id: usize) -> PathBuf {
let sandbox = &self.config.sandboxes().as_ref().unwrap();
let mut p = PathBuf::from(&sandbox.basedir);
p.push(id.to_string());
p
}
pub fn command(&self, id: Option<usize>, cmd: &Path) -> Command {
let mut c = match id {
Some(id) => {
let mut c = Command::new("/usr/sbin/chroot");
c.arg(self.path(id)).arg(cmd);
c
}
None => Command::new(cmd),
};
match self.context {
ActionContext::Build => self.apply_build_environment(&mut c),
ActionContext::Dev => self.apply_dev_environment(&mut c),
}
c
}
pub fn apply_build_environment(&self, cmd: &mut Command) {
let Some(ctx) = self.config.environment().and_then(|e| e.build.as_ref()) else {
return;
};
Self::apply_env_context(cmd, ctx);
for (name, value) in &ctx.vars {
cmd.env(name, value);
}
}
pub fn apply_dev_environment(&self, cmd: &mut Command) {
let Some(ctx) = self.config.environment().and_then(|e| e.dev.as_ref()) else {
return;
};
Self::apply_env_context(cmd, ctx);
}
fn apply_env_context(cmd: &mut Command, ctx: &crate::config::EnvContext) {
if ctx.clear {
cmd.env_clear();
for name in &ctx.inherit {
if let Ok(value) = std::env::var(name) {
cmd.env(name, value);
}
}
}
}
pub fn kill_processes_by_id(&self, id: Option<usize>) {
let Some(id) = id else { return };
let sandbox = self.path(id);
if sandbox.exists() {
let span = info_span!("kill_processes", sandbox_id = id);
let _guard = span.enter();
self.kill_processes_by_path(&sandbox);
}
}
fn kill_processes_by_path(&self, sandbox: &Path) {
for iteration in 0..KILL_PROCESSES_MAX_RETRIES {
let pids = self.find_pids(sandbox);
if pids.is_empty() {
debug!(retries = iteration, "No processes found in sandbox");
return;
}
info!(pids = %pids.join(" "), "Killed processes using sandbox");
let _ = Command::new("kill")
.arg("-9")
.args(&pids)
.stderr(Stdio::null())
.process_group(0)
.status();
let delay_ms = KILL_PROCESSES_INITIAL_DELAY_MS << iteration;
std::thread::sleep(Duration::from_millis(delay_ms));
}
if let Some(proc_info) = self.get_process_info(sandbox) {
warn!(
max_retries = KILL_PROCESSES_MAX_RETRIES,
remaining = %proc_info,
"Gave up killing processes after max retries"
);
} else {
warn!(
max_retries = KILL_PROCESSES_MAX_RETRIES,
"Gave up killing processes after max retries"
);
}
}
#[cfg(not(any(target_os = "macos", target_os = "netbsd")))]
fn find_pids(&self, sandbox: &Path) -> Vec<String> {
let output = Command::new("fuser")
.arg(sandbox)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.process_group(0)
.output();
let Ok(out) = output else { return vec![] };
if !out.status.success() {
return vec![];
}
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.map(|s| s.to_string())
.collect()
}
fn get_process_info(&self, sandbox: &Path) -> Option<String> {
let pids = self.find_pids(sandbox);
self.format_process_info(&pids)
}
#[cfg(not(any(target_os = "illumos", target_os = "solaris")))]
fn format_process_info(&self, pids: &[String]) -> Option<String> {
if pids.is_empty() {
return None;
}
let out = Command::new("ps")
.arg("-ww")
.arg("-o")
.arg("pid,args")
.arg("-p")
.arg(pids.join(","))
.process_group(0)
.output()
.ok()?;
let info: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.skip(1)
.filter_map(|line| {
let mut parts = line.split_whitespace();
let pid = parts.next()?;
let cmd: String = parts.collect::<Vec<_>>().join(" ");
Some(format!("pid={} cmd='{}'", pid, cmd))
})
.collect();
if info.is_empty() {
None
} else {
Some(info.join(", "))
}
}
fn mountpath(&self, id: usize, mnt: &PathBuf) -> PathBuf {
let mut p = self.path(id);
match mnt.strip_prefix("/") {
Ok(s) => p.push(s),
Err(_) => p.push(mnt),
};
p
}
fn verify_path_in_sandbox(&self, id: usize, path: &Path) -> Result<()> {
let sandbox_root = self.path(id);
let canonical_sandbox = sandbox_root.canonicalize().unwrap_or(sandbox_root.clone());
let canonical_path = if path.exists() {
path.canonicalize()?
} else {
if let Some(parent) = path.parent() {
if parent.exists() {
let canonical_parent = parent.canonicalize()?;
if !canonical_parent.starts_with(&canonical_sandbox) {
bail!(
"Path escapes sandbox: {} is not within {}",
path.display(),
sandbox_root.display()
);
}
}
}
return Ok(());
};
if !canonical_path.starts_with(&canonical_sandbox) {
bail!(
"Path escapes sandbox: {} resolves to {} which is not within {}",
path.display(),
canonical_path.display(),
canonical_sandbox.display()
);
}
Ok(())
}
fn bobmarker(&self, id: usize) -> PathBuf {
self.path(id).join(".bob")
}
fn completedpath(&self, id: usize) -> PathBuf {
self.bobmarker(id).join("completed")
}
fn mark_complete(&self, id: usize) -> Result<()> {
let path = self.completedpath(id);
fs::create_dir(&path).with_context(|| format!("Failed to create {}", path.display()))?;
Ok(())
}
fn is_bob_sandbox(&self, id: usize) -> bool {
self.bobmarker(id).exists()
}
fn is_sandbox_complete(&self, id: usize) -> bool {
self.completedpath(id).exists()
}
fn discover_sandboxes(&self) -> Result<Vec<usize>> {
let Some(basedir) = self.basedir() else {
return Ok(vec![]);
};
if !basedir.exists() {
return Ok(vec![]);
}
let mut ids = Vec::new();
let entries = fs::read_dir(basedir)
.with_context(|| format!("Failed to read {}", basedir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Ok(id) = name.parse::<usize>() else {
continue;
};
if self.is_bob_sandbox(id) {
ids.push(id);
}
}
ids.sort();
Ok(ids)
}
pub fn claim_id(&self) -> Result<usize> {
let mut candidate = 0;
loop {
if self.create(candidate)? {
return Ok(candidate);
}
candidate += 1;
}
}
pub fn claim_ids(&self, n: usize) -> Result<Vec<usize>> {
let results: Vec<Result<usize>> = (0..n).into_par_iter().map(|_| self.claim_id()).collect();
let mut claimed = Vec::with_capacity(n);
let mut first_error: Option<anyhow::Error> = None;
for result in results {
match result {
Ok(id) => claimed.push(id),
Err(e) => {
if first_error.is_none() {
first_error = Some(e);
}
}
}
}
if let Some(e) = first_error {
for &id in &claimed {
let _ = self.destroy(id);
}
return Err(e);
}
claimed.sort();
Ok(claimed)
}
pub fn create(&self, id: usize) -> Result<bool> {
let sandbox = self.path(id);
fs::create_dir_all(&sandbox)
.with_context(|| format!("Failed to create {}", sandbox.display()))?;
let marker = self.bobmarker(id);
match fs::create_dir(&marker) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
Err(e) => {
return Err(e).with_context(|| format!("Failed to create {}", marker.display()));
}
}
let Some(sandbox) = &self.config.sandboxes() else {
bail!("Internal error: trying to create sandbox when sandboxes disabled.");
};
let envs = self.script_env();
self.perform_actions(id, &sandbox.setup, &envs)?;
self.mark_complete(id)?;
Ok(true)
}
pub fn execute_script(
&self,
id: Option<usize>,
content: &str,
envs: Vec<(String, String)>,
) -> Result<Child> {
let mut cmd = self.command(id, Path::new("/bin/sh"));
cmd.new_session();
cmd.current_dir("/").arg("-s");
for (key, val) in envs {
cmd.env(key, val);
}
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(content.as_bytes())?;
}
Ok(child)
}
pub fn execute_command<I, S>(
&self,
id: Option<usize>,
cmd: &Path,
args: I,
envs: Vec<(String, String)>,
) -> Result<Child>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let mut command = self.command(id, cmd);
command.args(args);
for (key, val) in envs {
command.env(key, val);
}
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.spawn()
.map_err(Into::into)
}
pub fn run_pre_build(&self, sandbox_id: Option<usize>) -> Result<()> {
if let Some(bootstrap) = self.config.bootstrap() {
let Some(sandbox_id) = sandbox_id else {
bail!("bootstrap requires sandboxes to be enabled");
};
let dest = self.path(sandbox_id);
info!(bootstrap = %bootstrap.display(), dest = %dest.display(), "Unpacking bootstrap kit");
let file = fs::File::open(bootstrap)
.with_context(|| format!("Failed to open bootstrap {}", bootstrap.display()))?;
let gz = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(gz);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(true);
archive.unpack(&dest).with_context(|| {
format!(
"Failed to unpack bootstrap {} to {}",
bootstrap.display(),
dest.display()
)
})?;
}
let hooks = self.config.hooks();
if !hooks.is_empty() {
let Some(sandbox_id) = sandbox_id else {
bail!("hooks require sandboxes to be enabled");
};
let envs = self.script_env();
self.perform_actions(sandbox_id, hooks, &envs)?;
}
Ok(())
}
pub fn run_post_build(&self, sandbox_id: Option<usize>) -> Result<()> {
let hook_result: Result<()> = if let Some(sandbox_id) = sandbox_id {
let hooks = self.config.hooks();
if hooks.is_empty() {
Ok(())
} else {
let envs = self.script_env();
self.reverse_actions(sandbox_id, hooks, &envs)
}
} else if !self.config.hooks().is_empty() {
bail!("hooks require sandboxes to be enabled");
} else {
Ok(())
};
if let Some(pkgsrc) = self.pkgsrc_env.get() {
for path in [
&pkgsrc.prefix,
&pkgsrc.pkg_dbdir,
&pkgsrc.pkg_refcount_dbdir,
] {
let target = match sandbox_id {
Some(id) => self.resolve_path(id, path),
None => path.clone(),
};
if target.exists() {
debug!(path = %target.display(), "Removing");
if let Err(e) = fs::remove_dir_all(&target) {
warn!(
path = %target.display(),
error = format!("{e:#}"),
"Failed to remove directory"
);
}
}
}
}
hook_result
}
fn resolve_path(&self, sandbox_id: usize, path: &Path) -> PathBuf {
self.mountpath(sandbox_id, &path.to_path_buf())
}
fn run_action_cmd(
&self,
sandbox_id: usize,
cmd: &str,
chroot: bool,
envs: &[(String, String)],
) -> Result<Option<Output>> {
let sandbox_path = self.path(sandbox_id);
let output = if chroot {
let mut c = Command::new("/usr/sbin/chroot");
for (key, val) in envs {
c.env(key, val);
}
c.arg(&sandbox_path)
.arg("/bin/sh")
.arg("-ceu")
.arg(cmd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0);
c.output()?
} else {
let mut c = Command::new("/bin/sh");
for (key, val) in envs {
c.env(key, val);
}
c.arg("-ceu")
.arg(cmd)
.env("bob_sandbox_path", &sandbox_path)
.current_dir(&sandbox_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0);
c.output()?
};
Ok(Some(output))
}
pub fn destroy(&self, id: usize) -> anyhow::Result<()> {
let sandbox = self.path(id);
if !sandbox.exists() {
return Ok(());
}
let completeddir = self.completedpath(id);
if completeddir.exists() {
self.remove_empty_hierarchy(&completeddir)?;
}
let Some(sandbox_config) = &self.config.sandboxes() else {
bail!("Internal error: trying to destroy sandbox when sandboxes disabled.");
};
let envs = self.script_env();
self.kill_processes_by_path(&self.path(id));
self.reverse_actions(id, &sandbox_config.setup, &envs)?;
if sandbox.exists() {
let bobmarker = self.bobmarker(id);
let entries = fs::read_dir(&sandbox)
.with_context(|| format!("Failed to read {}", sandbox.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path == bobmarker {
continue;
}
self.remove_empty_hierarchy(&path)?;
}
if bobmarker.exists() {
self.remove_empty_hierarchy(&bobmarker)?;
}
self.remove_empty_hierarchy(&sandbox)?;
}
Ok(())
}
pub fn create_all(&self, count: usize) -> Result<()> {
let msg = if count == 1 {
"Creating sandbox".to_string()
} else {
format!("Creating {} sandboxes", count)
};
crate::print_status(&msg);
let start = Instant::now();
self.claim_ids(count)?;
crate::print_elapsed(&msg, start.elapsed());
Ok(())
}
pub fn destroy_all(&self) -> Result<()> {
let sandboxes = self.discover_sandboxes()?;
if sandboxes.is_empty() {
return Ok(());
}
for &id in &sandboxes {
if self.path(id).exists() {
if let Err(e) = self.run_post_build(Some(id)) {
warn!(error = format!("{e:#}"), sandbox = id, "post-build error");
}
}
}
let msg = if sandboxes.len() == 1 {
"Destroying sandbox".to_string()
} else {
format!("Destroying {} sandboxes", sandboxes.len())
};
crate::print_status(&msg);
let start = Instant::now();
let results: Vec<(usize, Result<()>)> = sandboxes
.into_par_iter()
.map(|i| (i, self.destroy(i)))
.collect();
let mut failed = 0;
for (i, result) in results {
if let Err(e) = result {
if failed == 0 {
println!();
}
eprintln!("sandbox {}: {:#}", i, e);
failed += 1;
}
}
if failed == 0 {
crate::print_elapsed(&msg, start.elapsed());
Ok(())
} else {
Err(anyhow::anyhow!(
"Failed to destroy {} sandbox{}.\n\
Remove unexpected files, then run 'bob sandbox destroy'.",
failed,
if failed == 1 { "" } else { "es" }
))
}
}
pub fn list_all(&self) -> Result<()> {
for id in self.discover_sandboxes()? {
let sandbox = self.path(id);
let line = if self.is_sandbox_complete(id) {
format!("{}", sandbox.display())
} else {
format!("{} (incomplete)", sandbox.display())
};
if !try_println(&line) {
break;
}
}
Ok(())
}
pub fn count_existing(&self) -> Result<usize> {
Ok(self.discover_sandboxes()?.len())
}
fn remove_empty_dirs(&self, id: usize, mountpoint: &Path) {
for p in mountpoint.ancestors() {
if !p.starts_with(self.path(id)) {
break;
}
if !p.exists() {
continue;
}
if fs::remove_dir(p).is_err() {
break;
}
}
}
#[allow(clippy::only_used_in_recursion)]
fn remove_empty_hierarchy(&self, path: &Path) -> Result<()> {
let meta = fs::symlink_metadata(path)?;
if meta.is_symlink() {
fs::remove_file(path).map_err(|e| {
anyhow::anyhow!("Failed to remove symlink {}: {}", path.display(), e)
})?;
return Ok(());
}
if !meta.is_dir() {
bail!(
"Cannot remove sandbox: non-directory exists at {}",
path.display()
);
}
let entries =
fs::read_dir(path).with_context(|| format!("Failed to read {}", path.display()))?;
for entry in entries {
let entry = entry?;
self.remove_empty_hierarchy(&entry.path())?;
}
fs::remove_dir(path).map_err(|e| {
anyhow::anyhow!(
"Failed to remove directory {}: {}. Directory may not be empty.",
path.display(),
e
)
})
}
fn perform_actions(
&self,
sandbox_id: usize,
actions: &[Action],
envs: &[(String, String)],
) -> Result<()> {
for action in actions {
if !action.only().matches(self.context) {
debug!(
sandbox = sandbox_id,
action = action
.action_type()
.map(|t| format!("{:?}", t))
.unwrap_or_default(),
"Skipped (only predicate not satisfied)"
);
continue;
}
action.validate()?;
let action_type = action.action_type()?;
let src = action.src().or(action.dest());
let dest = action
.dest()
.or(action.src())
.map(|d| self.resolve_path(sandbox_id, d));
if let Some(dest_path) = &dest {
self.verify_path_in_sandbox(sandbox_id, dest_path)?;
}
let mut opts = vec![];
if let Some(o) = action.opts() {
for opt in o.split(' ').collect::<Vec<&str>>() {
opts.push(opt);
}
}
let status = match action_type {
ActionType::Mount => {
let fs_type = action.fs_type()?;
let src =
src.ok_or_else(|| anyhow::anyhow!("mount action requires src or dest"))?;
let dest = dest.ok_or_else(|| anyhow::anyhow!("mount action requires dest"))?;
debug!(
sandbox = sandbox_id,
action = "mount",
fs = ?fs_type,
src = %src.display(),
dest = %dest.display(),
"Mounting"
);
if !dest.exists() {
fs::create_dir_all(&dest)
.with_context(|| format!("Failed to create {}", dest.display()))?;
}
match fs_type {
FSType::Bind => self.mount_bindfs(src, &dest, &opts)?,
FSType::Dev => self.mount_devfs(src, &dest, &opts)?,
FSType::Fd => self.mount_fdfs(src, &dest, &opts)?,
FSType::Nfs => self.mount_nfs(src, &dest, &opts)?,
FSType::Proc => self.mount_procfs(src, &dest, &opts)?,
FSType::Tmp => self.mount_tmpfs(src, &dest, &opts)?,
}
}
ActionType::Copy => {
let src =
src.ok_or_else(|| anyhow::anyhow!("copy action requires src or dest"))?;
let dest = dest.ok_or_else(|| anyhow::anyhow!("copy action requires dest"))?;
debug!(
sandbox = sandbox_id,
action = "copy",
src = %src.display(),
dest = %dest.display(),
"Copying"
);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
copy_dir::copy_dir(src, &dest).with_context(|| {
format!("Failed to copy {} to {}", src.display(), dest.display())
})?;
fs::set_permissions(&dest, fs::metadata(src)?.permissions())?;
None
}
ActionType::Cmd => {
if let Some(create_cmd) = action.create_cmd() {
debug!(
sandbox = sandbox_id,
action = "cmd",
cmd = %create_cmd.run,
chroot = action.chroot(),
"Running create command"
);
let mut merged_envs: Vec<(String, String)> = envs.to_vec();
merged_envs.extend(create_cmd.env.iter().cloned());
if let Some(out) = self.run_action_cmd(
sandbox_id,
&create_cmd.run,
action.chroot(),
&merged_envs,
)? {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stderr = stderr.trim();
if stderr.is_empty() {
bail!(
"create command failed (exit code {}): {}",
out.status
.code()
.map_or("signal".to_string(), |c| c.to_string()),
create_cmd.run,
);
} else {
bail!(
"create command failed (exit code {}): {}\n{}",
out.status
.code()
.map_or("signal".to_string(), |c| c.to_string()),
create_cmd.run,
stderr,
);
}
}
}
}
None
}
ActionType::Symlink => {
let src = action
.src()
.ok_or_else(|| anyhow::anyhow!("symlink action requires src"))?;
let dest_path = self.resolve_path(
sandbox_id,
action
.dest()
.ok_or_else(|| anyhow::anyhow!("symlink action requires dest"))?,
);
debug!(
sandbox = sandbox_id,
action = "symlink",
src = %src.display(),
dest = %dest_path.display(),
"Creating symlink"
);
if let Some(parent) = dest_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create {}", parent.display())
})?;
}
}
std::os::unix::fs::symlink(src, &dest_path).with_context(|| {
format!(
"Failed to create symlink {} -> {}",
dest_path.display(),
src.display()
)
})?;
None
}
ActionType::MacosMdnsListener => {
#[cfg(not(target_os = "macos"))]
bail!("'macos-mdns-listener' action is only supported on macOS");
#[cfg(target_os = "macos")]
{
debug!(
sandbox = sandbox_id,
action = "macos-mdns-listener",
"Creating mDNS listener"
);
self.create_mdns_listener(sandbox_id)?;
None
}
}
};
if let Some(s) = status {
if !s.success() {
bail!(
"Action failed (exit code {:?})",
s.code().map_or("signal".to_string(), |c| c.to_string()),
);
}
}
}
Ok(())
}
fn reverse_actions(
&self,
sandbox_id: usize,
actions: &[Action],
envs: &[(String, String)],
) -> Result<()> {
for action in actions.iter().rev() {
if !action.only().matches(self.context) {
continue;
}
let action_type = action.action_type()?;
let dest = action
.dest()
.or(action.src())
.map(|d| self.resolve_path(sandbox_id, d));
match action_type {
ActionType::Cmd => {
if let Some(destroy_cmd) = action.destroy_cmd() {
debug!(
action = "cmd",
cmd = %destroy_cmd.run,
chroot = action.chroot(),
"Running destroy command"
);
let mut merged_envs: Vec<(String, String)> = envs.to_vec();
merged_envs.extend(destroy_cmd.env.iter().cloned());
if let Some(out) = self.run_action_cmd(
sandbox_id,
&destroy_cmd.run,
action.chroot(),
&merged_envs,
)? {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stderr = stderr.trim();
if stderr.is_empty() {
bail!(
"destroy command failed (exit code {}): {}",
out.status
.code()
.map_or("signal".to_string(), |c| c.to_string()),
destroy_cmd.run,
);
} else {
bail!(
"destroy command failed (exit code {}): {}\n{}",
out.status
.code()
.map_or("signal".to_string(), |c| c.to_string()),
destroy_cmd.run,
stderr,
);
}
}
}
}
}
ActionType::Copy => {
let Some(dest) = dest else { continue };
if !dest.exists() {
self.remove_empty_dirs(sandbox_id, &dest);
continue;
}
if fs::remove_dir(&dest).is_ok() {
continue;
}
debug!(
sandbox = sandbox_id,
action = "copy",
dest = %dest.display(),
"Removing copied path"
);
self.verify_path_in_sandbox(sandbox_id, &dest)?;
self.remove_dir_recursive(&dest)?;
self.remove_empty_dirs(sandbox_id, &dest);
}
ActionType::Symlink => {
let Some(dest) = dest else { continue };
if dest.is_symlink() {
debug!(
sandbox = sandbox_id,
action = "symlink",
dest = %dest.display(),
"Removing symlink"
);
fs::remove_file(&dest)?;
}
self.remove_empty_dirs(sandbox_id, &dest);
}
ActionType::Mount => {
let Some(dest) = dest else { continue };
let fs_type = action.fs_type()?;
if !dest.exists() {
self.remove_empty_dirs(sandbox_id, &dest);
continue;
}
if fs::remove_dir(&dest).is_ok() {
continue;
}
debug!(
sandbox = sandbox_id,
action = "mount",
fs = ?fs_type,
dest = %dest.display(),
"Unmounting"
);
let status = match fs_type {
FSType::Bind => self.unmount_bindfs(&dest)?,
FSType::Dev => self.unmount_devfs(&dest)?,
FSType::Fd => self.unmount_fdfs(&dest)?,
FSType::Nfs => self.unmount_nfs(&dest)?,
FSType::Proc => self.unmount_procfs(&dest)?,
FSType::Tmp => self.unmount_tmpfs(&dest)?,
};
if let Some(s) = status {
if !s.success() {
bail!("Failed to unmount {}", dest.display());
}
}
self.remove_empty_dirs(sandbox_id, &dest);
}
ActionType::MacosMdnsListener => {
#[cfg(not(target_os = "macos"))]
bail!("'macos-mdns-listener' action is only supported on macOS");
#[cfg(target_os = "macos")]
{
debug!(
sandbox = sandbox_id,
action = "macos-mdns-listener",
"Destroying mDNS listener"
);
self.destroy_mdns_listener(sandbox_id)?;
}
}
}
}
Ok(())
}
#[allow(clippy::only_used_in_recursion)]
fn remove_dir_recursive(&self, path: &Path) -> Result<()> {
let meta = fs::symlink_metadata(path)
.with_context(|| format!("Failed to stat {}", path.display()))?;
if meta.is_symlink() {
fs::remove_file(path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
return Ok(());
}
if !meta.is_dir() {
fs::remove_file(path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
return Ok(());
}
let entries =
fs::read_dir(path).with_context(|| format!("Failed to read {}", path.display()))?;
for entry in entries {
let entry = entry?;
let entry_path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_symlink() {
fs::remove_file(&entry_path)
.with_context(|| format!("Failed to remove {}", entry_path.display()))?;
} else if file_type.is_dir() {
self.remove_dir_recursive(&entry_path)?;
} else {
fs::remove_file(&entry_path)
.with_context(|| format!("Failed to remove {}", entry_path.display()))?;
}
}
fs::remove_dir(path).with_context(|| format!("Failed to remove {}", path.display()))?;
Ok(())
}
}
#[derive(Debug)]
pub struct SandboxScope {
sandbox: Sandbox,
owned: Vec<usize>,
state: RunState,
}
impl SandboxScope {
pub fn new(sandbox: Sandbox, state: RunState) -> Self {
Self {
sandbox,
owned: Vec::new(),
state,
}
}
pub fn ensure(&mut self, n: usize) -> Result<&[usize]> {
if n <= self.owned.len() {
return Ok(&self.owned);
}
let needed = n - self.owned.len();
let new_ids = self.sandbox.claim_ids(needed)?;
if self.state.interrupted() {
for &id in &new_ids {
let _ = self.sandbox.destroy(id);
}
return Err(Interrupted.into());
}
self.owned.extend(new_ids);
Ok(&self.owned)
}
pub fn ids(&self) -> Option<&[usize]> {
if self.owned.is_empty() {
None
} else {
Some(&self.owned)
}
}
pub fn sandbox(&self) -> &Sandbox {
&self.sandbox
}
pub fn enabled(&self) -> bool {
self.sandbox.enabled()
}
pub fn count(&self) -> usize {
self.owned.len()
}
pub fn state(&self) -> &RunState {
&self.state
}
}
impl Drop for SandboxScope {
fn drop(&mut self) {
if !self.sandbox.enabled() || self.owned.is_empty() {
return;
}
for &id in &self.owned {
if self.sandbox.path(id).exists() {
if let Err(e) = self.sandbox.run_post_build(Some(id)) {
warn!(error = format!("{e:#}"), sandbox = id, "post-build error");
}
}
}
let msg = if self.owned.len() == 1 {
"Destroying sandbox".to_string()
} else {
format!("Destroying {} sandboxes", self.owned.len())
};
crate::print_status(&msg);
let start = Instant::now();
let ids = self.owned.clone();
let results: Vec<(usize, Result<()>)> = ids
.into_par_iter()
.map(|id| (id, self.sandbox.destroy(id)))
.collect();
let mut failed = 0;
for (id, result) in results {
if let Err(e) = result {
if failed == 0 {
println!();
}
eprintln!("sandbox {}: {:#}", id, e);
failed += 1;
}
}
if failed == 0 {
crate::print_elapsed(&msg, start.elapsed());
} else {
eprintln!(
"Warning: failed to destroy {} sandbox{}",
failed,
if failed == 1 { "" } else { "es" }
);
}
}
}