use cfg_if::cfg_if;
use std::error::Error as StdError;
use std::ffi::{OsStr, OsString};
use std::fmt::{self, Display, Formatter};
use std::path::{Component, Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use std::time::Instant;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug)]
pub enum Error {
Inactive,
Io {
action: String,
path: Option<PathBuf>,
source: std::io::Error,
},
NotRelative {
path: PathBuf,
},
Escapes {
path: PathBuf,
},
Other(Box<dyn StdError + Send + Sync>),
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Error::Inactive => write!(
f,
"the sandbox environment is not active; call this inside `Environment::run`"
)?,
Error::Io {
action,
path,
source: _,
} => match path {
Some(path) => write!(f, "could not {action} `{}`", path.display())?,
None => write!(f, "could not {action}")?,
},
Error::NotRelative { path } => write!(
f,
"path `{}` must be relative to the sandbox",
path.display()
)?,
Error::Escapes { path } => {
write!(f, "path `{}` escapes the sandbox directory", path.display())?
}
Error::Other(source) => write!(f, "{source}")?,
}
if f.alternate() {
let mut cause = StdError::source(self);
while let Some(error) = cause {
write!(f, ": {error}")?;
cause = error.source();
}
}
Ok(())
}
}
impl StdError for Error {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Error::Io { source, .. } => Some(source),
Error::Other(source) => source.source(),
Error::Inactive | Error::NotRelative { .. } | Error::Escapes { .. } => None,
}
}
}
impl From<Box<dyn StdError + Send + Sync>> for Error {
fn from(source: Box<dyn StdError + Send + Sync>) -> Self {
Error::Other(source)
}
}
impl From<std::io::Error> for Error {
fn from(source: std::io::Error) -> Self {
Error::Other(Box::new(source))
}
}
pub struct Environment {
entered: Option<Entered>,
}
struct Entered {
directory: PathBuf,
saved_cwd: PathBuf,
saved_env: Vec<(OsString, OsString)>,
started: Instant,
_guard: MutexGuard<'static, ()>,
}
pub fn run<T>(f: impl FnOnce(&mut Environment) -> Result<T, Error>) -> Result<T, Error> {
Environment::temporary().run(f)
}
impl Environment {
pub fn temporary() -> Self {
Self { entered: None }
}
pub fn directory(&self) -> Option<&Path> {
match &self.entered {
Some(entered) => Some(&entered.directory),
None => None,
}
}
pub fn run<T>(
mut self,
f: impl FnOnce(&mut Environment) -> Result<T, Error>,
) -> Result<T, Error> {
let guard = match ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let started = Instant::now();
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::debug!(msg = "Entering sandbox environment");
} else if #[cfg(feature = "logging")] {
log::debug!("msg=\"Entering sandbox environment\"");
}
}
let saved_cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(source) => {
return Err(Error::Io {
action: String::from("read the current directory"),
path: None,
source,
});
}
};
let saved_env = std::env::vars_os().collect();
let nanos = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(elapsed) => elapsed.as_nanos(),
Err(_) => 0,
};
let name = format!("tanzim-testing-{}-{}", std::process::id(), nanos);
let target = std::env::temp_dir().join(&name);
let created = match std::fs::create_dir(&target) {
Ok(()) => target,
Err(source) => {
if source.kind() != std::io::ErrorKind::PermissionDenied {
return Err(Error::Io {
action: String::from("create the sandbox directory"),
path: Some(target),
source,
});
}
let fallback = saved_cwd.join(&name);
match std::fs::create_dir(&fallback) {
Ok(()) => fallback,
Err(source) => {
return Err(Error::Io {
action: String::from("create the sandbox directory"),
path: Some(fallback),
source,
});
}
}
}
};
let directory = match std::fs::canonicalize(&created) {
Ok(directory) => directory,
Err(source) => {
let _ = std::fs::remove_dir_all(&created);
return Err(Error::Io {
action: String::from("resolve the sandbox directory"),
path: Some(created),
source,
});
}
};
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::info!(msg = "Created sandbox directory", path = ?directory);
} else if #[cfg(feature = "logging")] {
log::info!("msg=\"Created sandbox directory\" path={directory:?}");
}
}
let cwd_target = directory.clone();
self.entered = Some(Entered {
directory,
saved_cwd,
saved_env,
started,
_guard: guard,
});
match std::env::set_current_dir(&cwd_target) {
Ok(()) => {}
Err(source) => {
return Err(Error::Io {
action: String::from("enter the sandbox directory"),
path: Some(cwd_target),
source,
});
}
}
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::trace!(msg = "Changed working directory into sandbox", path = ?cwd_target);
} else if #[cfg(feature = "logging")] {
log::trace!("msg=\"Changed working directory into sandbox\" path={cwd_target:?}");
}
}
f(&mut self)
}
pub fn clear_env(&mut self) {
if self.entered.is_none() {
return;
}
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::debug!(msg = "Clearing environment variables");
} else if #[cfg(feature = "logging")] {
log::debug!("msg=\"Clearing environment variables\"");
}
}
for (key, _) in std::env::vars_os() {
unsafe { std::env::remove_var(&key) };
}
}
pub fn set_env(
&mut self,
key: impl AsRef<OsStr>,
value: impl AsRef<OsStr>,
) -> Result<(), Error> {
if self.entered.is_none() {
return Err(Error::Inactive);
}
let key = key.as_ref();
let value = value.as_ref();
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::debug!(msg = "Setting environment variable", key = ?key);
} else if #[cfg(feature = "logging")] {
log::debug!("msg=\"Setting environment variable\" key={key:?}");
}
}
unsafe { std::env::set_var(key, value) };
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::trace!(msg = "Set environment variable value", key = ?key, value = ?value);
} else if #[cfg(feature = "logging")] {
log::trace!("msg=\"Set environment variable value\" key={key:?} value={value:?}");
}
}
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::info!(msg = "Set environment variable", key = ?key);
} else if #[cfg(feature = "logging")] {
log::info!("msg=\"Set environment variable\" key={key:?}");
}
}
Ok(())
}
pub fn create_file(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
let directory = match &self.entered {
Some(entered) => entered.directory.clone(),
None => return Err(Error::Inactive),
};
let full = resolve(&directory, path.as_ref())?;
let _existed = full.exists();
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::debug!(msg = "Creating file", path = ?full);
} else if #[cfg(feature = "logging")] {
log::debug!("msg=\"Creating file\" path={full:?}");
}
}
create_parents(&full)?;
match std::fs::File::create(&full) {
Ok(_) => {}
Err(source) => {
return Err(Error::Io {
action: String::from("create the file"),
path: Some(full),
source,
});
}
}
confirm_within(&directory, &full)?;
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::info!(msg = "Created file", path = ?full, recreated = _existed);
} else if #[cfg(feature = "logging")] {
log::info!("msg=\"Created file\" path={full:?} recreated={_existed}");
}
}
Ok(())
}
pub fn write_file(
&mut self,
path: impl AsRef<Path>,
contents: impl AsRef<[u8]>,
) -> Result<(), Error> {
let directory = match &self.entered {
Some(entered) => entered.directory.clone(),
None => return Err(Error::Inactive),
};
let full = resolve(&directory, path.as_ref())?;
let bytes = contents.as_ref();
let _existed = full.exists();
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::debug!(msg = "Writing file", path = ?full, bytes = bytes.len());
} else if #[cfg(feature = "logging")] {
log::debug!("msg=\"Writing file\" path={full:?} bytes={}", bytes.len());
}
}
create_parents(&full)?;
match std::fs::write(&full, bytes) {
Ok(()) => {}
Err(source) => {
return Err(Error::Io {
action: String::from("write the file"),
path: Some(full),
source,
});
}
}
confirm_within(&directory, &full)?;
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::trace!(
msg = "Wrote file contents",
path = ?full,
contents = %String::from_utf8_lossy(bytes),
);
} else if #[cfg(feature = "logging")] {
log::trace!(
"msg=\"Wrote file contents\" path={full:?} contents={}",
String::from_utf8_lossy(bytes),
);
}
}
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::info!(
msg = "Wrote file",
path = ?full,
bytes = bytes.len(),
recreated = _existed,
);
} else if #[cfg(feature = "logging")] {
log::info!(
"msg=\"Wrote file\" path={full:?} bytes={} recreated={_existed}",
bytes.len(),
);
}
}
Ok(())
}
pub fn create_directory(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
let directory = match &self.entered {
Some(entered) => entered.directory.clone(),
None => return Err(Error::Inactive),
};
let full = resolve(&directory, path.as_ref())?;
let _existed = full.exists();
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::debug!(msg = "Creating directory", path = ?full);
} else if #[cfg(feature = "logging")] {
log::debug!("msg=\"Creating directory\" path={full:?}");
}
}
match std::fs::create_dir_all(&full) {
Ok(()) => {}
Err(source) => {
return Err(Error::Io {
action: String::from("create the directory"),
path: Some(full),
source,
});
}
}
confirm_within(&directory, &full)?;
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::info!(msg = "Created directory", path = ?full, recreated = _existed);
} else if #[cfg(feature = "logging")] {
log::info!("msg=\"Created directory\" path={full:?} recreated={_existed}");
}
}
Ok(())
}
}
impl Drop for Environment {
fn drop(&mut self) {
let entered = match self.entered.take() {
Some(entered) => entered,
None => return,
};
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::trace!(msg = "Restoring environment and removing sandbox");
} else if #[cfg(feature = "logging")] {
log::trace!("msg=\"Restoring environment and removing sandbox\"");
}
}
for (key, _) in std::env::vars_os() {
unsafe { std::env::remove_var(&key) };
}
for (key, value) in &entered.saved_env {
unsafe { std::env::set_var(key, value) };
}
match std::env::set_current_dir(&entered.saved_cwd) {
Ok(()) => {}
Err(_source) => {
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::warn!(
msg = "Could not restore working directory",
path = ?entered.saved_cwd,
error = ?_source,
);
} else if #[cfg(feature = "logging")] {
log::warn!(
"msg=\"Could not restore working directory\" path={:?} error={_source:?}",
entered.saved_cwd,
);
}
}
}
}
match std::fs::remove_dir_all(&entered.directory) {
Ok(()) => {
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::info!(msg = "Removed sandbox directory", path = ?entered.directory);
} else if #[cfg(feature = "logging")] {
log::info!(
"msg=\"Removed sandbox directory\" path={:?}",
entered.directory,
);
}
}
}
Err(_source) => {
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::warn!(
msg = "Could not remove sandbox directory",
path = ?entered.directory,
error = ?_source,
);
} else if #[cfg(feature = "logging")] {
log::warn!(
"msg=\"Could not remove sandbox directory\" path={:?} error={_source:?}",
entered.directory,
);
}
}
}
}
let _held = entered.started.elapsed();
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::info!(msg = "Released sandbox lock", held_seconds = _held.as_secs_f64());
} else if #[cfg(feature = "logging")] {
log::info!("msg=\"Released sandbox lock\" held_seconds={}", _held.as_secs_f64());
}
}
}
}
fn resolve(directory: &Path, relative: &Path) -> Result<PathBuf, Error> {
if relative.is_absolute() {
return Err(Error::NotRelative {
path: relative.to_path_buf(),
});
}
for component in relative.components() {
if matches!(component, Component::ParentDir) {
return Err(Error::Escapes {
path: relative.to_path_buf(),
});
}
}
Ok(directory.join(relative))
}
fn create_parents(full: &Path) -> Result<(), Error> {
match full.parent() {
Some(parent) => match std::fs::create_dir_all(parent) {
Ok(()) => Ok(()),
Err(source) => Err(Error::Io {
action: String::from("create parent directories"),
path: Some(parent.to_path_buf()),
source,
}),
},
None => Ok(()),
}
}
fn confirm_within(directory: &Path, full: &Path) -> Result<(), Error> {
let canonical = match std::fs::canonicalize(full) {
Ok(canonical) => canonical,
Err(source) => {
return Err(Error::Io {
action: String::from("resolve the created path"),
path: Some(full.to_path_buf()),
source,
});
}
};
if canonical.starts_with(directory) {
Ok(())
} else {
Err(Error::Escapes {
path: full.to_path_buf(),
})
}
}