use crate::host::{Access, Private};
use crate::service::Permit;
use std::fs::{File, OpenOptions};
use std::io;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::Path;
use std::time::SystemTime;
pub(crate) fn create_private_dir(_: Permit, path: &Path) -> io::Result<()> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)
}
pub(crate) fn create_private(_: Permit, path: &Path) -> io::Result<File> {
OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
}
pub(crate) fn open_private_append(_: Permit, path: &Path) -> io::Result<File> {
OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(path)
}
pub(crate) fn open_private_lock(_: Permit, path: &Path) -> io::Result<File> {
OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.mode(0o600)
.open(path)
}
pub(crate) fn copy_access(_: Permit, existing: &Path, temp: &Path) -> io::Result<()> {
match std::fs::symlink_metadata(existing) {
Ok(found) if !found.file_type().is_symlink() => {
let mode = found.permissions().mode() & 0o777;
std::fs::set_permissions(temp, std::fs::Permissions::from_mode(mode))
}
_ => Ok(()),
}
}
pub(crate) fn sync_dir(dir: &Path) {
let _ = flush_dir(dir);
}
pub(crate) fn flush_dir(dir: &Path) -> io::Result<()> {
File::open(dir)?.sync_all()
}
fn c_path(path: &Path) -> io::Result<std::ffi::CString> {
use std::os::unix::ffi::OsStrExt;
std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
}
fn not_replacing(renamed: libc::c_int) -> io::Result<()> {
if renamed == 0 {
Ok(())
} else {
Err(refusal(io::Error::last_os_error()))
}
}
fn refusal(refused: io::Error) -> io::Error {
match refused.raw_os_error() {
Some(libc::EEXIST) => io::Error::new(io::ErrorKind::AlreadyExists, refused),
Some(libc::ENOTSUP | libc::EINVAL | libc::ENOSYS) => {
io::Error::new(io::ErrorKind::Unsupported, refused)
}
_ => refused,
}
}
pub(crate) fn move_new(_: Permit, from: &Path, to: &Path) -> io::Result<()> {
let (from, to) = (c_path(from)?, c_path(to)?);
not_replacing(crate::host::os::rename_exclusive(&from, &to))
}
fn nearest(path: &Path) -> io::Result<std::fs::Metadata> {
let mut at = path;
loop {
match std::fs::metadata(at) {
Ok(found) => return Ok(found),
Err(e) if e.kind() == io::ErrorKind::NotFound => match at.parent() {
Some(parent) => at = parent,
None => return Err(e),
},
Err(e) => return Err(e),
}
}
}
pub(crate) fn same_volume(a: &Path, b: &Path) -> io::Result<bool> {
use std::os::unix::fs::MetadataExt;
Ok(nearest(a)?.dev() == nearest(b)?.dev())
}
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn file_id(path: &Path) -> Option<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(path).map(|it| (it.dev(), it.ino())).ok()
}
pub(crate) fn touch_dir(_: Permit, path: &Path, at: SystemTime) -> io::Result<()> {
File::open(path)?.set_modified(at)
}
pub(crate) fn create_dir_all(_: Permit, path: &Path) -> io::Result<()> {
std::fs::create_dir_all(path)
}
pub(crate) fn create_dir(_: Permit, path: &Path) -> io::Result<()> {
std::fs::create_dir(path)
}
pub(crate) fn copy(_: Permit, from: &Path, to: &Path) -> io::Result<u64> {
std::fs::copy(from, to)
}
pub(crate) fn rename(_: Permit, from: &Path, to: &Path) -> io::Result<()> {
std::fs::rename(from, to)
}
pub(crate) fn remove_file(_: Permit, path: &Path) -> io::Result<()> {
std::fs::remove_file(path)
}
pub(crate) fn remove_dir(_: Permit, path: &Path) -> io::Result<()> {
std::fs::remove_dir(path)
}
pub(crate) fn remove_dir_all(_: Permit, path: &Path) -> io::Result<()> {
std::fs::remove_dir_all(path)
}
pub(crate) fn private_dir(path: &Path) -> io::Result<Private> {
use std::os::unix::fs::MetadataExt;
let found = match std::fs::symlink_metadata(path) {
Ok(found) => found,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Private::Missing),
Err(e) => return Err(e),
};
let me = unsafe { libc::geteuid() };
Ok(if found.file_type().is_symlink() {
Private::Link
} else if !found.is_dir() {
Private::NotADirectory
} else if found.uid() != me {
Private::NotOwn
} else if found.mode() & 0o077 != 0 {
Private::Shared
} else {
Private::Yes
})
}
pub(crate) fn lock_dir(path: &Path) -> io::Result<File> {
let folder = File::open(path)?;
match folder.try_lock() {
Ok(()) => Ok(folder),
Err(std::fs::TryLockError::WouldBlock) => Err(io::ErrorKind::WouldBlock.into()),
Err(std::fs::TryLockError::Error(e)) => Err(e),
}
}
pub(crate) fn access(path: &Path) -> Option<Access> {
let mode = std::fs::metadata(path).ok()?.permissions().mode() & 0o777;
Some(Access {
shared: mode & 0o077 != 0,
described: format!("mode {mode:o}"),
})
}
#[cfg(any(test, feature = "test-support"))]
#[cfg_attr(not(feature = "test-support"), allow(unreachable_pub))]
pub mod testing {
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
fn set(path: &Path, mode: u32) -> io::Result<()> {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
}
pub fn open_to_others(path: &Path) -> io::Result<()> {
set(path, 0o644)
}
pub fn make_private(path: &Path) -> io::Result<()> {
set(path, private_mode(path)?)
}
pub fn is_private(path: &Path) -> io::Result<bool> {
let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
Ok(mode == private_mode(path)?)
}
fn private_mode(path: &Path) -> io::Result<u32> {
Ok(if std::fs::metadata(path)?.is_dir() {
0o700
} else {
0o600
})
}
pub fn read_only_for_owner(path: &Path) -> io::Result<()> {
set(path, 0o400)
}
pub fn deny_reading(path: &Path) -> io::Result<()> {
set(path, 0o000)
}
pub fn make_runnable(path: &Path) -> io::Result<()> {
set(path, 0o755)
}
pub fn deny_running(path: &Path) -> io::Result<()> {
set(path, 0o644)
}
pub fn deny_changes(dir: &Path) -> io::Result<()> {
set(dir, 0o555)
}
pub fn allow_changes(dir: &Path) -> io::Result<()> {
set(dir, 0o755)
}
pub fn link(target: &Path, at: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, at)
}
pub fn link_dir(target: &Path, at: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, at)
}
pub fn inode(path: &Path) -> io::Result<u64> {
use std::os::unix::fs::MetadataExt;
Ok(std::fs::symlink_metadata(path)?.ino())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"pitboard-host-fs-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn mode(p: &Path) -> u32 {
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
}
#[test]
fn a_folder_is_private_only_where_it_is_this_users_own_directory_closed_to_others() {
let dir = scratch("private");
assert_eq!(private_dir(&dir).unwrap(), Private::Missing);
create_private_dir(Permit::for_a_test(), &dir).unwrap();
assert_eq!(private_dir(&dir).unwrap(), Private::Yes);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap();
assert_eq!(private_dir(&dir).unwrap(), Private::Shared);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let link = dir.with_extension("link");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&dir, &link).unwrap();
assert_eq!(private_dir(&link).unwrap(), Private::Link);
std::fs::write(dir.join("file"), b"").unwrap();
assert_eq!(
private_dir(&dir.join("file")).unwrap(),
Private::NotADirectory
);
use std::os::unix::fs::MetadataExt;
let mine = std::fs::metadata(&dir).unwrap().uid();
if std::fs::metadata("/").unwrap().uid() != mine {
assert_eq!(private_dir(Path::new("/")).unwrap(), Private::NotOwn);
}
std::fs::remove_file(&link).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_rename_never_lands_on_something_already_there() {
use std::os::unix::fs::MetadataExt;
let dir = scratch("move-new");
let permit = Permit::for_a_test();
create_private_dir(permit, &dir).unwrap();
let (from, to) = (dir.join("from"), dir.join("to"));
std::fs::write(&from, b"incoming").unwrap();
std::fs::write(&to, b"already there").unwrap();
let refused = move_new(permit, &from, &to).unwrap_err();
assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists, "{refused}");
assert_eq!(std::fs::read(&to).unwrap(), b"already there");
assert_eq!(std::fs::read(&from).unwrap(), b"incoming");
std::fs::remove_file(&to).unwrap();
testing::link(&dir.join("nowhere"), &to).unwrap();
let refused = move_new(permit, &from, &to).unwrap_err();
assert_eq!(
refused.kind(),
io::ErrorKind::AlreadyExists,
"a dangling link"
);
assert!(std::fs::symlink_metadata(&to).unwrap().is_symlink());
assert!(!dir.join("nowhere").exists());
std::fs::remove_file(&to).unwrap();
let (dir_from, empty, full) = (dir.join("dir-from"), dir.join("empty"), dir.join("full"));
std::fs::create_dir(&dir_from).unwrap();
std::fs::create_dir(&empty).unwrap();
std::fs::create_dir(&full).unwrap();
std::fs::write(full.join("kept"), b"kept").unwrap();
for there in [&empty, &full] {
let refused = move_new(permit, &dir_from, there).unwrap_err();
assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists, "{refused}");
}
assert!(dir_from.is_dir());
assert_eq!(std::fs::read(full.join("kept")).unwrap(), b"kept");
let inode = std::fs::metadata(&from).unwrap().ino();
move_new(permit, &from, &to).unwrap();
assert_eq!(
std::fs::metadata(&to).unwrap().ino(),
inode,
"a move keeps the inode"
);
assert!(!from.exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_volume_that_cannot_refuse_is_refused_not_renamed_over() {
for errno in [libc::ENOTSUP, libc::EINVAL, libc::ENOSYS] {
assert_eq!(
refusal(io::Error::from_raw_os_error(errno)).kind(),
io::ErrorKind::Unsupported,
"{errno}"
);
}
assert_eq!(
refusal(io::Error::from_raw_os_error(libc::EEXIST)).kind(),
io::ErrorKind::AlreadyExists
);
assert!(not_replacing(0).is_ok());
}
#[test]
fn one_scratch_folder_is_one_volume_and_a_missing_path_asks_its_parent() {
let dir = scratch("volume");
create_private_dir(Permit::for_a_test(), &dir).unwrap();
assert!(same_volume(&dir, &dir.join("not/made/yet")).unwrap());
assert!(!same_volume(&dir, Path::new("/dev")).unwrap());
assert!(flush_dir(&dir).is_ok());
assert!(flush_dir(&dir.join("missing")).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_locked_folder_refuses_a_second_lock() {
let dir = scratch("lock");
create_private_dir(Permit::for_a_test(), &dir).unwrap();
let before: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
let held = lock_dir(&dir).unwrap();
assert_eq!(
lock_dir(&dir).map(drop).map_err(|e| e.kind()),
Err(io::ErrorKind::WouldBlock)
);
held.unlock().unwrap();
let again = lock_dir(&dir).unwrap();
drop(again);
assert!(before.is_empty());
assert_eq!(
std::fs::read_dir(&dir).unwrap().count(),
0,
"nothing written"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn created_directories_are_private_and_existing_ones_keep_their_mode() {
let scratch = scratch("dirs");
let permit = Permit::for_a_test();
create_private_dir(permit, &scratch.join("nested")).unwrap();
assert_eq!(
mode(&scratch),
0o700,
"a created parent must be private too"
);
assert_eq!(mode(&scratch.join("nested")), 0o700);
std::fs::set_permissions(&scratch, std::fs::Permissions::from_mode(0o755)).unwrap();
create_private_dir(permit, &scratch).unwrap();
assert_eq!(
mode(&scratch),
0o755,
"a directory the user named is theirs to set"
);
std::fs::remove_dir_all(&scratch).unwrap();
}
#[test]
fn a_file_created_private_is_private_however_the_umask_is_set() {
use std::io::Write;
let dir = scratch("file");
let permit = Permit::for_a_test();
create_private_dir(permit, &dir).unwrap();
let (fresh, history, lock) = (
dir.join(".state.json.1.0.pitboard"),
dir.join("history.jsonl"),
dir.join("state.lock"),
);
create_private(permit, &fresh).unwrap();
open_private_append(permit, &history).unwrap();
open_private_lock(permit, &lock).unwrap();
for made in [&fresh, &history, &lock] {
assert_eq!(
access(made),
Some(Access {
shared: false,
described: "mode 600".into()
}),
"{}",
made.display()
);
}
for there in [&history, &lock] {
std::fs::write(there, "one\n").unwrap();
testing::open_to_others(there).unwrap();
assert!(access(there).unwrap().shared);
}
writeln!(open_private_append(permit, &history).unwrap(), "two").unwrap();
open_private_lock(permit, &lock).unwrap();
assert_eq!(std::fs::read_to_string(&history).unwrap(), "one\ntwo\n");
assert_eq!(std::fs::read_to_string(&lock).unwrap(), "one\n");
for there in [&history, &lock] {
assert_eq!(
mode(there),
0o644,
"a file already there keeps its own mode"
);
}
assert!(create_private(permit, &fresh).is_err(), "a file is there");
let planted = dir.join(".usage.json.1.0.pitboard");
testing::link(&dir.join("elsewhere"), &planted).unwrap();
assert!(create_private(permit, &planted).is_err(), "a link is there");
assert!(
!dir.join("elsewhere").exists(),
"and nothing is written through it"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn each_way_a_test_sets_a_file_up_does_what_it_says() {
let dir = scratch("testing");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("file");
std::fs::write(&file, "").unwrap();
let gives = |set: fn(&Path) -> io::Result<()>, expected: u32| {
set(&file).unwrap();
assert_eq!(mode(&file), expected);
};
gives(testing::open_to_others, 0o644);
gives(testing::make_private, 0o600);
gives(testing::read_only_for_owner, 0o400);
gives(testing::deny_reading, 0o000);
gives(testing::make_runnable, 0o755);
gives(testing::deny_running, 0o644);
assert!(!testing::is_private(&file).unwrap());
testing::make_private(&file).unwrap();
assert!(testing::is_private(&file).unwrap());
let folder = dir.join("folder");
std::fs::create_dir(&folder).unwrap();
testing::make_private(&folder).unwrap();
assert_eq!(mode(&folder), 0o700, "a directory's own private mode");
assert!(testing::is_private(&folder).unwrap());
testing::deny_changes(&folder).unwrap();
assert_eq!(mode(&folder), 0o555);
testing::allow_changes(&folder).unwrap();
assert_eq!(mode(&folder), 0o755);
assert!(!testing::is_private(&folder).unwrap());
testing::link(Path::new("file"), &dir.join("to-file")).unwrap();
testing::link_dir(&folder, &dir.join("to-folder")).unwrap();
assert_eq!(
std::fs::read_link(dir.join("to-file")).unwrap(),
Path::new("file")
);
assert!(
dir.join("to-file").is_file(),
"read from the link's own directory"
);
assert!(dir.join("to-folder").is_dir());
std::fs::remove_dir_all(&dir).unwrap();
}
}