#![doc = document_features::document_features!()]
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#[cfg(all(feature = "async", not(async_supported)))]
compile_error!(r#"The "async" feature requires Rust compiler version 1.85 or later."#);
use std::fs::{self, File};
use std::io::{BufWriter, Write as _};
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use thiserror::Error;
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum Error {
#[error("I/O error")]
Io(#[from] std::io::Error),
#[error("requested path is outside of the sandbox")]
Uncontained(PathBuf),
}
#[derive(Debug)]
pub struct LitterTray {
canonical_dir: PathBuf,
_dir: TempDir,
saved_cwd: PathBuf,
}
#[cfg(not(feature = "async"))]
mod sync {
use std::sync::LazyLock;
use std::sync::Mutex;
#[allow(clippy::module_name_repetitions)]
pub fn global_lock_sync() -> std::sync::MutexGuard<'static, ()> {
G_LOCK.lock().unwrap()
}
static G_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
}
#[cfg(not(feature = "async"))]
pub use sync::global_lock_sync;
#[cfg(feature = "async")]
mod r#async {
use std::sync::LazyLock;
use tokio::sync::Mutex;
pub fn global_lock_sync() -> tokio::sync::MutexGuard<'static, ()> {
G_LOCK.blocking_lock()
}
pub async fn global_lock_async() -> tokio::sync::MutexGuard<'static, ()> {
G_LOCK.lock().await
}
static G_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
}
#[cfg(feature = "async")]
pub use r#async::{global_lock_async, global_lock_sync};
impl LitterTray {
#[track_caller]
pub fn try_with<F, R>(f: F) -> anyhow::Result<R>
where
F: FnOnce(&mut LitterTray) -> anyhow::Result<R>,
{
let dir = TempDir::new()?;
let guard = global_lock_sync();
let mut tray = LitterTray {
canonical_dir: dunce::canonicalize(dir.path())?,
_dir: dir,
saved_cwd: std::env::current_dir()?,
};
std::env::set_current_dir(tray.directory())?;
let outcome = f(&mut tray);
drop(tray); drop(guard);
outcome
}
pub fn run<F: FnOnce(&mut LitterTray)>(f: F) {
let _ = Self::try_with(|tray| {
f(tray);
Ok(())
});
}
#[cfg(all(feature = "async", async_supported))]
pub async fn try_with_async<F, R>(f: F) -> anyhow::Result<R>
where
F: AsyncFnOnce(&mut LitterTray) -> anyhow::Result<R>,
{
let dir = TempDir::new()?;
let guard = global_lock_async().await;
let mut tray = LitterTray {
canonical_dir: dunce::canonicalize(dir.path())?,
_dir: dir,
saved_cwd: std::env::current_dir()?,
};
std::env::set_current_dir(tray.directory())?;
let outcome = f(&mut tray).await;
drop(tray); drop(guard);
outcome
}
#[must_use]
pub fn directory(&self) -> &Path {
&self.canonical_dir
}
fn safe_path_within_tray<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> {
let path = dedot(path);
if path.is_absolute() {
if path.starts_with(self.directory()) {
return Ok(path);
}
return Err(Error::Uncontained(path));
}
Ok(path)
}
pub fn create_binary<P: AsRef<Path>>(&self, path: P, bytes: &[u8]) -> Result<File> {
let path = self.safe_path_within_tray(path)?;
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
writer.write_all(bytes)?;
Ok(writer
.into_inner()
.map_err(std::io::IntoInnerError::into_error)?)
}
pub fn create_text<P: AsRef<Path>>(&self, path: P, contents: &str) -> Result<File> {
self.create_binary(path, contents.as_bytes())
}
pub fn make_dir<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> {
let path = self.safe_path_within_tray(path)?;
fs::create_dir_all(&path)?;
Ok(path)
}
#[cfg(unix)]
pub fn make_symlink<P: AsRef<Path>, Q: AsRef<Path>>(
&self,
original: P,
link: Q,
) -> Result<PathBuf> {
let path_orig = self.safe_path_within_tray(original)?;
let path_link = self.safe_path_within_tray(link)?;
std::os::unix::fs::symlink(path_orig, &path_link)?;
Ok(path_link)
}
}
impl Drop for LitterTray {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.saved_cwd);
}
}
fn dedot<P: AsRef<Path>>(path: P) -> PathBuf {
#[allow(clippy::enum_glob_use)]
use std::path::Component::{CurDir, Normal, ParentDir, Prefix, RootDir};
let mut comps = vec![];
for component in path.as_ref().components() {
match component {
p @ Prefix(_) => comps = vec![p],
r @ RootDir if comps.iter().all(|c| matches!(c, Prefix(_))) => comps.push(r),
r @ RootDir => comps = vec![r],
CurDir => {}
ParentDir if comps.iter().all(|c| matches!(c, Prefix(_) | RootDir)) => {}
ParentDir => {
let _ = comps.pop();
}
c @ Normal(_) => comps.push(c),
}
}
comps.iter().map(|c| c.as_os_str()).collect()
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod test {
use super::{dedot, LitterTray};
use std::{
fs,
path::{Path, PathBuf},
};
#[test]
fn drop_removes_tempdir() {
let mut path = PathBuf::new();
LitterTray::try_with(|tray| {
let _ = tray.create_text("test.txt", "Hello, world!").unwrap();
path = tray.directory().to_path_buf();
assert!(fs::exists(&path)?);
assert!(fs::exists("test.txt")?);
Ok(())
})
.unwrap();
assert!(!fs::exists(path).unwrap());
}
#[test]
fn return_value() {
assert_eq!(LitterTray::try_with(|_tray| { Ok(42) }).unwrap(), 42);
}
fn getcwd() -> Option<PathBuf> {
std::env::current_dir().ok()
}
#[test]
fn working_directory_restored() {
let prev_dir = getcwd();
let mut tray_dir = PathBuf::new();
LitterTray::run(|tray| {
tray_dir = tray.directory().to_path_buf();
assert_ne!(prev_dir.unwrap_or_default(), tray_dir);
});
assert_ne!(tray_dir, getcwd().unwrap_or_default());
assert!(!std::fs::exists(tray_dir).unwrap());
}
#[test]
fn absolute_path() {
LitterTray::try_with(|tray| {
let mut path = PathBuf::from(tray.directory());
path.push("file.txt");
let _ = tray.create_text(path, "hi").unwrap();
Ok(())
})
.unwrap();
}
fn outside_path() -> &'static str {
if cfg!(windows) {
"C:\\not-a-litter-tray"
} else {
"/not-a-litter-tray"
}
}
#[test]
fn absolute_path_outside_fails_our_error_returned() {
let inner_error = LitterTray::try_with(|tray| {
let mut path = PathBuf::new();
path.push(outside_path());
let res = tray.create_text(path, "hi").unwrap_err();
Ok(res)
})
.unwrap();
let crate::Error::Uncontained(_) = inner_error else {
panic!("Wrong inner error type; got {inner_error:?}");
};
}
#[test]
fn inner_error_coerced_to_anyhow() {
let e = LitterTray::try_with(|tray| {
let mut path = PathBuf::new();
path.push(outside_path());
let res = tray.create_text(path, "hi")?;
Ok(res)
})
.unwrap_err();
assert!(e
.to_string()
.contains("requested path is outside of the sandbox"));
}
#[cfg(unix)]
#[test]
fn symlinks_work() {
LitterTray::try_with(|tray| {
let _ = tray.make_symlink("file1", "file2")?;
assert!(!std::fs::exists("file2")?);
let _ = tray.create_text("file1", "hi there");
assert!(std::fs::exists("file2")?);
Ok(())
})
.unwrap();
}
#[test]
fn test_returning_anyhow_result() -> anyhow::Result<()> {
LitterTray::try_with(|_| Ok(()))
}
#[test]
fn dedot_test() {
assert_eq!(dedot(PathBuf::from("/./a/../b/c/.")), PathBuf::from("/b/c"));
assert_eq!(dedot(PathBuf::from(".")), PathBuf::from(""));
}
#[test]
#[ignore = "broken"]
fn panic_in_closure_propagates() {
let r = std::panic::catch_unwind(|| {
LitterTray::run(|_| {
panic!("at the disco");
});
});
assert!(r.is_err());
}
#[test]
fn we_do_not_like_unc_pathnames() {
LitterTray::run(|tray| {
assert!(path_is_not_unc(tray.directory()));
});
}
pub(crate) fn path_is_not_unc(path: &Path) -> bool {
!path_is_unc(path)
}
pub(crate) fn path_is_unc(path: &Path) -> bool {
let s = path.as_os_str().to_string_lossy();
let b = s.as_bytes();
b[0] == b'\\' && b[1] == b'\\'
}
}
cfg_if::cfg_if! { if #[cfg(async_supported)] {
#[cfg(all(test, feature = "async", async_supported))]
#[cfg_attr(coverage_nightly, coverage(off))]
mod test_async {
#[allow(unused_imports)]
use crate::LitterTray;
#[cfg(feature = "async")]
#[test]
fn async_closure() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
LitterTray::try_with_async(async |tray| {
let _ = tray.create_text("test.txt", "Hello, world!").unwrap();
assert_eq!(
tokio::fs::read_to_string("test.txt").await.unwrap(),
"Hello, world!"
);
Ok(())
})
.await
.unwrap();
});
}
#[tokio::test]
async fn async_test() {
LitterTray::try_with_async(async |tray| {
let _ = tray.create_text("test.txt", "Hello, world!").unwrap();
assert_eq!(
tokio::fs::read_to_string("test.txt").await.unwrap(),
"Hello, world!"
);
Ok(())
})
.await
.unwrap();
}
#[tokio::test]
async fn async_test_returning_anyhow_result() -> anyhow::Result<()> {
LitterTray::try_with_async(async |_| Ok(())).await
}
#[tokio::test]
#[should_panic = "at the disco"]
async fn panic_in_async_propagates() {
let _ = LitterTray::try_with_async(async |_| {
panic!("at the disco");
#[allow(unreachable_code)] Ok(())
})
.await;
}
#[tokio::test]
async fn we_do_not_like_unc_pathnames() {
let _ = LitterTray::try_with_async(async |tray| {
assert!(crate::test::path_is_not_unc(tray.directory()));
Ok(())
}).await;
}
} }}