use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PrivatePathDurabilityFailurePoint {
ParentDirectorySync,
}
#[derive(Clone, Debug, Default)]
pub struct PrivatePathDurabilityFailureInjector {
failures: Arc<Mutex<Vec<PrivatePathDurabilityFailurePoint>>>,
}
impl PrivatePathDurabilityFailureInjector {
pub fn fail_next(&self, point: PrivatePathDurabilityFailurePoint) {
self.failures
.lock()
.expect("private-path failure injector lock poisoned")
.push(point);
}
fn check(&self, point: PrivatePathDurabilityFailurePoint) -> io::Result<()> {
let mut failures = self
.failures
.lock()
.map_err(|_| io::Error::other("private-path failure injector lock poisoned"))?;
if failures.first() == Some(&point) {
failures.remove(0);
return Err(io::Error::other(
"injected private-path parent directory sync failure",
));
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PrivateTreePolicy {
selected: Vec<PathBuf>,
}
impl PrivateTreePolicy {
pub fn root_only() -> Self {
Self::default()
}
pub fn selected<I, P>(paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
Self {
selected: paths
.into_iter()
.map(|path| path.as_ref().to_path_buf())
.collect(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PrivateTreeReport {
pub directories_hardened: usize,
pub files_hardened: usize,
}
#[derive(Debug)]
pub struct PrivateTree {
root: PathBuf,
root_descriptor: File,
operation_lock: std::sync::Mutex<()>,
}
impl PrivateTree {
pub fn open(root: &Path) -> io::Result<Self> {
#[cfg(unix)]
let root_descriptor = {
let descriptor = unix_walk_directory(root, false, true)?;
unix_validate_private_dir_exact(&descriptor)?;
unix_revalidate_directory_path(root, &descriptor)?;
descriptor
};
#[cfg(target_os = "windows")]
let root_descriptor = {
let descriptor = windows_open_directory_for_hardening(root)?;
windows_validate_directory(&descriptor)?;
windows_validate_hardenable_file_owner(&descriptor)?;
windows_harden_file_acl(&descriptor)?;
windows_revalidate_directory_path(root, &descriptor)?;
descriptor
};
#[cfg(not(any(unix, target_os = "windows")))]
let root_descriptor = File::open(root)?;
let tree = Self {
root: root.to_path_buf(),
root_descriptor,
operation_lock: std::sync::Mutex::new(()),
};
tree.revalidate_root()?;
Ok(tree)
}
pub fn harden_selected(&self, policy: &PrivateTreePolicy) -> io::Result<PrivateTreeReport> {
let _operation = self
.operation_lock
.lock()
.map_err(|_| io::Error::other("private-tree operation lock is poisoned"))?;
let selected = normalized_private_tree_selection(policy)?;
self.revalidate_root()?;
#[cfg(unix)]
let report = unix_harden_private_tree(self, &selected)?;
#[cfg(target_os = "windows")]
let report = windows_harden_private_tree(self, &selected)?;
#[cfg(not(any(unix, target_os = "windows")))]
let report = generic_harden_private_tree(self, &selected)?;
self.revalidate_root()?;
Ok(report)
}
pub fn revalidate_root(&self) -> io::Result<()> {
#[cfg(unix)]
{
unix_validate_private_dir_exact(&self.root_descriptor)?;
unix_revalidate_directory_path(&self.root, &self.root_descriptor)
}
#[cfg(target_os = "windows")]
{
windows_validate_directory(&self.root_descriptor)?;
windows_validate_file_owner(&self.root_descriptor)?;
windows_revalidate_directory_path(&self.root, &self.root_descriptor)
}
#[cfg(not(any(unix, target_os = "windows")))]
{
if self.root_descriptor.metadata()?.is_dir() {
Ok(())
} else {
Err(permission_denied("private tree root is not a directory"))
}
}
}
}
pub fn harden_private_tree(
root: &Path,
policy: &PrivateTreePolicy,
) -> io::Result<PrivateTreeReport> {
PrivateTree::open(root)?.harden_selected(policy)
}
fn normalized_private_tree_selection(policy: &PrivateTreePolicy) -> io::Result<Vec<PathBuf>> {
let mut selected = policy.selected.clone();
for path in &selected {
if path.as_os_str().is_empty() || path.is_absolute() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"private-tree selections must be non-empty relative paths",
));
}
for component in path.components() {
if !matches!(component, std::path::Component::Normal(_)) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"private-tree selections must contain only normal components",
));
}
}
}
selected.sort();
selected.dedup();
Ok(selected)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PrivateOpenMode {
Read,
Append,
Truncate,
CreateNew,
}
pub fn harden_owner_only(path: &Path) {
#[cfg(target_os = "windows")]
if let Err(error) = harden_windows_acl(path) {
tracing::warn!(?error, ?path, "owner-only ACL hardening failed");
}
#[cfg(not(target_os = "windows"))]
let _ = path;
}
pub fn harden_owner_only_fallible(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
let metadata = std::fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
return Err(permission_denied("owner-private path cannot be a symlink"));
}
if metadata.is_dir() {
return ensure_private_dir(path);
}
let _file = open_private_read(path)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
let metadata = std::fs::symlink_metadata(path)?;
reject_windows_reparse_metadata(&metadata)?;
if metadata.is_dir() {
return windows_ensure_private_dir(path);
}
let _file = windows_open_private(path, PrivateOpenMode::Read, None)?;
Ok(())
}
#[cfg(not(any(unix, target_os = "windows")))]
{
let _ = path;
Ok(())
}
}
pub fn ensure_private_dir(path: &Path) -> io::Result<()> {
ensure_private_dir_inner(path, None)
}
pub fn ensure_private_dir_with_failure_injector(
path: &Path,
failures: &PrivatePathDurabilityFailureInjector,
) -> io::Result<()> {
ensure_private_dir_inner(path, Some(failures))
}
fn ensure_private_dir_inner(
path: &Path,
failures: Option<&PrivatePathDurabilityFailureInjector>,
) -> io::Result<()> {
#[cfg(unix)]
{
let _ = unix_walk_directory_with_failure_injector(path, true, true, failures)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
windows_ensure_private_dir_with_failure_injector(path, failures)
}
#[cfg(not(any(unix, target_os = "windows")))]
{
std::fs::create_dir_all(path)
}
}
pub fn create_private_file(path: &Path) -> io::Result<File> {
open_private(path, PrivateOpenMode::CreateNew, None)
}
pub fn create_private_file_with_failure_injector(
path: &Path,
failures: &PrivatePathDurabilityFailureInjector,
) -> io::Result<File> {
open_private(path, PrivateOpenMode::CreateNew, Some(failures))
}
pub fn open_private_read(path: &Path) -> io::Result<File> {
open_private(path, PrivateOpenMode::Read, None)
}
pub fn open_private_append(path: &Path) -> io::Result<File> {
open_private(path, PrivateOpenMode::Append, None)
}
pub fn open_private_append_with_failure_injector(
path: &Path,
failures: &PrivatePathDurabilityFailureInjector,
) -> io::Result<File> {
open_private(path, PrivateOpenMode::Append, Some(failures))
}
pub fn open_private_truncate(path: &Path) -> io::Result<File> {
open_private(path, PrivateOpenMode::Truncate, None)
}
pub fn revalidate_private_file(file: &File) -> io::Result<()> {
#[cfg(unix)]
{
unix_revalidate_private_file(file)
}
#[cfg(target_os = "windows")]
{
windows_validate_file(file)
}
#[cfg(not(any(unix, target_os = "windows")))]
{
if file.metadata()?.is_file() {
Ok(())
} else {
Err(permission_denied("private file is not a regular file"))
}
}
}
pub fn revalidate_private_path(path: &Path, file: &File) -> io::Result<()> {
revalidate_private_file(file)?;
#[cfg(unix)]
{
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"private file path has no file name",
)
})?;
let name = CString::new(name.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file name contains NUL"))?;
let parent = unix_walk_directory(normalized_parent(path), false, false)?;
unix_validate_path_matches_file(parent.as_raw_fd(), &name, file)
}
#[cfg(target_os = "windows")]
{
windows_revalidate_private_path(path, file)
}
#[cfg(not(any(unix, target_os = "windows")))]
{
let _ = path;
Err(io::Error::new(
io::ErrorKind::Unsupported,
"path identity revalidation is unsupported on this platform",
))
}
}
pub fn atomic_replace_private_file(temp: &Path, destination: &Path) -> io::Result<()> {
if normalized_parent(temp) != normalized_parent(destination) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"private atomic replacement requires one lexical parent directory",
));
}
#[cfg(unix)]
{
unix_atomic_replace_private_file(temp, destination)
}
#[cfg(target_os = "windows")]
{
windows_atomic_replace_private_file(temp, destination)
}
#[cfg(not(any(unix, target_os = "windows")))]
{
std::fs::rename(temp, destination)?;
let file = open_private_read(destination)?;
revalidate_private_file(&file)
}
}
fn open_private(
path: &Path,
mode: PrivateOpenMode,
failures: Option<&PrivatePathDurabilityFailureInjector>,
) -> io::Result<File> {
#[cfg(unix)]
{
unix_open_private(path, mode, failures)
}
#[cfg(target_os = "windows")]
{
windows_open_private(path, mode, failures)
}
#[cfg(not(any(unix, target_os = "windows")))]
{
let _ = failures;
generic_open_private(path, mode)
}
}
fn normalized_parent(path: &Path) -> &Path {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn permission_denied(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, message)
}
#[cfg(unix)]
fn unix_walk_directory(path: &Path, create: bool, harden_final: bool) -> io::Result<File> {
unix_walk_directory_with_failure_injector(path, create, harden_final, None)
}
#[cfg(unix)]
fn unix_walk_directory_with_failure_injector(
path: &Path,
create: bool,
harden_final: bool,
failures: Option<&PrivatePathDurabilityFailureInjector>,
) -> io::Result<File> {
unix_walk_directory_with_hook(path, create, harden_final, failures, |_| Ok(()))
}
#[cfg(unix)]
fn unix_walk_directory_with_hook<F>(
path: &Path,
create: bool,
harden_final: bool,
failures: Option<&PrivatePathDurabilityFailureInjector>,
mut after_child_barrier: F,
) -> io::Result<File>
where
F: FnMut(&std::ffi::OsStr) -> io::Result<()>,
{
use std::ffi::{CString, OsStr};
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::ffi::OsStrExt;
let descriptor_path = unix_descriptor_path(path);
let mut components = Vec::new();
let mut absolute = false;
for component in descriptor_path.components() {
match component {
std::path::Component::RootDir => absolute = true,
std::path::Component::CurDir => {}
std::path::Component::Normal(value) => components.push(value.to_os_string()),
std::path::Component::ParentDir => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"owner-private paths cannot contain parent traversal",
));
}
std::path::Component::Prefix(_) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"unsupported Unix path prefix",
));
}
}
}
let base = CString::new(if absolute { "/" } else { "." }).expect("static path");
let raw = unsafe {
libc::open(
base.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
)
};
if raw < 0 {
return Err(io::Error::last_os_error());
}
let directory = unsafe { File::from_raw_fd(raw) };
let mut hierarchy = vec![directory];
for (index, component) in components.iter().enumerate() {
let name = CString::new(OsStr::new(component).as_bytes()).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "path component contains NUL")
})?;
let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW;
let parent = hierarchy
.last()
.expect("private directory hierarchy always retains its base");
let mut child_raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) };
let mut created = false;
if child_raw < 0 && io::Error::last_os_error().kind() == io::ErrorKind::NotFound && create {
let mkdir_result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) };
if mkdir_result < 0 {
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::AlreadyExists {
return Err(error);
}
} else {
created = true;
}
child_raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) };
}
if child_raw < 0 {
return Err(io::Error::last_os_error());
}
let child = unsafe { File::from_raw_fd(child_raw) };
let is_final = index + 1 == components.len();
unix_validate_private_dir(&child, created || (is_final && harden_final))?;
let already_private = unix_private_dir_is_exact(&child)?;
if created {
if let Some(failures) = failures {
failures.check(PrivatePathDurabilityFailurePoint::ParentDirectorySync)?;
}
}
if created || (create && already_private) {
parent.sync_all()?;
unix_validate_path_matches_directory(parent.as_raw_fd(), &name, &child)?;
}
hierarchy.push(child);
after_child_barrier(component.as_os_str())?;
unix_revalidate_retained_directory_chain(&hierarchy, &components[..=index])?;
}
if components.is_empty() && harden_final {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"refusing to harden a filesystem root or current directory",
));
}
unix_revalidate_retained_directory_chain(&hierarchy, &components)?;
hierarchy
.pop()
.ok_or_else(|| io::Error::other("private directory hierarchy is empty"))
}
#[cfg(unix)]
fn unix_revalidate_retained_directory_chain(
hierarchy: &[File],
components: &[std::ffi::OsString],
) -> io::Result<()> {
use std::ffi::{CString, OsStr};
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
if hierarchy.len() != components.len() + 1 {
return Err(io::Error::other(
"private directory hierarchy does not match its component chain",
));
}
for (index, component) in components.iter().enumerate() {
let name = CString::new(OsStr::new(component).as_bytes()).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "path component contains NUL")
})?;
unix_validate_path_matches_directory(
hierarchy[index].as_raw_fd(),
&name,
&hierarchy[index + 1],
)?;
}
Ok(())
}
#[cfg(unix)]
fn unix_private_dir_is_exact(directory: &File) -> io::Result<bool> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let metadata = directory.metadata()?;
Ok(metadata.is_dir()
&& metadata.uid() == unsafe { libc::geteuid() }
&& metadata.permissions().mode() & 0o777 == 0o700)
}
#[cfg(unix)]
fn unix_descriptor_path(path: &Path) -> std::path::PathBuf {
#[cfg(target_os = "macos")]
{
for (alias, real) in [
(Path::new("/var"), Path::new("/private/var")),
(Path::new("/tmp"), Path::new("/private/tmp")),
(Path::new("/etc"), Path::new("/private/etc")),
] {
if let Ok(suffix) = path.strip_prefix(alias) {
return real.join(suffix);
}
}
}
path.to_path_buf()
}
#[cfg(unix)]
fn unix_validate_private_dir(directory: &File, harden: bool) -> io::Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let before = directory.metadata()?;
if !before.is_dir() {
return Err(permission_denied(
"private path component is not a directory",
));
}
if harden {
if before.uid() != unsafe { libc::geteuid() } {
return Err(permission_denied(
"private directory is not owned by the effective user",
));
}
directory.set_permissions(std::fs::Permissions::from_mode(0o700))?;
let after = directory.metadata()?;
if after.dev() != before.dev()
|| after.ino() != before.ino()
|| after.uid() != before.uid()
|| !after.is_dir()
|| after.permissions().mode() & 0o777 != 0o700
{
return Err(permission_denied(
"private directory changed while it was hardened",
));
}
}
Ok(())
}
#[cfg(unix)]
fn unix_validate_private_dir_exact(directory: &File) -> io::Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let metadata = directory.metadata()?;
if !metadata.is_dir()
|| metadata.uid() != unsafe { libc::geteuid() }
|| metadata.permissions().mode() & 0o777 != 0o700
{
return Err(permission_denied(
"private directory must be current-user owned with mode 0700",
));
}
Ok(())
}
#[cfg(unix)]
fn unix_revalidate_directory_path(path: &Path, directory: &File) -> io::Result<()> {
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
unix_validate_private_dir_exact(directory)?;
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"private directory path has no file name",
)
})?;
let name = CString::new(name.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "directory name contains NUL"))?;
let parent = unix_walk_directory(normalized_parent(path), false, false)?;
unix_validate_path_matches_directory(parent.as_raw_fd(), &name, directory)
}
#[cfg(unix)]
fn unix_validate_path_matches_directory(
parent_fd: std::os::fd::RawFd,
name: &std::ffi::CStr,
directory: &File,
) -> io::Result<()> {
use std::mem::MaybeUninit;
use std::os::unix::fs::MetadataExt;
let mut stat = MaybeUninit::<libc::stat>::uninit();
let result = unsafe {
libc::fstatat(
parent_fd,
name.as_ptr(),
stat.as_mut_ptr(),
libc::AT_SYMLINK_NOFOLLOW,
)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
let stat = unsafe { stat.assume_init() };
let metadata = directory.metadata()?;
if i128::from(stat.st_dev) != i128::from(metadata.dev())
|| stat.st_ino != metadata.ino()
|| (u64::from(stat.st_mode) & libc::S_IFMT as u64) != libc::S_IFDIR as u64
{
return Err(permission_denied(
"private directory path changed during descriptor validation",
));
}
Ok(())
}
#[cfg(unix)]
fn unix_harden_private_tree(
tree: &PrivateTree,
selected: &[PathBuf],
) -> io::Result<PrivateTreeReport> {
use std::collections::BTreeSet;
let mut report = PrivateTreeReport::default();
if selected.is_empty() {
unix_validate_private_dir(&tree.root_descriptor, true)?;
unix_validate_private_dir_exact(&tree.root_descriptor)?;
report.directories_hardened = 1;
return Ok(report);
}
let mut visited = BTreeSet::new();
visited.insert(unix_file_identity(&tree.root_descriptor)?);
for relative in selected {
let components = relative
.components()
.map(|component| component.as_os_str().to_os_string())
.collect::<Vec<_>>();
unix_harden_selected_path(
&tree.root_descriptor,
&components,
&mut visited,
&mut report,
)?;
}
Ok(report)
}
#[cfg(unix)]
fn unix_harden_selected_path(
parent: &File,
components: &[std::ffi::OsString],
visited: &mut std::collections::BTreeSet<(u64, u64)>,
report: &mut PrivateTreeReport,
) -> io::Result<()> {
use std::ffi::{CString, OsStr};
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
let (component, remaining) = components.split_first().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"private-tree selection has no components",
)
})?;
let name = CString::new(OsStr::new(component).as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "tree component contains NUL"))?;
if !remaining.is_empty() {
let directory = unix_open_directory_at(parent, &name)?;
let identity = unix_file_identity(&directory)?;
if visited.insert(identity) {
report.directories_hardened += 1;
}
unix_harden_selected_path(&directory, remaining, visited, report)?;
unix_validate_path_matches_directory(parent.as_raw_fd(), &name, &directory)?;
return Ok(());
}
match unix_entry_kind(parent.as_raw_fd(), &name)? {
UnixEntryKind::Directory => {
let directory = unix_open_directory_at(parent, &name)?;
let identity = unix_file_identity(&directory)?;
if visited.insert(identity) {
report.directories_hardened += 1;
unix_harden_directory_contents(&directory, visited, report)?;
}
unix_validate_path_matches_directory(parent.as_raw_fd(), &name, &directory)
}
UnixEntryKind::RegularFile => {
let file = unix_open_file_at(parent, &name, PrivateOpenMode::Read)?;
unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)?;
if visited.insert(unix_file_identity(&file)?) {
report.files_hardened += 1;
}
unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)
}
UnixEntryKind::Rejected => Err(permission_denied(
"private tree contains a symlink or special file",
)),
}
}
#[cfg(unix)]
fn unix_harden_directory_contents(
directory: &File,
visited: &mut std::collections::BTreeSet<(u64, u64)>,
report: &mut PrivateTreeReport,
) -> io::Result<()> {
use std::ffi::{CStr, OsStr};
use std::os::fd::AsRawFd;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let dot = c".";
let enumeration_fd = unsafe {
libc::openat(
directory.as_raw_fd(),
dot.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
)
};
if enumeration_fd < 0 {
return Err(io::Error::last_os_error());
}
let stream = unsafe { libc::fdopendir(enumeration_fd) };
if stream.is_null() {
let error = io::Error::last_os_error();
unsafe {
libc::close(enumeration_fd);
}
return Err(error);
}
let mut names = Vec::new();
let enumeration_result = loop {
unix_set_errno(0);
let entry = unsafe { libc::readdir(stream) };
if entry.is_null() {
let errno = unix_errno();
break if errno == 0 {
Ok(())
} else {
Err(io::Error::from_raw_os_error(errno))
};
}
let bytes = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
if bytes != b"." && bytes != b".." {
names.push(std::ffi::OsString::from_vec(bytes.to_vec()));
}
};
let close_result = unsafe { libc::closedir(stream) };
enumeration_result?;
if close_result < 0 {
return Err(io::Error::last_os_error());
}
names.sort();
for child in names {
let name = std::ffi::CString::new(OsStr::new(&child).as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "tree entry contains NUL"))?;
match unix_entry_kind(directory.as_raw_fd(), &name)? {
UnixEntryKind::Directory => {
let child_directory = unix_open_directory_at(directory, &name)?;
let identity = unix_file_identity(&child_directory)?;
if visited.insert(identity) {
report.directories_hardened += 1;
unix_harden_directory_contents(&child_directory, visited, report)?;
}
unix_validate_path_matches_directory(
directory.as_raw_fd(),
&name,
&child_directory,
)?;
}
UnixEntryKind::RegularFile => {
let file = unix_open_file_at(directory, &name, PrivateOpenMode::Read)?;
unix_validate_path_matches_file(directory.as_raw_fd(), &name, &file)?;
if visited.insert(unix_file_identity(&file)?) {
report.files_hardened += 1;
}
unix_validate_path_matches_file(directory.as_raw_fd(), &name, &file)?;
}
UnixEntryKind::Rejected => {
return Err(permission_denied(
"private tree contains a symlink or special file",
));
}
}
}
Ok(())
}
#[cfg(unix)]
fn unix_open_directory_at(parent: &File, name: &std::ffi::CStr) -> io::Result<File> {
use std::os::fd::{AsRawFd, FromRawFd};
let raw = unsafe {
libc::openat(
parent.as_raw_fd(),
name.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
)
};
if raw < 0 {
return Err(io::Error::last_os_error());
}
let directory = unsafe { File::from_raw_fd(raw) };
unix_validate_private_dir(&directory, true)?;
unix_validate_private_dir_exact(&directory)?;
unix_validate_path_matches_directory(parent.as_raw_fd(), name, &directory)?;
Ok(directory)
}
#[cfg(unix)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UnixEntryKind {
Directory,
RegularFile,
Rejected,
}
#[cfg(unix)]
fn unix_entry_kind(
parent_fd: std::os::fd::RawFd,
name: &std::ffi::CStr,
) -> io::Result<UnixEntryKind> {
use std::mem::MaybeUninit;
let mut stat = MaybeUninit::<libc::stat>::uninit();
let result = unsafe {
libc::fstatat(
parent_fd,
name.as_ptr(),
stat.as_mut_ptr(),
libc::AT_SYMLINK_NOFOLLOW,
)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
let mode = u64::from(unsafe { stat.assume_init() }.st_mode) & libc::S_IFMT as u64;
Ok(if mode == libc::S_IFDIR as u64 {
UnixEntryKind::Directory
} else if mode == libc::S_IFREG as u64 {
UnixEntryKind::RegularFile
} else {
UnixEntryKind::Rejected
})
}
#[cfg(all(unix, target_os = "linux"))]
fn unix_errno() -> i32 {
unsafe { *libc::__errno_location() }
}
#[cfg(all(unix, target_os = "linux"))]
fn unix_set_errno(value: i32) {
unsafe {
*libc::__errno_location() = value;
}
}
#[cfg(target_os = "android")]
fn unix_errno() -> i32 {
unsafe { *libc::__errno() }
}
#[cfg(target_os = "android")]
fn unix_set_errno(value: i32) {
unsafe {
*libc::__errno() = value;
}
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
fn unix_errno() -> i32 {
unsafe { *libc::__error() }
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
fn unix_set_errno(value: i32) {
unsafe {
*libc::__error() = value;
}
}
#[cfg(unix)]
fn unix_open_private(
path: &Path,
mode: PrivateOpenMode,
failures: Option<&PrivatePathDurabilityFailureInjector>,
) -> io::Result<File> {
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"private file path has no file name",
)
})?;
let name = CString::new(name.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file name contains NUL"))?;
let parent_path = normalized_parent(path);
let harden_parent = parent_path != Path::new(".");
let create_parent = matches!(mode, PrivateOpenMode::Append | PrivateOpenMode::CreateNew);
let parent = unix_walk_directory_with_failure_injector(
parent_path,
create_parent,
harden_parent,
failures,
)?;
if harden_parent {
unix_revalidate_directory_path(parent_path, &parent)?;
}
let (file, created) = unix_open_file_at_with_created(&parent, &name, mode)?;
unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)?;
if harden_parent {
unix_revalidate_directory_path(parent_path, &parent)?;
}
if mode == PrivateOpenMode::Truncate {
file.set_len(0)?;
unix_revalidate_private_file(&file)?;
unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)?;
if harden_parent {
unix_revalidate_directory_path(parent_path, &parent)?;
}
}
if matches!(mode, PrivateOpenMode::Append | PrivateOpenMode::CreateNew) {
if created {
if let Some(failures) = failures {
failures.check(PrivatePathDurabilityFailurePoint::ParentDirectorySync)?;
}
}
parent.sync_all()?;
unix_validate_path_matches_file(parent.as_raw_fd(), &name, &file)?;
if harden_parent {
unix_revalidate_directory_path(parent_path, &parent)?;
}
}
Ok(file)
}
#[cfg(unix)]
fn unix_open_file_at(
parent: &File,
name: &std::ffi::CStr,
mode: PrivateOpenMode,
) -> io::Result<File> {
unix_open_file_at_with_created(parent, name, mode).map(|(file, _created)| file)
}
#[cfg(unix)]
fn unix_open_file_at_with_created(
parent: &File,
name: &std::ffi::CStr,
mode: PrivateOpenMode,
) -> io::Result<(File, bool)> {
use std::os::fd::{AsRawFd, FromRawFd};
let base_flags = libc::O_CLOEXEC | libc::O_NOFOLLOW;
let (mut flags, mut created) = match mode {
PrivateOpenMode::Read => (base_flags | libc::O_RDONLY, false),
PrivateOpenMode::Append => (base_flags | libc::O_RDWR | libc::O_APPEND, false),
PrivateOpenMode::Truncate => (base_flags | libc::O_RDWR, false),
PrivateOpenMode::CreateNew => (
base_flags | libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
true,
),
};
let mut raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags, 0o600) };
if raw < 0
&& mode == PrivateOpenMode::Append
&& io::Error::last_os_error().kind() == io::ErrorKind::NotFound
{
flags |= libc::O_CREAT | libc::O_EXCL;
raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags, 0o600) };
if raw < 0 && io::Error::last_os_error().kind() == io::ErrorKind::AlreadyExists {
flags &= !(libc::O_CREAT | libc::O_EXCL);
raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags, 0o600) };
} else if raw >= 0 {
created = true;
}
}
if raw < 0 {
return Err(io::Error::last_os_error());
}
let file = unsafe { File::from_raw_fd(raw) };
unix_revalidate_private_file(&file)?;
Ok((file, created))
}
#[cfg(unix)]
fn unix_revalidate_private_file(file: &File) -> io::Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let before = file.metadata()?;
let euid = unsafe { libc::geteuid() };
if !before.is_file() || before.uid() != euid || before.nlink() != 1 {
return Err(permission_denied(
"private file must be a current-user, single-link regular file",
));
}
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
let after = file.metadata()?;
if !after.is_file()
|| after.uid() != euid
|| after.nlink() != 1
|| after.dev() != before.dev()
|| after.ino() != before.ino()
|| after.permissions().mode() & 0o777 != 0o600
{
return Err(permission_denied(
"private file changed while it was hardened",
));
}
Ok(())
}
#[cfg(unix)]
fn unix_validate_path_matches_file(
parent_fd: std::os::fd::RawFd,
name: &std::ffi::CStr,
file: &File,
) -> io::Result<()> {
use std::mem::MaybeUninit;
use std::os::unix::fs::MetadataExt;
let mut stat = MaybeUninit::<libc::stat>::uninit();
let result = unsafe {
libc::fstatat(
parent_fd,
name.as_ptr(),
stat.as_mut_ptr(),
libc::AT_SYMLINK_NOFOLLOW,
)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
let stat = unsafe { stat.assume_init() };
let metadata = file.metadata()?;
if i128::from(stat.st_dev) != i128::from(metadata.dev())
|| stat.st_ino != metadata.ino()
|| (u64::from(stat.st_mode) & libc::S_IFMT as u64) != libc::S_IFREG as u64
{
return Err(permission_denied(
"private file path changed during descriptor validation",
));
}
Ok(())
}
#[cfg(unix)]
fn unix_atomic_replace_private_file(temp: &Path, destination: &Path) -> io::Result<()> {
unix_atomic_replace_private_file_with_hook(temp, destination, || Ok(()))
}
#[cfg(unix)]
fn unix_atomic_replace_private_file_with_hook<F>(
temp: &Path,
destination: &Path,
before_rename: F,
) -> io::Result<()>
where
F: FnOnce() -> io::Result<()>,
{
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
let parent_path = normalized_parent(destination);
let parent = unix_walk_directory(parent_path, false, parent_path != Path::new("."))?;
let revalidate_parent = parent_path != Path::new(".");
if revalidate_parent {
unix_revalidate_directory_path(parent_path, &parent)?;
}
let temp_name = CString::new(
temp.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "temp has no name"))?
.as_bytes(),
)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "temp name contains NUL"))?;
let destination_name = CString::new(
destination
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "destination has no name"))?
.as_bytes(),
)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "destination name contains NUL"))?;
let temp_file = unix_open_file_at(&parent, &temp_name, PrivateOpenMode::Read)?;
unix_validate_path_matches_file(parent.as_raw_fd(), &temp_name, &temp_file)?;
let temp_identity = unix_file_identity(&temp_file)?;
temp_file.sync_all()?;
before_rename()?;
unix_revalidate_private_file(&temp_file)?;
unix_validate_path_matches_file(parent.as_raw_fd(), &temp_name, &temp_file)?;
if revalidate_parent {
unix_revalidate_directory_path(parent_path, &parent)?;
}
let result = unsafe {
libc::renameat(
parent.as_raw_fd(),
temp_name.as_ptr(),
parent.as_raw_fd(),
destination_name.as_ptr(),
)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
let published = unix_open_file_at(&parent, &destination_name, PrivateOpenMode::Read)?;
unix_validate_path_matches_file(parent.as_raw_fd(), &destination_name, &published)?;
if unix_file_identity(&published)? != temp_identity {
return Err(permission_denied(
"published private file does not match the validated temp descriptor",
));
}
unix_revalidate_private_file(&temp_file)?;
parent.sync_all()?;
unix_revalidate_private_file(&published)?;
unix_validate_path_matches_file(parent.as_raw_fd(), &destination_name, &published)?;
if revalidate_parent {
unix_revalidate_directory_path(parent_path, &parent)?;
}
Ok(())
}
#[cfg(unix)]
fn unix_file_identity(file: &File) -> io::Result<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
let metadata = file.metadata()?;
Ok((metadata.dev(), metadata.ino()))
}
#[cfg(target_os = "windows")]
const FILE_ATTRIBUTE_REPARSE_POINT_VALUE: u32 = 0x0000_0400;
#[cfg(target_os = "windows")]
struct WindowsPrivateSecurityDescriptor {
descriptor: windows::Win32::Security::PSECURITY_DESCRIPTOR,
}
#[cfg(target_os = "windows")]
impl WindowsPrivateSecurityDescriptor {
fn new() -> io::Result<Self> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows::Win32::Security::PSECURITY_DESCRIPTOR;
let owner = current_process_default_owner_sid_string()?;
let user = current_process_sid_string()?;
let sddl = format!("O:{owner}D:P(A;;FA;;;{user})");
let mut wide = std::ffi::OsStr::new(&sddl)
.encode_wide()
.collect::<Vec<_>>();
wide.push(0);
let mut descriptor = PSECURITY_DESCRIPTOR::default();
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
PCWSTR(wide.as_ptr()),
SDDL_REVISION_1,
&mut descriptor,
None,
)
}
.map_err(windows_io_error)?;
if descriptor.0.is_null() {
return Err(io::Error::other(
"Windows returned a null private security descriptor",
));
}
Ok(Self { descriptor })
}
fn security_attributes(&self) -> windows::Win32::Security::SECURITY_ATTRIBUTES {
use windows::Win32::Foundation::BOOL;
use windows::Win32::Security::SECURITY_ATTRIBUTES;
SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: self.descriptor.0,
bInheritHandle: BOOL(0),
}
}
fn dacl(&self) -> io::Result<*const windows::Win32::Security::ACL> {
use windows::Win32::Foundation::BOOL;
use windows::Win32::Security::{GetSecurityDescriptorDacl, ACL};
let mut present = BOOL(0);
let mut defaulted = BOOL(0);
let mut dacl = std::ptr::null_mut::<ACL>();
unsafe {
GetSecurityDescriptorDacl(self.descriptor, &mut present, &mut dacl, &mut defaulted)
}
.map_err(windows_io_error)?;
if !present.as_bool() || dacl.is_null() {
return Err(io::Error::other(
"private Windows security descriptor has no DACL",
));
}
Ok(dacl)
}
}
#[cfg(target_os = "windows")]
impl Drop for WindowsPrivateSecurityDescriptor {
fn drop(&mut self) {
use windows::Win32::Foundation::{LocalFree, HLOCAL};
unsafe {
let _ = LocalFree(HLOCAL(self.descriptor.0));
}
}
}
#[cfg(target_os = "windows")]
fn windows_io_error(error: windows::core::Error) -> io::Error {
use windows::Win32::Foundation::WIN32_ERROR;
WIN32_ERROR::from_error(&error)
.map(|code| io::Error::from_raw_os_error(code.0 as i32))
.unwrap_or_else(|| io::Error::other(error.to_string()))
}
#[cfg(target_os = "windows")]
fn windows_error_stage<T>(stage: &'static str, result: io::Result<T>) -> io::Result<T> {
result.map_err(|error| io::Error::new(error.kind(), format!("{stage}: {error}")))
}
#[cfg(target_os = "windows")]
fn windows_path_wide(path: &Path) -> io::Result<Vec<u16>> {
use std::os::windows::ffi::OsStrExt;
let mut wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
if wide.contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Windows private path contains NUL",
));
}
wide.push(0);
Ok(wide)
}
#[cfg(target_os = "windows")]
fn windows_harden_file_acl(file: &File) -> io::Result<()> {
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::{CloseHandle, ERROR_SUCCESS, HANDLE, PSID};
use windows::Win32::Security::Authorization::{SetSecurityInfo, SE_FILE_OBJECT};
use windows::Win32::Security::{
GetTokenInformation, TokenOwner, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION,
PROTECTED_DACL_SECURITY_INFORMATION, TOKEN_OWNER, TOKEN_QUERY,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
windows_validate_hardenable_file_owner(file)?;
let descriptor = WindowsPrivateSecurityDescriptor::new()?;
let windows_error = |error: windows::core::Error| io::Error::other(error.to_string());
let mut token = HANDLE::default();
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
.map_err(windows_error)?;
let result = (|| {
let mut needed = 0u32;
let _ = unsafe { GetTokenInformation(token, TokenOwner, None, 0, &mut needed) };
if needed == 0 {
return Err(io::Error::last_os_error());
}
let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
let mut buffer = vec![0usize; words];
unsafe {
GetTokenInformation(
token,
TokenOwner,
Some(buffer.as_mut_ptr().cast()),
needed,
&mut needed,
)
}
.map_err(windows_error)?;
let owner = unsafe { &*buffer.as_ptr().cast::<TOKEN_OWNER>() };
let status = unsafe {
SetSecurityInfo(
HANDLE(file.as_raw_handle() as isize),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION
| OWNER_SECURITY_INFORMATION
| PROTECTED_DACL_SECURITY_INFORMATION,
owner.Owner,
PSID::default(),
Some(descriptor.dacl()?),
None,
)
};
if status == ERROR_SUCCESS {
Ok(())
} else {
Err(io::Error::from_raw_os_error(status.0 as i32))
}
})();
let _ = unsafe { CloseHandle(token) };
result?;
windows_validate_file_owner(file)?;
windows_validate_exact_private_acl(file)
}
#[cfg(target_os = "windows")]
fn windows_validate_exact_private_acl(file: &File) -> io::Result<()> {
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::{LocalFree, HANDLE, HLOCAL, PSID};
use windows::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
use windows::Win32::Security::{
GetAce, GetSecurityDescriptorControl, ACCESS_ALLOWED_ACE, ACL, DACL_SECURITY_INFORMATION,
PSECURITY_DESCRIPTOR, SE_DACL_PROTECTED,
};
use windows::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
const ACCESS_ALLOWED_ACE_TYPE_VALUE: u8 = 0;
let mut dacl = std::ptr::null_mut::<ACL>();
let mut descriptor = PSECURITY_DESCRIPTOR::default();
let status = unsafe {
GetSecurityInfo(
HANDLE(file.as_raw_handle() as isize),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
None,
None,
Some(&mut dacl),
None,
Some(&mut descriptor),
)
};
if !status.is_ok() {
return Err(io::Error::from_raw_os_error(status.0 as i32));
}
let result = (|| {
if dacl.is_null() {
return Err(permission_denied("private Windows ACL is absent"));
}
let mut control = 0u16;
let mut revision = 0u32;
unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) }
.map_err(windows_io_error)?;
if control & SE_DACL_PROTECTED.0 == 0 || unsafe { (*dacl).AceCount } != 1 {
return Err(permission_denied(
"private Windows ACL is not one protected owner ACE",
));
}
let mut raw_ace = std::ptr::null_mut();
unsafe { GetAce(dacl, 0, &mut raw_ace) }.map_err(windows_io_error)?;
let ace = unsafe { &*raw_ace.cast::<ACCESS_ALLOWED_ACE>() };
if ace.Header.AceType != ACCESS_ALLOWED_ACE_TYPE_VALUE || ace.Mask != FILE_ALL_ACCESS.0 {
return Err(permission_denied(
"private Windows ACL does not grant exact owner full control",
));
}
let sid = PSID(std::ptr::addr_of!(ace.SidStart).cast_mut().cast());
if windows_sid_string(sid)? != current_process_sid_string()? {
return Err(permission_denied(
"private Windows ACL is granted to another identity",
));
}
Ok(())
})();
unsafe {
let _ = LocalFree(HLOCAL(descriptor.0));
}
result
}
#[cfg(target_os = "windows")]
fn windows_create_private_directory(path: &Path) -> io::Result<()> {
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::CreateDirectoryW;
let descriptor = WindowsPrivateSecurityDescriptor::new()?;
let attributes = descriptor.security_attributes();
let wide = windows_path_wide(path)?;
unsafe { CreateDirectoryW(PCWSTR(wide.as_ptr()), Some(&attributes)) }.map_err(windows_io_error)
}
#[cfg(target_os = "windows")]
fn reject_windows_reparse_metadata(metadata: &std::fs::Metadata) -> io::Result<()> {
use std::os::windows::fs::MetadataExt;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT_VALUE != 0 {
Err(permission_denied(
"owner-private path cannot be a reparse point",
))
} else {
Ok(())
}
}
#[cfg(target_os = "windows")]
fn windows_ensure_private_dir(path: &Path) -> io::Result<()> {
windows_ensure_private_dir_with_failure_injector(path, None)
}
#[cfg(target_os = "windows")]
fn windows_ensure_private_dir_with_failure_injector(
path: &Path,
failures: Option<&PrivatePathDurabilityFailureInjector>,
) -> io::Result<()> {
windows_ensure_private_dir_with_hook(path, failures, |_| Ok(()))
}
#[cfg(target_os = "windows")]
fn windows_ensure_private_dir_with_hook<F>(
path: &Path,
failures: Option<&PrivatePathDurabilityFailureInjector>,
mut after_validated_child: F,
) -> io::Result<()>
where
F: FnMut(&Path) -> io::Result<()>,
{
let mut current = std::path::PathBuf::new();
let mut saw_directory = false;
let mut hierarchy: Vec<(PathBuf, File)> = Vec::new();
for component in path.components() {
current.push(component.as_os_str());
if !matches!(component, std::path::Component::Normal(_)) {
if matches!(component, std::path::Component::ParentDir) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"owner-private paths cannot contain parent traversal",
));
}
continue;
}
saw_directory = true;
match std::fs::symlink_metadata(¤t) {
Ok(metadata) => {
reject_windows_reparse_metadata(&metadata)?;
if !metadata.is_dir() {
return Err(permission_denied(
"private path component is not a directory",
));
}
let directory = if current == path {
windows_open_directory_for_hardening(¤t)?
} else {
windows_open_directory(¤t)?
};
windows_validate_directory(&directory)?;
if current == path {
windows_validate_hardenable_file_owner(&directory)?;
windows_harden_file_acl(&directory)?;
windows_revalidate_directory_path(¤t, &directory)?;
}
if windows_validate_file_owner(&directory).is_ok() {
let parent_path = normalized_parent(¤t);
let parent_probe = windows_open_directory(parent_path)?;
match windows_validate_file_owner(&parent_probe) {
Ok(()) => {
let parent = windows_open_directory_for_durability(parent_path)?;
windows_flush_directory_metadata(&parent)?;
windows_revalidate_directory_path(parent_path, &parent)?;
windows_revalidate_directory_path(¤t, &directory)?;
}
Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {}
Err(error) => return Err(error),
}
}
after_validated_child(¤t)?;
let retained_access = if current == path {
windows::Win32::Storage::FileSystem::FILE_GENERIC_READ.0
| windows::Win32::Storage::FileSystem::WRITE_DAC.0
| windows::Win32::Storage::FileSystem::WRITE_OWNER.0
} else {
windows::Win32::Storage::FileSystem::FILE_GENERIC_READ.0
};
let retained = windows_open_directory_with_access_and_share(
¤t,
retained_access,
windows::Win32::Storage::FileSystem::FILE_SHARE_READ
| windows::Win32::Storage::FileSystem::FILE_SHARE_WRITE,
)?;
windows_validate_directory(&retained)?;
if windows_file_identity(&directory)? != windows_file_identity(&retained)? {
return Err(permission_denied(
"validated private directory changed before retention",
));
}
if current == path {
windows_harden_file_acl(&retained)?;
windows_revalidate_directory_path(¤t, &retained)?;
} else {
windows_revalidate_directory_path_identity(¤t, &retained)?;
}
hierarchy.push((current.clone(), retained));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let parent_path = normalized_parent(¤t);
let parent = windows_open_directory_for_durability(parent_path)?;
windows_validate_directory(&parent)?;
if let Some((retained_path, retained)) = hierarchy.last() {
if retained_path == parent_path
&& windows_file_identity(retained)? != windows_file_identity(&parent)?
{
return Err(permission_denied(
"private directory parent changed before creation",
));
}
}
if let Err(create_error) = windows_create_private_directory(¤t) {
if create_error.kind() != io::ErrorKind::AlreadyExists {
return Err(create_error);
}
}
let directory = windows_open_directory_for_hardening(¤t)?;
windows_validate_directory(&directory)?;
windows_validate_hardenable_file_owner(&directory)?;
windows_harden_file_acl(&directory)?;
windows_revalidate_directory_path(¤t, &directory)?;
after_validated_child(¤t)?;
let retained = windows_open_directory_with_access_and_share(
¤t,
windows::Win32::Storage::FileSystem::FILE_GENERIC_READ.0
| windows::Win32::Storage::FileSystem::WRITE_DAC.0
| windows::Win32::Storage::FileSystem::WRITE_OWNER.0,
windows::Win32::Storage::FileSystem::FILE_SHARE_READ
| windows::Win32::Storage::FileSystem::FILE_SHARE_WRITE,
)?;
windows_validate_directory(&retained)?;
if windows_file_identity(&directory)? != windows_file_identity(&retained)? {
return Err(permission_denied(
"created private directory changed before retention",
));
}
windows_harden_file_acl(&retained)?;
windows_revalidate_directory_path(¤t, &retained)?;
if let Some(failures) = failures {
failures.check(PrivatePathDurabilityFailurePoint::ParentDirectorySync)?;
}
windows_flush_directory_metadata(&parent)?;
windows_revalidate_directory_path(parent_path, &parent)?;
windows_revalidate_directory_path(¤t, &retained)?;
windows_validate_exact_private_acl(&retained)?;
hierarchy.push((current.clone(), retained));
}
Err(error) => return Err(error),
}
}
if !saw_directory {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"refusing to harden a filesystem root or current directory",
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn windows_flush_directory_metadata(directory: &File) -> io::Result<()> {
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::{ERROR_ACCESS_DENIED, HANDLE, WIN32_ERROR};
use windows::Win32::Storage::FileSystem::FlushFileBuffers;
match unsafe { FlushFileBuffers(HANDLE(directory.as_raw_handle() as isize)) } {
Ok(()) => Ok(()),
Err(error) if WIN32_ERROR::from_error(&error) == Some(ERROR_ACCESS_DENIED) => Ok(()),
Err(error) => Err(windows_io_error(error)),
}
}
#[cfg(target_os = "windows")]
fn windows_validate_existing_directory_chain(path: &Path) -> io::Result<()> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
if !matches!(component, std::path::Component::Normal(_)) {
if matches!(component, std::path::Component::ParentDir) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"owner-private paths cannot contain parent traversal",
));
}
continue;
}
let metadata = std::fs::symlink_metadata(¤t)?;
reject_windows_reparse_metadata(&metadata)?;
if !metadata.is_dir() {
return Err(permission_denied(
"private path component is not a directory",
));
}
let directory = windows_open_directory(¤t)?;
windows_validate_directory(&directory)?;
}
Ok(())
}
#[cfg(target_os = "windows")]
fn windows_open_directory(path: &Path) -> io::Result<File> {
use windows::Win32::Storage::FileSystem::FILE_GENERIC_READ;
windows_open_directory_with_access(path, FILE_GENERIC_READ.0)
}
#[cfg(target_os = "windows")]
fn windows_open_directory_for_hardening(path: &Path) -> io::Result<File> {
use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, WRITE_DAC, WRITE_OWNER};
windows_open_directory_with_access(path, FILE_GENERIC_READ.0 | WRITE_DAC.0 | WRITE_OWNER.0)
}
#[cfg(target_os = "windows")]
fn windows_open_directory_for_durability(path: &Path) -> io::Result<File> {
use windows::Win32::Storage::FileSystem::{
FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ, FILE_SHARE_WRITE, WRITE_DAC,
};
windows_open_directory_with_access_and_share(
path,
FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0 | WRITE_DAC.0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
)
}
#[cfg(target_os = "windows")]
fn windows_open_directory_with_access(path: &Path, desired_access: u32) -> io::Result<File> {
use windows::Win32::Storage::FileSystem::{
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
};
windows_open_directory_with_access_and_share(
path,
desired_access,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
)
}
#[cfg(target_os = "windows")]
fn windows_open_directory_with_access_and_share(
path: &Path,
desired_access: u32,
share: windows::Win32::Storage::FileSystem::FILE_SHARE_MODE,
) -> io::Result<File> {
use std::os::windows::io::FromRawHandle;
use windows::core::PCWSTR;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, OPEN_EXISTING,
};
let wide = windows_path_wide(path)?;
let handle = unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
desired_access,
share,
None,
OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
HANDLE::default(),
)
}
.map_err(windows_io_error)?;
Ok(unsafe { File::from_raw_handle(handle.0 as *mut std::ffi::c_void) })
}
#[cfg(target_os = "windows")]
fn windows_open_existing_file_for_hardening(path: &Path) -> io::Result<File> {
use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, WRITE_DAC, WRITE_OWNER};
windows_open_existing_file_with_access(path, FILE_GENERIC_READ.0 | WRITE_DAC.0 | WRITE_OWNER.0)
}
#[cfg(target_os = "windows")]
fn windows_open_existing_file_with_access(path: &Path, desired_access: u32) -> io::Result<File> {
use std::os::windows::io::FromRawHandle;
use windows::core::PCWSTR;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
let wide = windows_path_wide(path)?;
let handle = unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
desired_access,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
None,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
HANDLE::default(),
)
}
.map_err(windows_io_error)?;
Ok(unsafe { File::from_raw_handle(handle.0 as *mut std::ffi::c_void) })
}
#[cfg(target_os = "windows")]
fn windows_validate_directory(directory: &File) -> io::Result<()> {
let metadata = directory.metadata()?;
reject_windows_reparse_metadata(&metadata)?;
if metadata.is_dir() {
Ok(())
} else {
Err(permission_denied("private path is not a directory"))
}
}
#[cfg(target_os = "windows")]
fn windows_validate_file_owner(file: &File) -> io::Result<()> {
let owner = windows_file_owner_sid_string(file)?;
let process_owner = current_process_default_owner_sid_string()?;
if owner == process_owner {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("private path owner {owner} does not match token owner {process_owner}"),
))
}
}
#[cfg(target_os = "windows")]
fn windows_validate_hardenable_file_owner(file: &File) -> io::Result<()> {
let owner = windows_file_owner_sid_string(file)?;
let process_user = current_process_sid_string()?;
let default_owner = current_process_default_owner_sid_string()?;
if owner == process_user || owner == default_owner {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"private path owner {owner} is neither process user {process_user} nor token default owner {default_owner}"
),
))
}
}
#[cfg(target_os = "windows")]
fn windows_file_owner_sid_string(file: &File) -> io::Result<String> {
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::{LocalFree, HANDLE, HLOCAL, PSID};
use windows::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
use windows::Win32::Security::{OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR};
let mut owner = PSID::default();
let mut descriptor = PSECURITY_DESCRIPTOR::default();
let status = unsafe {
GetSecurityInfo(
HANDLE(file.as_raw_handle() as isize),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
Some(&mut descriptor),
)
};
if status.is_err() {
return Err(io::Error::other(status.to_hresult().message()));
}
let result = windows_sid_string(owner);
unsafe {
let _ = LocalFree(HLOCAL(descriptor.0));
}
result
}
#[cfg(target_os = "windows")]
fn windows_sid_string(sid: windows::Win32::Foundation::PSID) -> io::Result<String> {
use windows::core::PWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::ConvertSidToStringSidW;
let mut sid_text = PWSTR::null();
unsafe { ConvertSidToStringSidW(sid, &mut sid_text) }
.map_err(|error| io::Error::other(error.to_string()))?;
let result = unsafe { sid_text.to_string() }
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error));
unsafe {
let _ = LocalFree(HLOCAL(sid_text.0.cast()));
}
result
}
#[cfg(target_os = "windows")]
fn windows_revalidate_directory_path(path: &Path, directory: &File) -> io::Result<()> {
windows_validate_directory(directory)?;
windows_validate_file_owner(directory)?;
let by_path = windows_open_directory(path)?;
windows_validate_directory(&by_path)?;
windows_validate_file_owner(&by_path)?;
if windows_file_identity(directory)? != windows_file_identity(&by_path)? {
return Err(permission_denied(
"private directory path changed during ACL hardening",
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn windows_revalidate_directory_path_identity(path: &Path, directory: &File) -> io::Result<()> {
windows_validate_directory(directory)?;
let by_path = windows_open_directory(path)?;
windows_validate_directory(&by_path)?;
if windows_file_identity(directory)? != windows_file_identity(&by_path)? {
return Err(permission_denied(
"directory path changed before its retained handle was validated",
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn windows_open_private(
path: &Path,
mode: PrivateOpenMode,
failures: Option<&PrivatePathDurabilityFailureInjector>,
) -> io::Result<File> {
use std::os::windows::io::FromRawHandle;
use windows::core::PCWSTR;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
CreateFileW, CREATE_NEW, FILE_APPEND_DATA, FILE_ATTRIBUTE_NORMAL,
FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_DELETE,
FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, WRITE_DAC, WRITE_OWNER,
};
let parent = normalized_parent(path);
if matches!(mode, PrivateOpenMode::Append | PrivateOpenMode::CreateNew) {
if parent != Path::new(".") {
windows_ensure_private_dir_with_failure_injector(parent, failures)?;
}
} else if parent != Path::new(".") {
windows_validate_existing_directory_chain(parent)?;
}
let durability_parent = if matches!(mode, PrivateOpenMode::Append | PrivateOpenMode::CreateNew)
{
let descriptor = windows_open_directory_for_durability(parent)?;
windows_validate_directory(&descriptor)?;
Some(descriptor)
} else {
None
};
let (desired_access, disposition) = match mode {
PrivateOpenMode::Read => (
FILE_GENERIC_READ.0 | WRITE_DAC.0 | WRITE_OWNER.0,
OPEN_EXISTING,
),
PrivateOpenMode::Append => (
FILE_GENERIC_READ.0 | FILE_APPEND_DATA.0 | WRITE_DAC.0 | WRITE_OWNER.0,
OPEN_EXISTING,
),
PrivateOpenMode::Truncate => (
FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0 | WRITE_DAC.0 | WRITE_OWNER.0,
OPEN_EXISTING,
),
PrivateOpenMode::CreateNew => (
FILE_GENERIC_WRITE.0 | WRITE_DAC.0 | WRITE_OWNER.0,
CREATE_NEW,
),
};
let descriptor = WindowsPrivateSecurityDescriptor::new()?;
let attributes = descriptor.security_attributes();
let wide = windows_path_wide(path)?;
let open = |disposition| {
unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
desired_access,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
Some(&attributes),
disposition,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
HANDLE::default(),
)
}
.map_err(windows_io_error)
};
let mut created = mode == PrivateOpenMode::CreateNew;
let handle = match open(disposition) {
Ok(handle) => handle,
Err(error)
if mode == PrivateOpenMode::Append && error.kind() == io::ErrorKind::NotFound =>
{
match open(CREATE_NEW) {
Ok(handle) => {
created = true;
handle
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => open(OPEN_EXISTING)?,
Err(error) => return Err(error),
}
}
Err(error) => return Err(error),
};
let file = unsafe { File::from_raw_handle(handle.0 as *mut std::ffi::c_void) };
windows_validate_file(&file)?;
windows_validate_hardenable_file_owner(&file)?;
windows_harden_file_acl(&file)?;
windows_revalidate_private_path(path, &file)?;
if mode == PrivateOpenMode::Truncate {
if parent != Path::new(".") {
windows_validate_existing_directory_chain(parent)?;
}
file.set_len(0)?;
windows_validate_file(&file)?;
windows_validate_file_owner(&file)?;
windows_revalidate_private_path(path, &file)?;
}
if let Some(parent_descriptor) = durability_parent.as_ref() {
if created {
if let Some(failures) = failures {
failures.check(PrivatePathDurabilityFailurePoint::ParentDirectorySync)?;
}
}
windows_flush_directory_metadata(parent_descriptor)?;
windows_revalidate_directory_path(parent, parent_descriptor)?;
windows_revalidate_private_path(path, &file)?;
}
Ok(file)
}
#[cfg(target_os = "windows")]
fn windows_validate_file(file: &File) -> io::Result<()> {
let metadata = file.metadata()?;
reject_windows_reparse_metadata(&metadata)?;
if !metadata.is_file() {
return Err(permission_denied("private file is not a regular file"));
}
if windows_file_link_count(file)? != 1 {
return Err(permission_denied(
"private file must have exactly one hard link",
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn windows_revalidate_private_path(path: &Path, file: &File) -> io::Result<()> {
use std::os::windows::fs::OpenOptionsExt;
let by_path = std::fs::OpenOptions::new()
.read(true)
.custom_flags(0x0020_0000) .open(path)?;
windows_validate_file(&by_path)?;
windows_validate_file_owner(&by_path)?;
if windows_file_identity(file)? != windows_file_identity(&by_path)? {
return Err(permission_denied(
"private file path no longer names the validated descriptor",
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn windows_harden_private_tree(
tree: &PrivateTree,
selected: &[PathBuf],
) -> io::Result<PrivateTreeReport> {
use std::collections::BTreeSet;
let mut report = PrivateTreeReport::default();
if selected.is_empty() {
windows_harden_file_acl(&tree.root_descriptor)?;
tree.revalidate_root()?;
report.directories_hardened = 1;
return Ok(report);
}
let mut visited = BTreeSet::new();
visited.insert(windows_file_identity(&tree.root_descriptor)?);
for relative in selected {
tree.revalidate_root()?;
windows_harden_selected_path(tree, relative, &mut visited, &mut report)?;
tree.revalidate_root()?;
}
Ok(report)
}
#[cfg(target_os = "windows")]
fn windows_harden_selected_path(
tree: &PrivateTree,
relative: &Path,
visited: &mut std::collections::BTreeSet<(u32, u64)>,
report: &mut PrivateTreeReport,
) -> io::Result<()> {
let components = relative
.components()
.map(|component| component.as_os_str().to_os_string())
.collect::<Vec<_>>();
windows_harden_selected_components(
tree,
&tree.root_descriptor,
&tree.root,
&components,
visited,
report,
)
}
#[cfg(target_os = "windows")]
fn windows_harden_selected_components(
tree: &PrivateTree,
parent: &File,
parent_path: &Path,
components: &[std::ffi::OsString],
visited: &mut std::collections::BTreeSet<(u32, u64)>,
report: &mut PrivateTreeReport,
) -> io::Result<()> {
let (component, remaining) = components.split_first().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"private-tree selection has no components",
)
})?;
let path = parent_path.join(component);
let entry = windows_open_entry_at_for_hardening(parent, component)?;
if remaining.is_empty() {
return windows_harden_tree_entry(tree, parent, component, &path, entry, visited, report);
}
windows_validate_directory(&entry)?;
windows_validate_hardenable_file_owner(&entry)?;
windows_harden_file_acl(&entry)?;
windows_revalidate_directory_path(&path, &entry)?;
windows_revalidate_entry_at(parent, component, &entry)?;
if visited.insert(windows_file_identity(&entry)?) {
report.directories_hardened += 1;
}
windows_harden_selected_components(tree, &entry, &path, remaining, visited, report)?;
windows_revalidate_entry_at(parent, component, &entry)
}
#[cfg(target_os = "windows")]
fn windows_harden_tree_entry(
tree: &PrivateTree,
parent: &File,
name: &std::ffi::OsStr,
path: &Path,
entry: File,
visited: &mut std::collections::BTreeSet<(u32, u64)>,
report: &mut PrivateTreeReport,
) -> io::Result<()> {
let metadata = entry.metadata()?;
reject_windows_reparse_metadata(&metadata)?;
windows_validate_hardenable_file_owner(&entry)?;
if metadata.is_dir() {
windows_validate_directory(&entry)?;
windows_harden_file_acl(&entry)?;
windows_revalidate_directory_path(path, &entry)?;
windows_revalidate_entry_at(parent, name, &entry)?;
if visited.insert(windows_file_identity(&entry)?) {
report.directories_hardened += 1;
for child in windows_directory_names(&entry)? {
tree.revalidate_root()?;
let child_path = path.join(&child);
let child_entry = windows_open_entry_at_for_hardening(&entry, &child)?;
windows_harden_tree_entry(
tree,
&entry,
&child,
&child_path,
child_entry,
visited,
report,
)?;
}
}
windows_revalidate_entry_at(parent, name, &entry)?;
} else if metadata.is_file() {
windows_validate_file(&entry)?;
windows_harden_file_acl(&entry)?;
windows_revalidate_private_path(path, &entry)?;
windows_revalidate_entry_at(parent, name, &entry)?;
if visited.insert(windows_file_identity(&entry)?) {
report.files_hardened += 1;
}
windows_revalidate_entry_at(parent, name, &entry)?;
} else {
return Err(permission_denied(
"private tree contains a reparse point or special file",
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn windows_revalidate_entry_at(
parent: &File,
name: &std::ffi::OsStr,
entry: &File,
) -> io::Result<()> {
let by_name = windows_open_entry_at(parent, name)?;
let metadata = by_name.metadata()?;
reject_windows_reparse_metadata(&metadata)?;
windows_validate_file_owner(&by_name)?;
if metadata.is_file() {
windows_validate_file(&by_name)?;
} else if metadata.is_dir() {
windows_validate_directory(&by_name)?;
} else {
return Err(permission_denied(
"private tree contains a reparse point or special file",
));
}
if windows_file_identity(entry)? != windows_file_identity(&by_name)? {
return Err(permission_denied(
"private tree entry changed during descriptor validation",
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn windows_open_entry_at(parent: &File, name: &std::ffi::OsStr) -> io::Result<File> {
use windows::Win32::Storage::FileSystem::FILE_GENERIC_READ;
windows_open_entry_at_with_access(parent, name, FILE_GENERIC_READ)
}
#[cfg(target_os = "windows")]
fn windows_open_entry_at_for_hardening(parent: &File, name: &std::ffi::OsStr) -> io::Result<File> {
use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, WRITE_DAC, WRITE_OWNER};
windows_open_entry_at_with_access(parent, name, FILE_GENERIC_READ | WRITE_DAC | WRITE_OWNER)
}
#[cfg(target_os = "windows")]
fn windows_open_entry_at_with_access(
parent: &File,
name: &std::ffi::OsStr,
desired_access: windows::Win32::Storage::FileSystem::FILE_ACCESS_RIGHTS,
) -> io::Result<File> {
use windows::Win32::Storage::FileSystem::{
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
};
windows_open_entry_at_with_access_and_share(
parent,
name,
desired_access,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
)
}
#[cfg(target_os = "windows")]
fn windows_open_entry_at_with_access_and_share(
parent: &File,
name: &std::ffi::OsStr,
desired_access: windows::Win32::Storage::FileSystem::FILE_ACCESS_RIGHTS,
share: windows::Win32::Storage::FileSystem::FILE_SHARE_MODE,
) -> io::Result<File> {
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle};
use windows::core::PWSTR;
use windows::Wdk::Foundation::OBJECT_ATTRIBUTES;
use windows::Wdk::Storage::FileSystem::{
NtCreateFile, FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT,
};
use windows::Win32::Foundation::{RtlNtStatusToDosError, HANDLE, UNICODE_STRING};
use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL;
use windows::Win32::System::IO::IO_STATUS_BLOCK;
let mut wide = name.encode_wide().collect::<Vec<_>>();
let byte_length = wide
.len()
.checked_mul(std::mem::size_of::<u16>())
.and_then(|length| u16::try_from(length).ok())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "tree name is too long"))?;
let unicode = UNICODE_STRING {
Length: byte_length,
MaximumLength: byte_length,
Buffer: PWSTR(wide.as_mut_ptr()),
};
let attributes = OBJECT_ATTRIBUTES {
Length: std::mem::size_of::<OBJECT_ATTRIBUTES>() as u32,
RootDirectory: HANDLE(parent.as_raw_handle() as isize),
ObjectName: &unicode,
Attributes: 0x40, SecurityDescriptor: std::ptr::null(),
SecurityQualityOfService: std::ptr::null(),
};
let mut handle = HANDLE::default();
let mut status_block = IO_STATUS_BLOCK::default();
let status = unsafe {
NtCreateFile(
&mut handle,
desired_access,
&attributes,
&mut status_block,
None,
FILE_ATTRIBUTE_NORMAL,
share,
FILE_OPEN,
FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT,
None,
0,
)
};
if status.is_err() {
let win32_error = unsafe { RtlNtStatusToDosError(status) };
return Err(io::Error::from_raw_os_error(win32_error as i32));
}
Ok(unsafe { File::from_raw_handle(handle.0 as *mut std::ffi::c_void) })
}
#[cfg(target_os = "windows")]
fn windows_directory_names(directory: &File) -> io::Result<Vec<std::ffi::OsString>> {
use std::os::windows::ffi::OsStringExt;
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::{ERROR_NO_MORE_FILES, HANDLE, WIN32_ERROR};
use windows::Win32::Storage::FileSystem::{
FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, GetFileInformationByHandleEx,
FILE_ID_BOTH_DIR_INFO,
};
let scan = directory.try_clone()?;
windows_validate_directory(&scan)?;
let mut buffer = vec![0u64; 8192];
let mut restart = true;
let mut names = Vec::new();
loop {
let class = if restart {
FileIdBothDirectoryRestartInfo
} else {
FileIdBothDirectoryInfo
};
restart = false;
let result = unsafe {
GetFileInformationByHandleEx(
HANDLE(scan.as_raw_handle() as isize),
class,
buffer.as_mut_ptr().cast(),
(buffer.len() * std::mem::size_of::<u64>()) as u32,
)
};
if let Err(error) = result {
if WIN32_ERROR::from_error(&error) == Some(ERROR_NO_MORE_FILES) {
break;
}
return Err(io::Error::other(error.to_string()));
}
let buffer_bytes = buffer.len() * std::mem::size_of::<u64>();
let mut offset = 0usize;
loop {
if offset + std::mem::size_of::<FILE_ID_BOTH_DIR_INFO>() > buffer_bytes {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"directory enumeration returned a truncated entry",
));
}
let entry = unsafe {
&*buffer
.as_ptr()
.cast::<u8>()
.add(offset)
.cast::<FILE_ID_BOTH_DIR_INFO>()
};
let name_units = usize::try_from(entry.FileNameLength / 2).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "directory name is too long")
})?;
let name_offset = offset + std::mem::offset_of!(FILE_ID_BOTH_DIR_INFO, FileName);
let name_bytes = name_units.checked_mul(2).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "directory name is too long")
})?;
if name_offset + name_bytes > buffer_bytes {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"directory enumeration returned a truncated name",
));
}
let name_slice = unsafe {
std::slice::from_raw_parts(
buffer.as_ptr().cast::<u8>().add(name_offset).cast::<u16>(),
name_units,
)
};
if name_slice != [b'.' as u16] && name_slice != [b'.' as u16, b'.' as u16] {
names.push(std::ffi::OsString::from_wide(name_slice));
}
if entry.NextEntryOffset == 0 {
break;
}
let next = usize::try_from(entry.NextEntryOffset).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "invalid directory entry offset")
})?;
if next == 0 || offset + next >= buffer_bytes {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid directory entry offset",
));
}
offset += next;
}
}
names.sort();
Ok(names)
}
#[cfg(target_os = "windows")]
fn windows_file_identity(file: &File) -> io::Result<(u32, u64)> {
let information = windows_file_information(file)?;
Ok((
information.dwVolumeSerialNumber,
(u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow),
))
}
#[cfg(target_os = "windows")]
fn windows_file_link_count(file: &File) -> io::Result<u32> {
Ok(windows_file_information(file)?.nNumberOfLinks)
}
#[cfg(target_os = "windows")]
fn windows_file_information(
file: &File,
) -> io::Result<windows::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION> {
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
};
let mut information = BY_HANDLE_FILE_INFORMATION::default();
unsafe { GetFileInformationByHandle(HANDLE(file.as_raw_handle() as isize), &mut information) }
.map_err(|error| io::Error::other(error.to_string()))?;
Ok(information)
}
#[cfg(target_os = "windows")]
fn windows_atomic_replace_private_file(temp: &Path, destination: &Path) -> io::Result<()> {
windows_atomic_replace_private_file_with_hook(temp, destination, || Ok(()))
}
#[cfg(target_os = "windows")]
fn windows_atomic_replace_private_file_with_hook<F>(
temp: &Path,
destination: &Path,
before_rename: F,
) -> io::Result<()>
where
F: FnOnce() -> io::Result<()>,
{
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
FileRenameInfoEx, SetFileInformationByHandle, DELETE, FILE_GENERIC_READ,
FILE_GENERIC_WRITE, FILE_RENAME_INFO, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
WRITE_DAC, WRITE_OWNER,
};
const FILE_RENAME_FLAG_REPLACE_IF_EXISTS: u32 = 0x1;
const FILE_RENAME_FLAG_POSIX_SEMANTICS: u32 = 0x2;
let parent_path = normalized_parent(destination);
windows_validate_existing_directory_chain(parent_path)?;
let parent = windows_error_stage(
"open retained replacement parent",
windows_open_directory_with_access_and_share(
parent_path,
FILE_GENERIC_READ.0 | WRITE_DAC.0 | WRITE_OWNER.0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
),
)?;
windows_error_stage(
"validate retained replacement parent",
windows_validate_directory(&parent),
)?;
windows_error_stage(
"validate retained replacement parent owner",
windows_validate_hardenable_file_owner(&parent),
)?;
windows_error_stage(
"revalidate retained replacement parent before hardening",
windows_revalidate_directory_path(parent_path, &parent),
)?;
windows_error_stage(
"harden retained replacement parent",
windows_harden_file_acl(&parent),
)?;
windows_error_stage(
"revalidate retained replacement parent after hardening",
windows_revalidate_directory_path(parent_path, &parent),
)?;
let temp_name = temp.file_name().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "private temp has no file name")
})?;
let destination_name = destination.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"private destination has no file name",
)
})?;
let temp_file = windows_error_stage(
"open retained replacement source",
windows_open_entry_at_with_access_and_share(
&parent,
temp_name,
FILE_GENERIC_READ | FILE_GENERIC_WRITE | WRITE_DAC | WRITE_OWNER | DELETE,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
),
)?;
windows_error_stage(
"validate retained replacement source",
windows_validate_file(&temp_file),
)?;
windows_error_stage(
"validate retained replacement source owner",
windows_validate_hardenable_file_owner(&temp_file),
)?;
windows_error_stage(
"revalidate retained replacement source before hardening",
windows_revalidate_entry_at(&parent, temp_name, &temp_file),
)?;
let temp_identity = windows_error_stage(
"read retained replacement source identity",
windows_file_identity(&temp_file),
)?;
windows_error_stage(
"harden retained replacement source",
windows_harden_file_acl(&temp_file),
)?;
windows_error_stage(
"revalidate retained replacement source after hardening",
windows_revalidate_entry_at(&parent, temp_name, &temp_file),
)?;
windows_error_stage("sync retained replacement source", temp_file.sync_all())?;
let destination_identity = match windows_open_entry_at(&parent, destination_name) {
Ok(file) => {
windows_validate_file(&file)?;
windows_validate_file_owner(&file)?;
Some(windows_file_identity(&file)?)
}
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
windows_error_stage("run replacement race hook", before_rename())?;
windows_error_stage(
"revalidate retained replacement parent before publication",
windows_revalidate_directory_path(parent_path, &parent),
)?;
windows_error_stage(
"revalidate retained replacement source before publication",
windows_revalidate_entry_at(&parent, temp_name, &temp_file),
)?;
let current_destination_identity = match windows_open_entry_at(&parent, destination_name) {
Ok(file) => {
windows_validate_file(&file)?;
windows_validate_file_owner(&file)?;
Some(windows_file_identity(&file)?)
}
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
if current_destination_identity != destination_identity {
return Err(permission_denied(
"private destination changed before handle-relative replacement",
));
}
let destination_absolute = std::path::absolute(destination)?;
let destination_wide = destination_absolute
.as_os_str()
.encode_wide()
.collect::<Vec<_>>();
if destination_wide.is_empty() || destination_wide.contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"private destination name is empty or contains NUL",
));
}
let name_bytes = destination_wide
.len()
.checked_mul(std::mem::size_of::<u16>())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "file name is too long"))?;
let buffer_bytes = std::mem::offset_of!(FILE_RENAME_INFO, FileName)
.checked_add(name_bytes)
.and_then(|size| size.checked_add(std::mem::size_of::<u16>()))
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "file name is too long"))?;
let mut storage = vec![0u64; buffer_bytes.div_ceil(std::mem::size_of::<u64>())];
let rename = storage.as_mut_ptr().cast::<FILE_RENAME_INFO>();
let rename_result = unsafe {
(*rename).Anonymous.Flags =
FILE_RENAME_FLAG_REPLACE_IF_EXISTS | FILE_RENAME_FLAG_POSIX_SEMANTICS;
(*rename).RootDirectory = HANDLE::default();
(*rename).FileNameLength = u32::try_from(name_bytes)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file name is too long"))?;
std::ptr::copy_nonoverlapping(
destination_wide.as_ptr(),
std::ptr::addr_of_mut!((*rename).FileName).cast::<u16>(),
destination_wide.len(),
);
SetFileInformationByHandle(
HANDLE(temp_file.as_raw_handle() as isize),
FileRenameInfoEx,
rename.cast(),
u32::try_from(buffer_bytes).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "rename buffer is too large")
})?,
)
};
windows_error_stage(
"publish retained replacement source",
rename_result.map_err(windows_io_error),
)?;
let published = windows_error_stage(
"open published replacement",
windows_open_entry_at_for_hardening(&parent, destination_name),
)?;
windows_validate_file(&published)?;
windows_validate_file_owner(&published)?;
if windows_file_identity(&published)? != temp_identity {
return Err(permission_denied(
"published private file does not match the validated temp descriptor",
));
}
windows_harden_file_acl(&published)?;
windows_revalidate_entry_at(&parent, destination_name, &published)?;
windows_revalidate_directory_path(parent_path, &parent)
}
#[cfg(not(any(unix, target_os = "windows")))]
fn generic_open_private(path: &Path, mode: PrivateOpenMode) -> io::Result<File> {
if mode != PrivateOpenMode::Read {
if let Some(parent) = path.parent().filter(|path| !path.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
}
let mut options = std::fs::OpenOptions::new();
match mode {
PrivateOpenMode::Read => {
options.read(true);
}
PrivateOpenMode::Append => {
options.read(true).append(true).create(true);
}
PrivateOpenMode::Truncate => {
options.read(true).write(true);
}
PrivateOpenMode::CreateNew => {
options.write(true).create_new(true);
}
}
let file = options.open(path)?;
revalidate_private_file(&file)?;
if mode == PrivateOpenMode::Truncate {
file.set_len(0)?;
revalidate_private_file(&file)?;
}
Ok(file)
}
#[cfg(not(any(unix, target_os = "windows")))]
fn generic_harden_private_tree(
_tree: &PrivateTree,
_selected: &[PathBuf],
) -> io::Result<PrivateTreeReport> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"descriptor-root private-tree hardening is unsupported on this platform",
))
}
#[cfg(target_os = "windows")]
fn harden_windows_acl(path: &Path) -> io::Result<()> {
let metadata = std::fs::symlink_metadata(path)?;
reject_windows_reparse_metadata(&metadata)?;
let file = if metadata.is_dir() {
windows_open_directory_for_hardening(path)?
} else if metadata.is_file() {
windows_open_existing_file_for_hardening(path)?
} else {
return Err(permission_denied(
"owner-private path is not a regular file or directory",
));
};
windows_validate_hardenable_file_owner(&file)?;
windows_harden_file_acl(&file)?;
if metadata.is_dir() {
windows_revalidate_directory_path(path, &file)
} else {
windows_validate_file(&file)?;
windows_revalidate_private_path(path, &file)
}
}
#[cfg(target_os = "windows")]
fn current_process_default_owner_sid_string() -> std::io::Result<String> {
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Security::{GetTokenInformation, TokenOwner, TOKEN_OWNER, TOKEN_QUERY};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
let windows_error = |error: windows::core::Error| std::io::Error::other(error.to_string());
let mut token = HANDLE::default();
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
.map_err(windows_error)?;
let result = (|| {
let mut needed = 0u32;
let _ = unsafe { GetTokenInformation(token, TokenOwner, None, 0, &mut needed) };
if needed == 0 {
return Err(std::io::Error::last_os_error());
}
let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
let mut buffer = vec![0usize; words];
unsafe {
GetTokenInformation(
token,
TokenOwner,
Some(buffer.as_mut_ptr().cast()),
needed,
&mut needed,
)
}
.map_err(windows_error)?;
let owner = unsafe { &*buffer.as_ptr().cast::<TOKEN_OWNER>() };
windows_sid_string(owner.Owner)
})();
let _ = unsafe { CloseHandle(token) };
result
}
#[cfg(target_os = "windows")]
fn current_process_sid_string() -> std::io::Result<String> {
use windows::core::PWSTR;
use windows::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL};
use windows::Win32::Security::Authorization::ConvertSidToStringSidW;
use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
let windows_error = |error: windows::core::Error| std::io::Error::other(error.to_string());
let mut token = HANDLE::default();
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
.map_err(windows_error)?;
let result = (|| {
let mut needed = 0u32;
let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut needed) };
if needed == 0 {
return Err(std::io::Error::last_os_error());
}
let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
let mut buffer = vec![0usize; words];
unsafe {
GetTokenInformation(
token,
TokenUser,
Some(buffer.as_mut_ptr().cast()),
needed,
&mut needed,
)
}
.map_err(windows_error)?;
let user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
let mut sid_text = PWSTR::null();
unsafe { ConvertSidToStringSidW(user.User.Sid, &mut sid_text) }.map_err(windows_error)?;
let sid = unsafe { sid_text.to_string() }
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error));
unsafe {
let _ = LocalFree(HLOCAL(sid_text.0.cast()));
}
sid
})();
let _ = unsafe { CloseHandle(token) };
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_private_paths_use_native_handle_bound_security_apis() {
let source = include_str!("secure_path.rs");
assert!(source.contains(&["Create", "DirectoryW"].concat()));
assert!(source.contains(&["Create", "FileW"].concat()));
assert!(source.contains(&["Set", "SecurityInfo"].concat()));
assert!(source.contains(&["SetFileInformation", "ByHandle"].concat()));
assert!(source.contains(&["Flush", "FileBuffers"].concat()));
assert!(source.contains("FILE_GENERIC_WRITE.0 | WRITE_DAC.0"));
assert!(!source.contains(&["Command::new(\"", "icacls", "\")"].concat()));
assert!(!source.contains(&["Move", "FileExW("].concat()));
}
#[test]
fn harden_is_a_noop_on_a_missing_path_and_never_panics() {
harden_owner_only(Path::new("this/path/does/not/exist/xyz"));
}
#[cfg(target_os = "windows")]
fn assert_exact_protected_owner_acl(file: &File) {
windows_validate_file_owner(file).unwrap();
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::{LocalFree, HANDLE, HLOCAL, PSID};
use windows::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
use windows::Win32::Security::{
GetAce, GetSecurityDescriptorControl, ACCESS_ALLOWED_ACE, ACL,
DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SE_DACL_PROTECTED,
};
let mut dacl = std::ptr::null_mut::<ACL>();
let mut descriptor = PSECURITY_DESCRIPTOR::default();
let status = unsafe {
GetSecurityInfo(
HANDLE(file.as_raw_handle() as isize),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
None,
None,
Some(&mut dacl),
None,
Some(&mut descriptor),
)
};
assert!(status.is_ok());
assert!(!dacl.is_null());
let mut control = 0u16;
let mut revision = 0u32;
unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) }.unwrap();
assert_ne!(control & SE_DACL_PROTECTED.0, 0);
assert_eq!(unsafe { (*dacl).AceCount }, 1);
let mut raw_ace = std::ptr::null_mut();
unsafe { GetAce(dacl, 0, &mut raw_ace) }.unwrap();
let ace = unsafe { &*raw_ace.cast::<ACCESS_ALLOWED_ACE>() };
assert_eq!(ace.Header.AceType, 0, "ACE must be access-allowed");
let sid = PSID(std::ptr::addr_of!(ace.SidStart).cast_mut().cast());
let actual_sid = windows_sid_string(sid).unwrap();
assert_eq!(actual_sid, current_process_sid_string().unwrap());
unsafe {
let _ = LocalFree(HLOCAL(descriptor.0));
}
}
#[cfg(target_os = "windows")]
#[test]
fn created_files_and_directories_have_exact_protected_owner_acl() {
let sandbox = tempfile::tempdir().unwrap();
let directory = sandbox.path().join("private");
ensure_private_dir(&directory).unwrap();
let directory_handle = windows_open_directory(&directory).unwrap();
assert_exact_protected_owner_acl(&directory_handle);
let path = directory.join("record.jsonl");
let file = create_private_file(&path).unwrap();
assert_exact_protected_owner_acl(&file);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_first_use_flush_failure_is_not_acknowledged_and_retry_flushes() {
let sandbox = tempfile::tempdir().unwrap();
let directory = sandbox.path().join("private").join("nested");
let failures = PrivatePathDurabilityFailureInjector::default();
failures.fail_next(PrivatePathDurabilityFailurePoint::ParentDirectorySync);
assert!(ensure_private_dir_with_failure_injector(&directory, &failures).is_err());
ensure_private_dir_with_failure_injector(&directory, &failures).unwrap();
ensure_private_dir_with_failure_injector(&directory, &failures).unwrap();
let path = directory.join("receipt.json");
failures.fail_next(PrivatePathDurabilityFailurePoint::ParentDirectorySync);
assert!(open_private_append_with_failure_injector(&path, &failures).is_err());
let file = open_private_append_with_failure_injector(&path, &failures).unwrap();
assert_exact_protected_owner_acl(&file);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_validated_directory_handoff_swap_fails_closed_and_retries() {
let sandbox = tempfile::tempdir().unwrap();
let directory = sandbox.path().join("private");
ensure_private_dir(&directory).unwrap();
let moved = sandbox.path().join("moved-private");
let child = directory.join("child");
let mut swapped = false;
let error = windows_ensure_private_dir_with_hook(&child, None, |validated| {
if !swapped && validated == directory {
std::fs::rename(&directory, &moved)?;
ensure_private_dir(&directory)?;
swapped = true;
}
Ok(())
})
.unwrap_err();
assert!(swapped, "the exact intermediate directory was substituted");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(!child.exists());
std::fs::remove_dir(&directory).unwrap();
std::fs::rename(&moved, &directory).unwrap();
ensure_private_dir(&child).unwrap();
let retained = windows_open_directory(&directory).unwrap();
windows_validate_exact_private_acl(&retained).unwrap();
}
#[cfg(target_os = "windows")]
#[test]
fn windows_replace_rejects_temp_name_substitution_before_publication() {
use std::io::Write;
let root = tempfile::tempdir().unwrap();
let destination = root.path().join("record.jsonl");
let mut destination_file = create_private_file(&destination).unwrap();
destination_file.write_all(b"old").unwrap();
drop(destination_file);
let temp = root.path().join("validated.tmp");
let moved = root.path().join("moved.tmp");
let mut file = create_private_file(&temp).unwrap();
file.write_all(b"validated").unwrap();
drop(file);
let error = windows_atomic_replace_private_file_with_hook(&temp, &destination, || {
std::fs::rename(&temp, &moved)?;
std::fs::write(&temp, b"substitute")?;
Ok(())
})
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(
error
.to_string()
.contains("revalidate retained replacement source before publication"),
"substitution must be rejected before publication: {error}"
);
}
#[cfg(target_os = "windows")]
#[test]
fn handle_relative_replace_blocks_parent_root_swap() {
use std::io::Write;
let sandbox = tempfile::tempdir().unwrap();
let root = sandbox.path().join("private-root");
ensure_private_dir(&root).unwrap();
let destination = root.join("record.jsonl");
std::fs::write(&destination, b"old").unwrap();
let temp = root.join("validated.tmp");
let mut file = create_private_file(&temp).unwrap();
file.write_all(b"validated").unwrap();
drop(file);
let moved_root = sandbox.path().join("moved-root");
windows_atomic_replace_private_file_with_hook(&temp, &destination, || {
assert!(
std::fs::rename(&root, &moved_root).is_err(),
"retained no-delete-share parent handle must block root replacement"
);
Ok(())
})
.unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), b"validated");
assert!(!moved_root.exists());
}
#[cfg(unix)]
fn mode(path: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::symlink_metadata(path)
.unwrap()
.permissions()
.mode()
& 0o777
}
#[cfg(unix)]
#[test]
fn private_creation_uses_owner_only_modes_from_first_open() {
let root = tempfile::tempdir().unwrap();
let private_dir = root.path().join("nested").join("private");
ensure_private_dir(&private_dir).unwrap();
assert_eq!(mode(&root.path().join("nested")), 0o700);
assert_eq!(mode(&private_dir), 0o700);
let path = private_dir.join("record.jsonl");
let file = create_private_file(&path).unwrap();
assert_eq!(mode(&path), 0o600);
revalidate_private_file(&file).unwrap();
}
#[cfg(unix)]
#[test]
fn nested_private_creation_propagates_parent_sync_failure_and_retries() {
let sandbox = tempfile::tempdir().unwrap();
let nested = sandbox.path().join("private").join("nested");
let failures = PrivatePathDurabilityFailureInjector::default();
failures.fail_next(PrivatePathDurabilityFailurePoint::ParentDirectorySync);
let error = ensure_private_dir_with_failure_injector(&nested, &failures).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::Other);
assert!(
!nested.exists(),
"failed first component must not acknowledge the tree"
);
ensure_private_dir_with_failure_injector(&nested, &failures).unwrap();
assert_eq!(mode(&sandbox.path().join("private")), 0o700);
assert_eq!(mode(&nested), 0o700);
}
#[cfg(unix)]
#[test]
fn retained_intermediate_rename_is_rejected_before_the_next_child_create() {
let sandbox = tempfile::tempdir().unwrap();
let private = sandbox.path().join("private");
ensure_private_dir(&private).unwrap();
let intermediate = private.join("retained-intermediate");
let moved = private.join("moved-intermediate");
let target = intermediate.join("child");
let mut renamed = false;
let error =
unix_walk_directory_with_hook(&target, true, true, None, |validated_component| {
if !renamed && validated_component == std::ffi::OsStr::new("retained-intermediate")
{
std::fs::rename(&intermediate, &moved)?;
renamed = true;
}
Ok(())
})
.unwrap_err();
assert!(
renamed,
"the retained intermediate was renamed by the race hook"
);
assert!(matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
));
assert!(
!target.exists() && !moved.join("child").exists(),
"the next child must not be created under the moved retained directory"
);
std::fs::rename(&moved, &intermediate).unwrap();
ensure_private_dir(&target).unwrap();
assert!(
target.is_dir(),
"retry on the restored exact chain succeeds"
);
}
#[cfg(unix)]
#[test]
fn new_private_file_propagates_parent_sync_failure_and_append_retry_is_durable() {
use std::io::Write;
let sandbox = tempfile::tempdir().unwrap();
let directory = sandbox.path().join("private");
ensure_private_dir(&directory).unwrap();
let path = directory.join("journal.jsonl");
let failures = PrivatePathDurabilityFailureInjector::default();
failures.fail_next(PrivatePathDurabilityFailurePoint::ParentDirectorySync);
let error = open_private_append_with_failure_injector(&path, &failures).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::Other);
assert!(
path.exists(),
"the unacknowledged entry remains private for safe retry"
);
assert_eq!(mode(&path), 0o600);
let mut file = open_private_append_with_failure_injector(&path, &failures).unwrap();
file.write_all(b"one\n").unwrap();
file.sync_all().unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"one\n");
}
#[cfg(unix)]
#[test]
fn opening_owned_permissive_file_hardens_it() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let path = root.path().join("record.jsonl");
std::fs::write(&path, b"old\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let _file = open_private_append(&path).unwrap();
assert_eq!(mode(&path), 0o600);
}
#[cfg(unix)]
#[test]
fn private_read_hardens_without_changing_content() {
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let path = root.path().join("record.jsonl");
std::fs::write(&path, b"historical").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let mut file = open_private_read(&path).unwrap();
let mut content = String::new();
file.read_to_string(&mut content).unwrap();
assert_eq!(content, "historical");
assert_eq!(mode(&path), 0o600);
}
#[cfg(unix)]
#[test]
fn atomic_replace_publishes_the_validated_private_inode() {
use std::io::Write;
let root = tempfile::tempdir().unwrap();
let destination = root.path().join("record.jsonl");
std::fs::write(&destination, b"old").unwrap();
let temp = root.path().join(".record.random.tmp");
let mut file = create_private_file(&temp).unwrap();
file.write_all(b"new").unwrap();
file.sync_all().unwrap();
drop(file);
atomic_replace_private_file(&temp, &destination).unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), b"new");
assert_eq!(mode(&destination), 0o600);
assert!(!temp.exists());
}
#[cfg(unix)]
#[test]
fn atomic_replace_rejects_temp_path_substitution() {
use std::io::Write;
let root = tempfile::tempdir().unwrap();
let destination = root.path().join("record.jsonl");
std::fs::write(&destination, b"old").unwrap();
let temp = root.path().join(".record.random.tmp");
let moved = root.path().join("validated.tmp");
let mut file = create_private_file(&temp).unwrap();
file.write_all(b"validated").unwrap();
drop(file);
let result = unix_atomic_replace_private_file_with_hook(&temp, &destination, || {
std::fs::rename(&temp, &moved)?;
let mut substitute = create_private_file(&temp)?;
substitute.write_all(b"substitute")?;
Ok(())
});
assert!(result.is_err());
assert_eq!(std::fs::read(&destination).unwrap(), b"old");
assert_eq!(std::fs::read(&moved).unwrap(), b"validated");
}
#[cfg(unix)]
#[test]
fn path_revalidation_rejects_rename_and_substitution() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("record.jsonl");
let file = open_private_append(&path).unwrap();
revalidate_private_path(&path, &file).unwrap();
let moved = root.path().join("moved.jsonl");
std::fs::rename(&path, &moved).unwrap();
let _substitute = create_private_file(&path).unwrap();
assert!(revalidate_private_path(&path, &file).is_err());
}
#[cfg(unix)]
#[test]
fn private_tree_rejects_root_rename_and_replacement() {
let sandbox = tempfile::tempdir().unwrap();
let root = sandbox.path().join("car");
std::fs::create_dir(&root).unwrap();
let tree = PrivateTree::open(&root).unwrap();
let moved = sandbox.path().join("moved-car");
std::fs::rename(&root, &moved).unwrap();
std::fs::create_dir(&root).unwrap();
assert!(tree.revalidate_root().is_err());
assert!(tree
.harden_selected(&PrivateTreePolicy::root_only())
.is_err());
}
#[cfg(unix)]
#[test]
fn private_tree_retry_is_idempotent_and_does_not_touch_unselected_siblings() {
use std::os::unix::fs::{symlink, PermissionsExt};
let sandbox = tempfile::tempdir().unwrap();
let root = sandbox.path().join("car");
std::fs::create_dir(&root).unwrap();
let good = root.join("a-good.json");
let blocked = root.join("b-blocked.json");
let unrelated = root.join("unrelated.json");
std::fs::write(&good, b"good").unwrap();
symlink(&good, &blocked).unwrap();
std::fs::write(&unrelated, b"unrelated").unwrap();
for path in [&good, &unrelated] {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
}
let tree = PrivateTree::open(&root).unwrap();
let policy = PrivateTreePolicy::selected(["a-good.json", "b-blocked.json"]);
assert!(tree.harden_selected(&policy).is_err());
assert_eq!(mode(&good), 0o600);
assert_eq!(mode(&unrelated), 0o644);
std::fs::remove_file(&blocked).unwrap();
std::fs::write(&blocked, b"repaired").unwrap();
std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o644)).unwrap();
let report = tree.harden_selected(&policy).unwrap();
assert_eq!(report.files_hardened, 2);
assert_eq!(mode(&good), 0o600);
assert_eq!(mode(&blocked), 0o600);
assert_eq!(mode(&unrelated), 0o644);
let retry = tree.harden_selected(&policy).unwrap();
assert_eq!(retry, report);
}
#[cfg(unix)]
#[test]
fn private_tree_recurses_selected_directories_and_rejects_hardlinks() {
use std::os::unix::fs::PermissionsExt;
let sandbox = tempfile::tempdir().unwrap();
let root = sandbox.path().join("car");
let selected = root.join("runs");
std::fs::create_dir_all(&selected).unwrap();
let victim = selected.join("victim.json");
let alias = selected.join("alias.json");
std::fs::write(&victim, b"run").unwrap();
std::fs::hard_link(&victim, &alias).unwrap();
std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o755)).unwrap();
let tree = PrivateTree::open(&root).unwrap();
let policy = PrivateTreePolicy::selected(["runs"]);
assert!(tree.harden_selected(&policy).is_err());
assert_eq!(mode(&selected), 0o700);
assert_eq!(std::fs::read(&victim).unwrap(), b"run");
}
#[cfg(unix)]
#[test]
fn private_tree_recursive_retry_has_stable_receipts_and_exact_modes() {
use std::os::unix::fs::PermissionsExt;
let sandbox = tempfile::tempdir().unwrap();
let root = sandbox.path().join("car");
let nested = root.join("runs").join("today");
std::fs::create_dir_all(&nested).unwrap();
let run = nested.join("run.json");
std::fs::write(&run, b"run").unwrap();
std::fs::set_permissions(root.join("runs"), std::fs::Permissions::from_mode(0o755))
.unwrap();
std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o644)).unwrap();
let tree = PrivateTree::open(&root).unwrap();
let policy = PrivateTreePolicy::selected(["runs"]);
let first = tree.harden_selected(&policy).unwrap();
let retry = tree.harden_selected(&policy).unwrap();
assert_eq!(first, retry);
assert_eq!(first.directories_hardened, 2);
assert_eq!(first.files_hardened, 1);
assert_eq!(mode(&root), 0o700);
assert_eq!(mode(&root.join("runs")), 0o700);
assert_eq!(mode(&nested), 0o700);
assert_eq!(mode(&run), 0o600);
}
#[cfg(unix)]
#[test]
fn private_tree_rejects_parent_traversal_without_touching_external_file() {
use std::os::unix::fs::PermissionsExt;
let sandbox = tempfile::tempdir().unwrap();
let root = sandbox.path().join("car");
std::fs::create_dir(&root).unwrap();
let external = sandbox.path().join("external.json");
std::fs::write(&external, b"external").unwrap();
std::fs::set_permissions(&external, std::fs::Permissions::from_mode(0o644)).unwrap();
let tree = PrivateTree::open(&root).unwrap();
let policy = PrivateTreePolicy::selected(["../external.json"]);
assert!(tree.harden_selected(&policy).is_err());
assert_eq!(mode(&external), 0o644);
assert_eq!(std::fs::read(&external).unwrap(), b"external");
}
#[cfg(unix)]
#[test]
fn overlapping_exact_selection_still_requires_the_nested_marker() {
let sandbox = tempfile::tempdir().unwrap();
let root = sandbox.path().join("car");
let runs = root.join("runs");
std::fs::create_dir_all(&runs).unwrap();
let tree = PrivateTree::open(&root).unwrap();
let policy = PrivateTreePolicy::selected(["runs", "runs/.nobackup"]);
assert_eq!(
tree.harden_selected(&policy).unwrap_err().kind(),
io::ErrorKind::NotFound
);
std::fs::write(runs.join(".nobackup"), b"").unwrap();
let report = tree.harden_selected(&policy).unwrap();
assert_eq!(report.directories_hardened, 1);
assert_eq!(report.files_hardened, 1);
}
#[cfg(unix)]
#[test]
fn truncate_requires_an_existing_validated_file() {
let root = tempfile::tempdir().unwrap();
let existing = root.path().join("existing.json");
std::fs::write(&existing, b"content").unwrap();
let file = open_private_truncate(&existing).unwrap();
assert_eq!(file.metadata().unwrap().len(), 0);
assert_eq!(mode(&existing), 0o600);
let missing = root.path().join("missing.json");
assert_eq!(
open_private_truncate(&missing).unwrap_err().kind(),
io::ErrorKind::NotFound
);
assert!(!missing.exists());
}
#[cfg(unix)]
#[test]
fn truncate_rejects_a_hardlink_without_touching_victim_bytes() {
let root = tempfile::tempdir().unwrap();
let victim = root.path().join("victim.json");
let alias = root.path().join("alias.json");
std::fs::write(&victim, b"must-survive").unwrap();
std::fs::hard_link(&victim, &alias).unwrap();
assert!(open_private_truncate(&alias).is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"must-survive");
}
#[cfg(unix)]
#[test]
fn symlink_and_hardlink_files_are_rejected() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let target = root.path().join("target");
std::fs::write(&target, b"target").unwrap();
let link = root.path().join("link");
symlink(&target, &link).unwrap();
assert!(open_private_append(&link).is_err());
let hardlink = root.path().join("hardlink");
std::fs::hard_link(&target, &hardlink).unwrap();
assert!(open_private_append(&hardlink).is_err());
}
#[cfg(unix)]
#[test]
fn symlink_directory_component_is_rejected() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let real = root.path().join("real");
std::fs::create_dir(&real).unwrap();
let link = root.path().join("link");
symlink(&real, &link).unwrap();
assert!(ensure_private_dir(&link.join("child")).is_err());
assert!(create_private_file(&link.join("record")).is_err());
}
#[test]
fn create_private_file_never_reuses_an_existing_name() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("record");
let _file = create_private_file(&path).unwrap();
assert_eq!(
create_private_file(&path).unwrap_err().kind(),
std::io::ErrorKind::AlreadyExists
);
}
}