use std::ffi::OsStr;
use std::fs::Permissions;
use std::future::Future;
use std::io::ErrorKind;
use std::marker::PhantomData;
use std::ops::Deref;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::path::Path;
use std::path::PathBuf;
use std::thread;
use anyhow::Context as _;
use anyhow::Result;
use libc::S_IWUSR;
use tokio::fs::metadata;
use tokio::fs::read_dir;
use tokio::fs::set_permissions;
use tokio::runtime::Handle;
#[cfg(unix)]
fn read_only(mut permissions: Permissions) -> Permissions {
#[expect(trivial_numeric_casts, clippy::unnecessary_cast)]
let () = permissions.set_mode(permissions.mode() & !S_IWUSR as u32);
permissions
}
#[cfg(not(unix))]
fn read_only(mut permissions: Permissions) -> Permissions {
let () = permissions.set_readonly(true);
permissions
}
#[cfg(unix)]
fn writeable(mut permissions: Permissions) -> Permissions {
#[expect(trivial_numeric_casts, clippy::unnecessary_cast)]
let () = permissions.set_mode(permissions.mode() | S_IWUSR as u32);
permissions
}
#[cfg(not(unix))]
fn writeable(mut permissions: Permissions) -> Permissions {
let () = permissions.set_readonly(false);
permissions
}
async fn change_item_permissions<F>(path: &Path, f: F) -> Result<()>
where
F: FnOnce(Permissions) -> Permissions,
{
let meta_data = match metadata(path).await {
Ok(meta_data) => meta_data,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
r @ Err(_) => {
r.with_context(|| format!("failed to retrieve meta data for {}", path.display()))?
},
};
let permissions = meta_data.permissions();
let new_permissions = f(permissions.clone());
if new_permissions != permissions {
let () = set_permissions(path, new_permissions)
.await
.with_context(|| format!("failed to adjust permissions of {}", path.display()))?;
}
Ok(())
}
async fn change_directory_permissions<F>(directory: &Path, f: F) -> Result<()>
where
F: Fn(Permissions) -> Permissions,
{
change_item_permissions(directory, &f).await?;
let mut dir = match read_dir(directory).await {
Ok(dir) => dir,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
r @ Err(_) => r.with_context(|| {
format!(
"failed to read contents of directory {}",
directory.display()
)
})?,
};
while let Some(entry) = dir.next_entry().await.with_context(|| {
format!(
"failed to iterate contents of directory {}",
directory.display()
)
})? {
let file_type = entry
.file_type()
.await
.with_context(|| format!("failed to inquire file type of {}", entry.path().display()))?;
if !file_type.is_dir() {
let () = change_item_permissions(&entry.path(), &f).await?;
}
}
Ok(())
}
fn run_async<Fut>(future: Fut)
where
Fut: Future<Output = ()> + Send,
{
let handle = Handle::current();
let () = thread::scope(|scope| {
let handle = scope.spawn(|| handle.block_on(future));
handle.join().unwrap()
});
}
#[derive(Debug)]
pub struct DirCap {
directory: PathBuf,
}
impl DirCap {
pub async fn for_dir(directory: PathBuf) -> Result<Self> {
let slf = Self { directory };
let () = slf.protect().await?;
Ok(slf)
}
async fn protect(&self) -> Result<()> {
change_directory_permissions(&self.directory, read_only).await
}
async fn unprotect(&self) -> Result<()> {
change_directory_permissions(&self.directory, writeable).await
}
pub async fn write(&mut self) -> Result<WriteGuard<'_>> {
WriteGuard::new(self).await
}
pub fn path(&self) -> &Path {
&self.directory
}
}
impl Drop for DirCap {
fn drop(&mut self) {
let () = run_async(async {
let result = self.unprotect().await;
if cfg!(debug_assertions) {
let () = result.unwrap_or_else(|error| {
panic!(
"failed to revert permissions of {}: {error}",
self.directory.display()
)
});
}
});
}
}
#[derive(Debug)]
pub struct WriteGuard<'cap> {
dir_cap: &'cap mut DirCap,
}
impl<'cap> WriteGuard<'cap> {
async fn new(dir_cap: &'cap mut DirCap) -> Result<WriteGuard<'cap>> {
let () = change_item_permissions(&dir_cap.directory, writeable).await?;
Ok(Self { dir_cap })
}
pub fn file_cap<'slf>(&'slf self, file: &OsStr) -> FileCap<'slf> {
FileCap::new(self.dir_cap.directory.join(file))
}
}
impl Deref for WriteGuard<'_> {
type Target = DirCap;
fn deref(&self) -> &Self::Target {
self.dir_cap
}
}
impl Drop for WriteGuard<'_> {
fn drop(&mut self) {
let () = run_async(async {
let result = change_directory_permissions(&self.dir_cap.directory, read_only).await;
if cfg!(debug_assertions) {
let () = result.unwrap_or_else(|error| {
panic!(
"failed to revert permissions of {}: {error}",
self.dir_cap.directory.display()
)
});
}
});
}
}
#[derive(Debug)]
pub struct FileCap<'cap> {
path: PathBuf,
_phantom: PhantomData<&'cap ()>,
}
impl FileCap<'_> {
fn new(path: PathBuf) -> Self {
Self {
path,
_phantom: PhantomData,
}
}
pub async fn with_writeable_path<F, Fut>(&mut self, f: F) -> Result<()>
where
F: FnOnce(PathBuf) -> Fut,
Fut: Future<Output = Result<()>>,
{
let () = change_item_permissions(&self.path, writeable).await?;
let result = f(self.path.to_path_buf()).await;
match (result, change_item_permissions(&self.path, read_only).await) {
(Ok(()), Ok(())) => Ok(()),
(Ok(()), r @ Err(_)) => {
r.with_context(|| format!("failed to revert permissions of {}", self.path.display()))
},
(r @ Err(_), Ok(())) => r,
(r @ Err(_), Err(_)) => {
eprintln!("failed to revert permissions of {}", self.path.display());
r
},
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
use tempfile::TempDir;
use tokio::fs::create_dir;
use tokio::fs::read_to_string;
use tokio::fs::remove_file;
use tokio::fs::write;
use tokio::test;
#[test]
async fn protect_directory() {
let root = TempDir::new().unwrap();
let file1 = NamedTempFile::new_in(root.path()).unwrap();
let file2 = NamedTempFile::new_in(root.path()).unwrap();
let file3 = NamedTempFile::new_in(root.path()).unwrap();
let file4 = NamedTempFile::new_in(root.path()).unwrap();
{
let path = root.path().to_path_buf();
let _capability = DirCap::for_dir(path).await.unwrap();
let error = remove_file(file1.path()).await.unwrap_err();
assert_eq!(error.kind(), ErrorKind::PermissionDenied);
let error = NamedTempFile::new_in(root.path()).unwrap_err();
assert_eq!(error.kind(), ErrorKind::PermissionDenied);
let error = write(file2.path(), "test data").await.unwrap_err();
assert_eq!(error.kind(), ErrorKind::PermissionDenied);
}
let () = write(file3.path(), "hihi, it works").await.unwrap();
let () = remove_file(file4.path()).await.unwrap();
let _file5 = NamedTempFile::new_in(root.path()).unwrap();
}
#[test]
async fn non_existent_directory_and_file() {
let path = {
let dir = TempDir::new().unwrap();
dir.path().to_path_buf()
};
let mut capability = DirCap::for_dir(path.clone()).await.unwrap();
let write_guard = capability.write().await.unwrap();
let mut file_cap = write_guard.file_cap(OsStr::new("non-existent-file-in-non-existent-dir"));
let () = file_cap
.with_writeable_path(|path| async move {
assert!(!path.exists());
Ok(())
})
.await
.unwrap();
assert!(!path.exists());
}
#[test]
async fn newly_created_directory_is_protected() {
let path = {
let dir = TempDir::new().unwrap();
dir.path().to_path_buf()
};
let mut capability = DirCap::for_dir(path.clone()).await.unwrap();
{
let _write_guard = capability.write().await.unwrap();
let () = create_dir(&path).await.unwrap();
let () = write(path.join("test-file"), "test data").await.unwrap();
}
let error = write(path.join("another-file"), "test").await.unwrap_err();
assert_eq!(error.kind(), ErrorKind::PermissionDenied);
}
#[test]
async fn newly_created_file_is_protected() {
let root = TempDir::new().unwrap();
let path = root.path().to_path_buf();
let mut capability = DirCap::for_dir(path).await.unwrap();
let path = {
let _guard = capability.write().await.unwrap();
let file = NamedTempFile::new_in(root.path()).unwrap();
let path = file.into_temp_path().keep().unwrap();
path
};
let error = write(path, "test data").await.unwrap_err();
assert_eq!(error.kind(), ErrorKind::PermissionDenied);
}
#[test]
async fn file_cap_unprotects_file() {
let root = TempDir::new().unwrap();
let file = NamedTempFile::new_in(root.path()).unwrap();
{
let path = root.path().to_path_buf();
let mut capability = DirCap::for_dir(path).await.unwrap();
let guard = capability.write().await.unwrap();
let mut file_cap = guard.file_cap(file.path().file_name().unwrap());
let error = write(file.path(), "test data").await.unwrap_err();
assert_eq!(error.kind(), ErrorKind::PermissionDenied);
let () = file_cap
.with_writeable_path(|path| async move {
let () = write(path, "success").await.unwrap();
Ok(())
})
.await
.unwrap();
let error = write(file.path(), "test data").await.unwrap_err();
assert_eq!(error.kind(), ErrorKind::PermissionDenied);
}
let content = read_to_string(file.path()).await.unwrap();
assert_eq!(content, "success");
}
}