use std::path::{Path, PathBuf};
pub const PRIVATE_DIR_MODE: u32 = 0o700;
#[derive(Debug)]
#[non_exhaustive]
pub enum PrivateDirError {
Create {
path: PathBuf,
source: std::io::Error,
},
Stat {
path: PathBuf,
source: std::io::Error,
},
Symlink {
path: PathBuf,
},
NotADirectory {
path: PathBuf,
found: &'static str,
},
Harden {
path: PathBuf,
mode: u32,
source: std::io::Error,
},
}
impl std::fmt::Display for PrivateDirError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Create { path, source } => {
write!(
f,
"could not create private directory {}: {source}",
path.display()
)
}
Self::Stat { path, source } => {
write!(f, "could not stat {}: {source}", path.display())
}
Self::Symlink { path } => {
write!(
f,
"refusing {}: it is a symlink, not a private directory",
path.display()
)
}
Self::NotADirectory { path, found } => write!(
f,
"refusing {}: found a {found}, not a directory",
path.display()
),
Self::Harden { path, mode, source } => write!(
f,
"could not tighten {} to {mode:04o}: {source}",
path.display()
),
}
}
}
impl std::error::Error for PrivateDirError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Create { source, .. }
| Self::Stat { source, .. }
| Self::Harden { source, .. } => Some(source),
Self::Symlink { .. } | Self::NotADirectory { .. } => None,
}
}
}
impl From<PrivateDirError> for std::io::Error {
fn from(err: PrivateDirError) -> Self {
let kind = match &err {
PrivateDirError::Create { source, .. }
| PrivateDirError::Stat { source, .. }
| PrivateDirError::Harden { source, .. } => source.kind(),
PrivateDirError::Symlink { .. } | PrivateDirError::NotADirectory { .. } => {
std::io::ErrorKind::NotADirectory
}
};
std::io::Error::new(kind, err.to_string())
}
}
pub fn ensure_private_dir(dir: &Path, mode: u32) -> Result<(), PrivateDirError> {
match std::fs::symlink_metadata(dir) {
Ok(meta) => {
let ftype = meta.file_type();
if ftype.is_symlink() {
return Err(PrivateDirError::Symlink {
path: dir.to_path_buf(),
});
}
if !ftype.is_dir() {
return Err(PrivateDirError::NotADirectory {
path: dir.to_path_buf(),
found: describe_non_dir(&ftype),
});
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let current = meta.permissions().mode() & 0o777;
if current != mode {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).map_err(
|source| PrivateDirError::Harden {
path: dir.to_path_buf(),
mode,
source,
},
)?;
}
}
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => create_at_mode(dir, mode),
Err(source) => Err(PrivateDirError::Stat {
path: dir.to_path_buf(),
source,
}),
}
}
fn describe_non_dir(ft: &std::fs::FileType) -> &'static str {
if ft.is_file() {
"regular file"
} else {
"special file"
}
}
#[cfg(unix)]
fn create_at_mode(dir: &Path, mode: u32) -> Result<(), PrivateDirError> {
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.mode(mode)
.recursive(true)
.create(dir)
.map_err(|source| PrivateDirError::Create {
path: dir.to_path_buf(),
source,
})
}
#[cfg(not(unix))]
fn create_at_mode(dir: &Path, _mode: u32) -> Result<(), PrivateDirError> {
std::fs::create_dir_all(dir).map_err(|source| PrivateDirError::Create {
path: dir.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn mode_of(path: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).expect("stat").permissions().mode() & 0o777
}
#[test]
fn ensure_private_dir_creates_new_ancestors_at_mode() {
let tmp = tempfile::tempdir().expect("tempdir");
let nested = tmp.path().join("a").join("b").join("c");
ensure_private_dir(&nested, PRIVATE_DIR_MODE).expect("create");
#[cfg(unix)]
for level in [tmp.path().join("a"), tmp.path().join("a").join("b"), nested] {
assert_eq!(
mode_of(&level),
PRIVATE_DIR_MODE,
"{} must be 0700, including ancestors this call created",
level.display()
);
}
#[cfg(not(unix))]
assert!(nested.is_dir());
}
#[test]
fn ensure_private_dir_narrows_a_wide_existing_leaf() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("wide");
std::fs::create_dir_all(&dir).expect("mkdir");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755))
.expect("chmod wide");
}
ensure_private_dir(&dir, PRIVATE_DIR_MODE).expect("narrow");
#[cfg(unix)]
assert_eq!(
mode_of(&dir),
PRIVATE_DIR_MODE,
"pre-existing wide dir must be narrowed"
);
}
#[test]
#[cfg(unix)]
fn ensure_private_dir_refuses_a_symlinked_leaf() {
let tmp = tempfile::tempdir().expect("tempdir");
let real_target = tmp.path().join("real");
std::fs::create_dir_all(&real_target).expect("mkdir real");
let leaf = tmp.path().join("leaf");
std::os::unix::fs::symlink(&real_target, &leaf).expect("symlink");
let err = ensure_private_dir(&leaf, PRIVATE_DIR_MODE).expect_err("must refuse a symlink");
assert!(
matches!(err, PrivateDirError::Symlink { .. }),
"got {err:?}"
);
assert!(
std::fs::symlink_metadata(&leaf)
.expect("lstat")
.file_type()
.is_symlink()
);
}
#[test]
fn ensure_private_dir_refuses_a_regular_file_leaf() {
let tmp = tempfile::tempdir().expect("tempdir");
let leaf = tmp.path().join("leaf");
std::fs::write(&leaf, b"not a directory").expect("write file");
let err =
ensure_private_dir(&leaf, PRIVATE_DIR_MODE).expect_err("must refuse a regular file");
assert!(
matches!(err, PrivateDirError::NotADirectory { .. }),
"got {err:?}"
);
}
#[test]
fn private_dir_error_into_io_error_preserves_kind() {
let tmp = tempfile::tempdir().expect("tempdir");
let blocking_file = tmp.path().join("blocker");
std::fs::write(&blocking_file, b"x").expect("write blocker");
let unreachable = blocking_file.join("cannot_have_a_child").join("leaf");
let err = ensure_private_dir(&unreachable, PRIVATE_DIR_MODE).expect_err("must fail");
assert!(
matches!(
err,
PrivateDirError::Stat { .. } | PrivateDirError::Create { .. }
),
"got {err:?}"
);
let io_err: std::io::Error = err.into();
assert_ne!(
io_err.kind(),
std::io::ErrorKind::Other,
"the original OS error kind must survive the conversion, got {io_err:?}"
);
}
#[test]
#[cfg(unix)]
fn private_dir_error_symlink_into_io_error_is_not_already_exists() {
let tmp = tempfile::tempdir().expect("tempdir");
let real_target = tmp.path().join("real");
std::fs::create_dir_all(&real_target).expect("mkdir real");
let leaf = tmp.path().join("leaf");
std::os::unix::fs::symlink(&real_target, &leaf).expect("symlink");
let err = ensure_private_dir(&leaf, PRIVATE_DIR_MODE).expect_err("must refuse a symlink");
assert!(
matches!(err, PrivateDirError::Symlink { .. }),
"got {err:?}"
);
let io_err: std::io::Error = err.into();
assert_eq!(
io_err.kind(),
std::io::ErrorKind::NotADirectory,
"a symlink refusal must not read as AlreadyExists, got {io_err:?}"
);
}
}