use std::ffi::{OsStr, OsString};
use std::io;
use std::path::{Component, Path};
use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt, OpenOptionsSyncExt};
use cap_std::ambient_authority;
use cap_std::fs::{Dir, File, Metadata, OpenOptions};
use crate::CryptoError;
use crate::error::{sanitize_for_display, sanitize_path_for_display};
#[cfg(unix)]
const DIR_CREATE_MODE: u32 = crate::fs::commit::STAGED_DIR_MODE;
pub(crate) const INITIAL_FILE_CREATE_MODE: u32 = crate::fs::commit::STAGED_FILE_MODE;
pub(crate) const DIRECTORY_PROMOTION_SUPPORTED: bool = cfg!(any(
target_os = "linux",
target_os = "macos",
target_os = "windows"
));
#[cfg(windows)]
pub(super) const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
const SYMLINK_IN_EXTRACTION_PATH: &str = "Symlink in extraction path";
const DIRECTORY_MISSING_IN_EXTRACTION: &str = "Directory in extraction path went missing";
const DIRECTORY_MISSING_IN_ARCHIVE_SOURCE: &str = "Directory in archive source went missing";
#[derive(Clone, Copy)]
pub(crate) enum WalkSide {
ArchiveSource,
Extraction,
}
impl WalkSide {
fn symlink_label(self) -> &'static str {
match self {
Self::ArchiveSource => SYMLINK_IN_ARCHIVE_SOURCE,
Self::Extraction => SYMLINK_IN_EXTRACTION_PATH,
}
}
fn missing_directory_label(self) -> &'static str {
match self {
Self::ArchiveSource => DIRECTORY_MISSING_IN_ARCHIVE_SOURCE,
Self::Extraction => DIRECTORY_MISSING_IN_EXTRACTION,
}
}
}
pub(super) const SYMLINK_IN_ARCHIVE_SOURCE: &str = "Symlink in archive source";
pub(crate) fn open_anchor(path: &Path) -> Result<Dir, CryptoError> {
Dir::open_ambient_dir(path, ambient_authority()).map_err(CryptoError::Io)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct ObjectId {
dev: u64,
ino: u64,
}
pub(crate) fn metadata_object_id(metadata: &Metadata) -> Option<ObjectId> {
crate::fs::atomic::file_identity(metadata).map(|(dev, ino)| ObjectId { dev, ino })
}
pub(crate) fn dir_object_id(dir: &Dir) -> Result<Option<ObjectId>, CryptoError> {
let metadata = dir.dir_metadata().map_err(CryptoError::Io)?;
Ok(metadata_object_id(&metadata))
}
pub(crate) fn file_object_id(file: &File) -> Result<Option<ObjectId>, CryptoError> {
let metadata = file.metadata().map_err(CryptoError::Io)?;
Ok(metadata_object_id(&metadata))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum OwnerComparison {
#[cfg_attr(not(unix), allow(dead_code))]
Same,
#[cfg_attr(not(unix), allow(dead_code))]
Different,
#[cfg_attr(unix, allow(dead_code))]
Unavailable,
}
#[cfg(unix)]
pub(crate) fn compare_owners(dir: &Dir, file: &File) -> Result<OwnerComparison, CryptoError> {
use cap_std::fs::MetadataExt;
let dir_owner = dir.dir_metadata().map_err(CryptoError::Io)?.uid();
let file_owner = file.metadata().map_err(CryptoError::Io)?.uid();
Ok(if dir_owner == file_owner {
OwnerComparison::Same
} else {
OwnerComparison::Different
})
}
#[cfg(not(unix))]
pub(crate) fn compare_owners(_dir: &Dir, _file: &File) -> Result<OwnerComparison, CryptoError> {
Ok(OwnerComparison::Unavailable)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn rename_at_no_clobber(
dir: &Dir,
from: &OsStr,
to: &OsStr,
root_is_file: bool,
) -> Result<PromotionOutcome, PromotionFailure> {
use crate::fs::commit::{FlaggedRename, rename_no_replace_at};
match rename_no_replace_at(dir, from, to) {
Ok(FlaggedRename::Committed) => {}
Ok(FlaggedRename::Unsupported) => {
return rename_at_no_clobber_via_claim(dir, from, to, root_is_file);
}
Err(error) => return Err(PromotionFailure::plain(error)),
}
sync_dir_handle(dir);
Ok(PromotionOutcome::Clean)
}
#[derive(Debug)]
pub(crate) struct PromotionFailure {
pub(crate) error: io::Error,
pub(crate) claim_left: bool,
}
impl PromotionFailure {
pub(crate) fn plain(error: io::Error) -> Self {
Self {
error,
claim_left: false,
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl From<crate::fs::commit::CommitFailure> for PromotionFailure {
fn from(failure: crate::fs::commit::CommitFailure) -> Self {
Self {
error: failure.error,
claim_left: failure.claim_left,
}
}
}
#[derive(Debug)]
pub(crate) enum PromotionOutcome {
Clean,
#[cfg(unix)]
LinkedFile,
#[cfg(any(target_os = "linux", target_os = "macos"))]
StagedLinkRetained(io::Error),
}
impl PromotionOutcome {
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn for_tempfile_file_promotion() -> Self {
#[cfg(unix)]
{
Self::LinkedFile
}
#[cfg(not(unix))]
{
Self::Clean
}
}
pub(crate) fn into_staged_link_error(self) -> Option<io::Error> {
match self {
Self::Clean => None,
#[cfg(unix)]
Self::LinkedFile => None,
#[cfg(any(target_os = "linux", target_os = "macos"))]
Self::StagedLinkRetained(error) => Some(error),
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn rename_at_no_clobber_via_claim(
dir: &Dir,
from: &OsStr,
to: &OsStr,
root_is_file: bool,
) -> Result<PromotionOutcome, PromotionFailure> {
rename_at_no_clobber_via_claim_with(dir, from, to, root_is_file, |dir, from| {
dir.remove_file(from)
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn rename_at_no_clobber_via_claim_with(
dir: &Dir,
from: &OsStr,
to: &OsStr,
root_is_file: bool,
remove_staged: impl FnOnce(&Dir, &OsStr) -> io::Result<()>,
) -> Result<PromotionOutcome, PromotionFailure> {
use crate::fs::commit::{CommitKind, CommitRoute, commit_by_link_or_claim};
let kind = if root_is_file {
CommitKind::File
} else {
CommitKind::Directory
};
let outcome = match commit_by_link_or_claim(dir, from, to, kind) {
Ok(CommitRoute::Renamed) => PromotionOutcome::Clean,
Ok(CommitRoute::Linked) => match remove_staged(dir, from) {
Ok(()) => PromotionOutcome::LinkedFile,
Err(error) if error.kind() == io::ErrorKind::NotFound => PromotionOutcome::LinkedFile,
Err(error) => PromotionOutcome::StagedLinkRetained(error),
},
Err(failure) => return Err(PromotionFailure::from(failure)),
};
sync_dir_handle(dir);
Ok(outcome)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn sync_dir_handle(dir: &Dir) {
if let Ok(sync_fd) = crate::fs::atomic::reopen_dir_for_flush(dir) {
let _ = crate::fs::atomic::fsync_uninterrupted(&sync_fd);
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn sync_dir_handle(_dir: &Dir) {}
#[cfg(target_os = "macos")]
pub(crate) fn sync_extraction_barrier(dir: &Dir) -> io::Result<()> {
crate::fs::atomic::sync_dir_durable_at(dir)
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn sync_extraction_barrier(_dir: &Dir) -> io::Result<()> {
Ok(())
}
#[cfg(windows)]
fn reject_reparse_point(dir: &Dir, name: &OsStr, label: &str) -> Result<(), CryptoError> {
use cap_std::fs::MetadataExt;
let meta = dir.dir_metadata().map_err(CryptoError::Io)?;
reject_reparse_attributes(meta.file_attributes(), name, label)
}
#[cfg(windows)]
fn reject_reparse_attributes(
file_attributes: u32,
name: &OsStr,
label: &str,
) -> Result<(), CryptoError> {
if file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(CryptoError::InvalidInput(format!(
"{label}: {}",
sanitize_for_display(&name.to_string_lossy())
)));
}
Ok(())
}
fn finalize_dir_open(dir: Dir, _name: &OsStr, _label: &str) -> Result<Dir, CryptoError> {
#[cfg(windows)]
reject_reparse_point(&dir, _name, _label)?;
Ok(dir)
}
pub(super) fn classify_open_failure(
parent: &Dir,
name: &OsStr,
e: io::Error,
label: &str,
diagnostic: &str,
) -> CryptoError {
if let Ok(meta) = parent.symlink_metadata(name) {
if meta.file_type().is_symlink() {
return CryptoError::InvalidInput(format!(
"{label}: {}",
sanitize_for_display(diagnostic)
));
}
}
CryptoError::Io(e)
}
pub(crate) fn open_child_dir_nofollow(
parent: &Dir,
name: &OsStr,
label: &str,
) -> Result<Dir, CryptoError> {
let dir = parent.open_dir_nofollow(name).map_err(|e| {
classify_open_failure(
parent,
name,
e,
label,
&Path::new(name).display().to_string(),
)
})?;
finalize_dir_open(dir, name, label)
}
pub(crate) fn mkdir_strict(parent: &Dir, name: &OsStr) -> Result<Dir, CryptoError> {
create_dir_with_default_mode(parent, name)
}
pub(crate) fn retain_staged_dir(parent: &Dir, name: &OsStr, dir: &Dir) -> io::Result<Dir> {
#[cfg(unix)]
{
let _ = (parent, name);
dir.try_clone()
}
#[cfg(windows)]
{
use cap_std::fs::OpenOptionsExt;
let _ = dir;
let mut options = OpenOptions::new();
options
.access_mode(0)
.custom_flags(crate::fs::atomic::FILE_FLAG_BACKUP_SEMANTICS);
options.follow(FollowSymlinks::No);
let file = parent.open_with(name, &options)?;
Ok(Dir::from_std_file(file.into_std()))
}
}
fn create_dir_with_default_mode(parent: &Dir, name: &OsStr) -> Result<Dir, CryptoError> {
crate::fs::commit::create_dir_at(parent, name).map_err(CryptoError::Io)?;
let dir = parent.open_dir_nofollow(name).map_err(|e| {
classify_open_failure(
parent,
name,
e,
SYMLINK_IN_EXTRACTION_PATH,
&Path::new(name).display().to_string(),
)
})?;
finalize_dir_open(dir, name, SYMLINK_IN_EXTRACTION_PATH)
}
#[cfg(unix)]
fn chmod_dir_via_self_path(dir: &Dir, mode: u32) -> Result<(), CryptoError> {
use cap_std::fs::PermissionsExt;
let perm = cap_std::fs::Permissions::from_mode(mode & super::PERMISSION_BITS_MASK);
dir.set_permissions(".", perm).map_err(CryptoError::Io)
}
pub(crate) fn walk_to_parent(
root: &Dir,
rel: &Path,
side: WalkSide,
) -> Result<(Dir, OsString), CryptoError> {
let mut components: Vec<Component<'_>> = rel.components().collect();
let last = components.pop().ok_or(crate::error::internal_invariant!(
"archive entry resolved to empty path"
))?;
let final_name = normal_component(last, rel)?.to_os_string();
let mut cur = root.try_clone().map_err(CryptoError::Io)?;
for component in components {
let name = normal_component(component, rel)?;
cur = open_child_dir_nofollow(&cur, name, side.symlink_label())
.map_err(|e| missing_walk_component(e, side, name))?;
}
Ok((cur, final_name))
}
fn missing_walk_component(error: CryptoError, side: WalkSide, name: &OsStr) -> CryptoError {
match &error {
CryptoError::Io(io_error) if io_error.kind() == io::ErrorKind::NotFound => {
CryptoError::InvalidInput(format!(
"{}: {}",
side.missing_directory_label(),
sanitize_for_display(&name.to_string_lossy())
))
}
_ => error,
}
}
pub(crate) fn open_dir_at_rel(root: &Dir, rel: &Path) -> Result<Dir, CryptoError> {
let mut cur = root.try_clone().map_err(CryptoError::Io)?;
for component in rel.components() {
let name = normal_component(component, rel)?;
cur = open_child_dir_nofollow(&cur, name, SYMLINK_IN_EXTRACTION_PATH)
.map_err(|e| missing_walk_component(e, WalkSide::Extraction, name))?;
}
Ok(cur)
}
pub(crate) fn open_file_nofollow(parent: &Dir, name: &OsStr) -> Result<File, CryptoError> {
let mut options = OpenOptions::new();
options.read(true).follow(FollowSymlinks::No).nonblock(true);
let file = parent.open_with(name, &options).map_err(|e| {
classify_open_failure(
parent,
name,
e,
SYMLINK_IN_EXTRACTION_PATH,
&Path::new(name).display().to_string(),
)
})?;
finalize_file_open(file, name)
}
fn finalize_file_open(file: File, name: &OsStr) -> Result<File, CryptoError> {
let metadata = file.metadata().map_err(CryptoError::Io)?;
#[cfg(windows)]
{
use cap_std::fs::MetadataExt;
reject_reparse_attributes(metadata.file_attributes(), name, SYMLINK_IN_EXTRACTION_PATH)?;
}
if !metadata.file_type().is_file() {
return Err(CryptoError::InvalidInput(format!(
"Extraction path is no longer a regular file: {}",
sanitize_for_display(&name.to_string_lossy())
)));
}
Ok(file)
}
fn normal_component<'a>(component: Component<'a>, full: &Path) -> Result<&'a OsStr, CryptoError> {
match component {
Component::Normal(s) => Ok(s),
_ => Err(CryptoError::InvalidInput(format!(
"Invalid component in archive entry: {}",
sanitize_path_for_display(full)
))),
}
}
pub(crate) fn create_file_at(parent: &Dir, name: &OsStr, create_mode: u32) -> io::Result<File> {
#[cfg(unix)]
let create_mode = create_mode & super::PERMISSION_BITS_MASK;
crate::fs::commit::create_file_at(parent, name, create_mode)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn sync_file_standard(file: &File) -> io::Result<()> {
crate::fs::atomic::fsync_uninterrupted(file)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn sync_file_standard(file: &File) -> io::Result<()> {
file.sync_all()
}
pub(crate) fn sync_single_file_durable(file: &File) -> io::Result<()> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
crate::fs::atomic::flush_file_with_fallback(
|| crate::fs::atomic::full_fsync_uninterrupted(file),
|| crate::fs::atomic::fsync_uninterrupted(file),
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
file.sync_all()
}
}
#[cfg(unix)]
pub(crate) fn chmod_file_handle(file: &File, mode: u32) -> Result<(), CryptoError> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(mode & super::PERMISSION_BITS_MASK);
file.set_permissions(cap_std::fs::Permissions::from_std(perms))
.map_err(CryptoError::Io)
}
#[cfg(not(unix))]
pub(crate) fn chmod_file_handle(_file: &File, _mode: u32) -> Result<(), CryptoError> {
Ok(())
}
#[cfg(unix)]
pub(crate) fn chmod_dir_handle(dir: Dir, mode: u32) -> Result<(), CryptoError> {
chmod_dir_via_self_path(&dir, mode)
}
#[cfg(not(unix))]
pub(crate) fn chmod_dir_handle(_dir: Dir, _mode: u32) -> Result<(), CryptoError> {
Ok(())
}
#[cfg(all(unix, any(target_os = "linux", target_os = "android")))]
pub(crate) fn restore_owner_access(parent: &Dir, name: &OsStr) -> io::Result<()> {
use cap_std::fs::PermissionsExt;
parent.set_permissions(name, cap_std::fs::Permissions::from_mode(DIR_CREATE_MODE))
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
pub(crate) fn restore_owner_access(parent: &Dir, name: &OsStr) -> io::Result<()> {
use std::os::fd::AsFd;
use rustix::fs::{AtFlags, Mode, RawMode, chmodat};
chmodat(
parent.as_fd(),
name,
Mode::from_bits_truncate(DIR_CREATE_MODE as RawMode),
AtFlags::SYMLINK_NOFOLLOW,
)
.map_err(io::Error::from)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn chmod_dir_handle_durable(dir: Dir, mode: u32) -> Result<(), CryptoError> {
let sync_fd = crate::fs::atomic::reopen_dir_for_flush(&dir).ok();
chmod_dir_handle(dir, mode)?;
if let Some(sync_fd) = sync_fd {
let _ = crate::fs::atomic::fsync_uninterrupted(&sync_fd);
}
Ok(())
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn chmod_dir_handle_durable(dir: Dir, mode: u32) -> Result<(), CryptoError> {
chmod_dir_handle(dir, mode)
}
#[cfg(all(test, windows))]
pub(crate) fn try_make_junction(target: &Path, junction: &Path) -> io::Result<()> {
let output = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(junction)
.arg(target)
.output()?;
if output.status.success() {
Ok(())
} else {
Err(io::Error::other(format!(
"mklink /J failed with exit code {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
use std::fs;
use std::path::Path;
#[cfg(unix)]
#[test]
fn open_dir_at_rel_rejects_symlink_to_outside() {
use std::os::unix::fs as unix_fs;
let tmp = tempfile::TempDir::new().unwrap();
unix_fs::symlink("/tmp", tmp.path().join("evil")).unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let err = open_dir_at_rel(&parent, Path::new("evil")).unwrap_err();
assert!(
err.to_string().contains("Symlink in extraction path"),
"expected symlink-path diagnostic, got: {err}"
);
}
#[cfg(unix)]
#[test]
fn open_child_dir_nofollow_rejects_symlink_with_archive_label() {
use std::os::unix::fs as unix_fs;
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir(tmp.path().join("real")).unwrap();
unix_fs::symlink("real", tmp.path().join("link")).unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let err = open_child_dir_nofollow(&parent, OsStr::new("link"), SYMLINK_IN_ARCHIVE_SOURCE)
.unwrap_err();
assert!(
err.to_string().contains("Symlink in archive source"),
"expected encode-side symlink diagnostic, got: {err}"
);
}
#[test]
fn open_file_nofollow_opens_regular_file() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("plain.txt"), b"content").unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let file = open_file_nofollow(&parent, OsStr::new("plain.txt")).unwrap();
assert!(file.metadata().unwrap().is_file());
}
#[test]
fn extraction_syncs_succeed_on_created_entries() {
use std::io::Write;
let tmp = tempfile::TempDir::new().unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let mut single =
create_file_at(&parent, OsStr::new("single"), INITIAL_FILE_CREATE_MODE).unwrap();
single.write_all(b"content").unwrap();
sync_single_file_durable(&single).unwrap();
let root = mkdir_strict(&parent, OsStr::new("root")).unwrap();
let mut child =
create_file_at(&root, OsStr::new("child"), INITIAL_FILE_CREATE_MODE).unwrap();
child.write_all(b"content").unwrap();
sync_file_standard(&child).unwrap();
sync_dir_handle(&root);
sync_extraction_barrier(&root).unwrap();
}
#[cfg(unix)]
#[test]
fn open_file_nofollow_rejects_fifo_without_blocking() {
let tmp = tempfile::TempDir::new().unwrap();
crate::fs::paths::make_fifo(&tmp.path().join("swapped"));
let parent = open_anchor(tmp.path()).unwrap();
let err = open_file_nofollow(&parent, OsStr::new("swapped")).unwrap_err();
assert!(
err.to_string()
.contains("Extraction path is no longer a regular file"),
"expected regular-file rejection, got: {err}"
);
}
#[test]
fn open_file_nofollow_rejects_directory() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir(tmp.path().join("subdir")).unwrap();
let parent = open_anchor(tmp.path()).unwrap();
assert!(open_file_nofollow(&parent, OsStr::new("subdir")).is_err());
}
#[cfg(unix)]
#[test]
fn open_dir_at_rel_rejects_in_sandbox_symlink() {
use std::os::unix::fs as unix_fs;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("root");
fs::create_dir_all(root.join("real")).unwrap();
unix_fs::symlink("real", root.join("link")).unwrap();
let parent = open_anchor(&root).unwrap();
let err = open_dir_at_rel(&parent, Path::new("link")).unwrap_err();
assert!(err.to_string().contains("Symlink in extraction path"));
}
#[cfg(unix)]
#[test]
fn walk_to_parent_rejects_in_sandbox_intermediate_symlink() {
use std::os::unix::fs as unix_fs;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("root");
fs::create_dir_all(root.join("real")).unwrap();
unix_fs::symlink("real", root.join("link")).unwrap();
let parent = open_anchor(&root).unwrap();
let err =
walk_to_parent(&parent, Path::new("link/file.txt"), WalkSide::Extraction).unwrap_err();
assert!(err.to_string().contains("Symlink in extraction path"));
assert!(!root.join("real/file.txt").exists());
}
#[cfg(unix)]
#[test]
fn walk_to_parent_rejects_outside_intermediate_symlink() {
use std::os::unix::fs as unix_fs;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("root");
let a = root.join("a");
fs::create_dir_all(&a).unwrap();
let victim = tmp.path().join("victim_dir");
fs::create_dir_all(&victim).unwrap();
unix_fs::symlink(&victim, a.join("b")).unwrap();
let parent = open_anchor(&root).unwrap();
let err =
walk_to_parent(&parent, Path::new("a/b/file.txt"), WalkSide::Extraction).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("symlink") || err.to_string().contains("path")
);
assert!(victim.read_dir().unwrap().next().is_none());
}
#[cfg(unix)]
#[test]
fn create_file_at_rejects_existing_symlink() {
use std::os::unix::fs as unix_fs;
let tmp = tempfile::TempDir::new().unwrap();
let victim = tmp.path().join("victim.txt");
fs::write(&victim, "original").unwrap();
unix_fs::symlink(&victim, tmp.path().join("link.txt")).unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let err =
create_file_at(&parent, OsStr::new("link.txt"), INITIAL_FILE_CREATE_MODE).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read_to_string(&victim).unwrap(), "original");
}
#[cfg(unix)]
#[test]
fn create_file_at_rejects_dangling_symlink() {
use std::os::unix::fs as unix_fs;
let tmp = tempfile::TempDir::new().unwrap();
unix_fs::symlink(
tmp.path().join("does_not_exist"),
tmp.path().join("link.txt"),
)
.unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let err =
create_file_at(&parent, OsStr::new("link.txt"), INITIAL_FILE_CREATE_MODE).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert!(!tmp.path().join("does_not_exist").exists());
}
#[cfg(unix)]
#[test]
fn open_dir_at_rel_rejects_relative_escape_symlink() {
use std::os::unix::fs as unix_fs;
let tmp = tempfile::TempDir::new().unwrap();
let inside = tmp.path().join("inside");
let outside = tmp.path().join("outside");
fs::create_dir_all(&inside).unwrap();
fs::create_dir_all(&outside).unwrap();
unix_fs::symlink("../outside", inside.join("escape")).unwrap();
let parent = open_anchor(&inside).unwrap();
let result = open_dir_at_rel(&parent, Path::new("escape"));
assert!(result.is_err());
assert!(outside.read_dir().unwrap().next().is_none());
}
#[test]
fn walk_to_parent_refuses_a_missing_intermediate_and_creates_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("root");
fs::create_dir_all(&root).unwrap();
let parent = open_anchor(&root).unwrap();
let result = walk_to_parent(&parent, Path::new("missing/file.txt"), WalkSide::Extraction);
let err = result.expect_err("a missing intermediate must fail the walk");
assert!(
err.to_string().contains(DIRECTORY_MISSING_IN_EXTRACTION),
"the walk must say the directory went missing, got: {err}"
);
assert!(
!root.join("missing").exists(),
"the walk must not create the missing directory"
);
}
#[cfg(unix)]
#[test]
fn walk_to_parent_rejects_dot_dot_traversal() {
let tmp = tempfile::TempDir::new().unwrap();
let inside = tmp.path().join("inside");
let outside = tmp.path().join("outside");
fs::create_dir_all(&inside).unwrap();
fs::create_dir_all(&outside).unwrap();
let parent = open_anchor(&inside).unwrap();
let result = walk_to_parent(&parent, Path::new("../outside/x.txt"), WalkSide::Extraction);
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn deferred_chmod_does_not_follow_substituted_symlink() {
use std::os::unix::fs as unix_fs;
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("root");
fs::create_dir_all(root.join("real")).unwrap();
fs::set_permissions(root.join("real"), fs::Permissions::from_mode(0o755)).unwrap();
unix_fs::symlink("real", root.join("extracted")).unwrap();
let parent = open_anchor(&root).unwrap();
let dir_result = open_dir_at_rel(&parent, Path::new("extracted"));
assert!(
dir_result.is_err(),
"open_dir_at_rel must reject the substituted symlink"
);
let real_mode = fs::metadata(root.join("real"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
real_mode, 0o755,
"real's mode must remain unchanged after rejected re-open"
);
}
#[cfg(unix)]
#[test]
fn chmod_file_handle_applies_mode_on_handle() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let f = create_file_at(&parent, OsStr::new("plain.bin"), INITIAL_FILE_CREATE_MODE).unwrap();
chmod_file_handle(&f, 0o640).unwrap();
drop(f);
let mode = fs::metadata(tmp.path().join("plain.bin"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o640);
}
#[cfg(unix)]
#[test]
fn chmod_dir_handle_strips_special_bits() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let dir = mkdir_strict(&parent, OsStr::new("d")).unwrap();
chmod_dir_handle(dir, 0o4755).unwrap();
let mode = fs::metadata(tmp.path().join("d"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(mode, 0o755, "setuid bit must be stripped");
}
#[cfg(unix)]
#[test]
fn chmod_dir_handle_durable_applies_unreadable_mode() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let dir = mkdir_strict(&parent, OsStr::new("d")).unwrap();
chmod_dir_handle_durable(dir, 0o311).unwrap();
let path = tmp.path().join("d");
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o311, "expected mode 0o311, got 0o{mode:o}");
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn mkdir_strict_creates_then_rejects_repeat() {
let tmp = tempfile::TempDir::new().unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let _d = mkdir_strict(&parent, OsStr::new("fresh")).unwrap();
assert!(tmp.path().is_dir());
assert!(tmp.path().join("fresh").is_dir());
let err = mkdir_strict(&parent, OsStr::new("fresh")).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("exist"),
"expected AlreadyExists-style rejection, got: {err}"
);
}
#[cfg(unix)]
#[test]
fn objects_this_process_creates_report_the_same_owner() {
let tmp = tempfile::TempDir::new().unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let dir = mkdir_strict(&parent, OsStr::new("d")).unwrap();
let file = create_file_at(&dir, OsStr::new("f"), INITIAL_FILE_CREATE_MODE).unwrap();
assert_eq!(compare_owners(&dir, &file).unwrap(), OwnerComparison::Same);
}
#[cfg(unix)]
#[test]
fn mkdir_strict_initial_mode_is_owner_private() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let parent = open_anchor(tmp.path()).unwrap();
let _d = mkdir_strict(&parent, OsStr::new("private")).unwrap();
let mode = fs::metadata(tmp.path().join("private"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o700, "expected initial mode 0o700, got 0o{mode:o}");
}
#[test]
fn open_dir_at_rel_with_empty_rel_returns_root_clone() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("root");
fs::create_dir_all(&root).unwrap();
let root_dir = open_anchor(&root).unwrap();
let cloned = open_dir_at_rel(&root_dir, Path::new("")).unwrap();
let _f = create_file_at(&cloned, OsStr::new("via_clone.txt"), 0o600).unwrap();
assert!(root.join("via_clone.txt").exists());
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn rename_at_no_clobber_anchors_to_handle_across_path_swap() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
fs::write(out.join("root.incomplete"), b"real").unwrap();
let handle = open_anchor(&out).unwrap();
let moved = tmp.path().join("out.moved");
fs::rename(&out, &moved).unwrap();
fs::create_dir(&out).unwrap();
fs::write(out.join("root.incomplete"), b"attacker").unwrap();
rename_at_no_clobber(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("root"),
true,
)
.unwrap();
assert_eq!(fs::read(moved.join("root")).unwrap(), b"real");
assert!(!moved.join("root.incomplete").exists());
assert!(!out.join("root").exists());
assert_eq!(fs::read(out.join("root.incomplete")).unwrap(), b"attacker");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn rename_at_no_clobber_refuses_existing_target() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
fs::write(out.join("root.incomplete"), b"new").unwrap();
fs::write(out.join("root"), b"existing").unwrap();
let handle = open_anchor(&out).unwrap();
let err = rename_at_no_clobber(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("root"),
true,
)
.unwrap_err();
assert_eq!(err.error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(out.join("root")).unwrap(), b"existing");
assert_eq!(fs::read(out.join("root.incomplete")).unwrap(), b"new");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn via_claim_promotes_file_root() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("root.incomplete"), b"payload").unwrap();
let handle = open_anchor(tmp.path()).unwrap();
rename_at_no_clobber_via_claim(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("root"),
true,
)
.unwrap();
assert_eq!(fs::read(tmp.path().join("root")).unwrap(), b"payload");
assert!(!tmp.path().join("root.incomplete").exists());
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn link_no_clobber_commits_without_a_placeholder() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("root.incomplete"), b"payload").unwrap();
fs::write(tmp.path().join("taken"), b"existing").unwrap();
let handle = open_anchor(tmp.path()).unwrap();
let outcome = rename_at_no_clobber_via_claim(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("root"),
true,
)
.unwrap();
assert!(
matches!(outcome, PromotionOutcome::LinkedFile),
"a link commit must report the route it took"
);
assert_eq!(fs::read(tmp.path().join("root")).unwrap(), b"payload");
assert!(!tmp.path().join("root.incomplete").exists());
fs::write(tmp.path().join("root.incomplete"), b"payload").unwrap();
let err = rename_at_no_clobber_via_claim(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("taken"),
true,
)
.unwrap_err();
assert_eq!(err.error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(tmp.path().join("taken")).unwrap(), b"existing");
assert!(tmp.path().join("root.incomplete").exists());
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn a_failed_staged_unlink_keeps_both_complete_links_and_reports() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("root.incomplete"), b"payload").unwrap();
let handle = open_anchor(tmp.path()).unwrap();
let outcome = rename_at_no_clobber_via_claim_with(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("root"),
true,
|_, _| {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"unlink refused",
))
},
)
.unwrap();
let error = outcome
.into_staged_link_error()
.expect("the staged unlink failure must be reported");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(
tmp.path().join("root").exists(),
"the complete final link must not be withdrawn"
);
assert_eq!(fs::read(tmp.path().join("root")).unwrap(), b"payload");
assert_eq!(
fs::read(tmp.path().join("root.incomplete")).unwrap(),
b"payload"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn a_renamed_staged_link_keeps_the_link_count_check_after_not_found() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("root.incomplete"), b"payload").unwrap();
let handle = open_anchor(tmp.path()).unwrap();
let outcome = rename_at_no_clobber_via_claim_with(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("root"),
true,
|dir, from| {
dir.rename(from, dir, "moved-staging-link")?;
dir.remove_file(from)
},
)
.unwrap();
assert!(matches!(outcome, PromotionOutcome::LinkedFile));
assert!(outcome.into_staged_link_error().is_none());
assert_eq!(fs::read(tmp.path().join("root")).unwrap(), b"payload");
assert_eq!(
fs::read(tmp.path().join("moved-staging-link")).unwrap(),
b"payload"
);
assert!(!tmp.path().join("root.incomplete").exists());
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn a_failed_staged_unlink_never_removes_a_replaced_final_entry() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("root.incomplete"), b"payload").unwrap();
let handle = open_anchor(tmp.path()).unwrap();
let outcome = rename_at_no_clobber_via_claim_with(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("root"),
true,
|dir, _| {
dir.remove_file("root")?;
let mut options = cap_std::fs::OpenOptions::new();
options.write(true).create_new(true);
let mut replacement = dir.open_with("root", &options)?;
use std::io::Write as _;
replacement.write_all(b"replacement")?;
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"delayed staged unlink refused",
))
},
)
.unwrap();
assert!(outcome.into_staged_link_error().is_some());
assert_eq!(fs::read(tmp.path().join("root")).unwrap(), b"replacement");
assert_eq!(
fs::read(tmp.path().join("root.incomplete")).unwrap(),
b"payload"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn via_claim_promotes_directory_root() {
let tmp = tempfile::TempDir::new().unwrap();
let staged = tmp.path().join("root.incomplete");
fs::create_dir(&staged).unwrap();
fs::write(staged.join("inner.txt"), b"alpha").unwrap();
let handle = open_anchor(tmp.path()).unwrap();
rename_at_no_clobber_via_claim(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("root"),
false,
)
.unwrap();
assert_eq!(
fs::read(tmp.path().join("root").join("inner.txt")).unwrap(),
b"alpha"
);
assert!(!staged.exists());
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn via_claim_refuses_existing_target() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("staged-file"), b"new").unwrap();
fs::create_dir(tmp.path().join("staged-dir")).unwrap();
fs::write(tmp.path().join("taken-file"), b"existing").unwrap();
fs::create_dir(tmp.path().join("taken-dir")).unwrap();
let handle = open_anchor(tmp.path()).unwrap();
for (from, to, is_file) in [
("staged-file", "taken-file", true),
("staged-file", "taken-dir", true),
("staged-dir", "taken-file", false),
("staged-dir", "taken-dir", false),
] {
let err =
rename_at_no_clobber_via_claim(&handle, OsStr::new(from), OsStr::new(to), is_file)
.unwrap_err();
assert_eq!(
err.error.kind(),
io::ErrorKind::AlreadyExists,
"{from} -> {to}"
);
}
assert_eq!(
fs::read(tmp.path().join("taken-file")).unwrap(),
b"existing"
);
assert_eq!(fs::read(tmp.path().join("staged-file")).unwrap(), b"new");
assert!(tmp.path().join("staged-dir").is_dir());
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn via_claim_leaves_its_claim_when_the_rename_fails() {
for is_file in [true, false] {
let tmp = tempfile::TempDir::new().unwrap();
let handle = open_anchor(tmp.path()).unwrap();
let err = rename_at_no_clobber_via_claim(
&handle,
OsStr::new("missing.incomplete"),
OsStr::new("root"),
is_file,
)
.unwrap_err();
assert_eq!(err.error.kind(), io::ErrorKind::NotFound);
assert!(
tmp.path().join("root").exists(),
"the claim is left at the final name (is_file={is_file})"
);
assert!(
err.claim_left,
"a name left occupied must be reported (is_file={is_file})"
);
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn via_claim_anchors_to_handle_across_path_swap() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
fs::write(out.join("root.incomplete"), b"real").unwrap();
let handle = open_anchor(&out).unwrap();
let moved = tmp.path().join("out.moved");
fs::rename(&out, &moved).unwrap();
fs::create_dir(&out).unwrap();
fs::write(out.join("root.incomplete"), b"attacker").unwrap();
rename_at_no_clobber_via_claim(
&handle,
OsStr::new("root.incomplete"),
OsStr::new("root"),
true,
)
.unwrap();
assert_eq!(fs::read(moved.join("root")).unwrap(), b"real");
assert!(!moved.join("root.incomplete").exists());
assert!(!out.join("root").exists());
assert_eq!(fs::read(out.join("root.incomplete")).unwrap(), b"attacker");
}
#[cfg(windows)]
fn require_or_skip<T>(label: &str, result: io::Result<T>) -> Option<T> {
match result {
Ok(t) => Some(t),
Err(e) => {
if std::env::var_os("FERROCRYPT_REQUIRE_WINDOWS_SYMLINK_TESTS").is_some() {
panic!("{label} required by CI but failed: {e}");
}
eprintln!(
"{label} unavailable ({e}); skipping. \
Set FERROCRYPT_REQUIRE_WINDOWS_SYMLINK_TESTS=1 in CI to fail closed."
);
None
}
}
}
#[cfg(windows)]
#[test]
fn open_dir_at_rel_rejects_windows_symlink_dir() {
use std::os::windows::fs as win_fs;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("target_dir");
fs::create_dir_all(&target).unwrap();
if require_or_skip(
"symlink_dir",
win_fs::symlink_dir(&target, tmp.path().join("link")),
)
.is_none()
{
return;
}
let parent = open_anchor(tmp.path()).unwrap();
let _err = open_dir_at_rel(&parent, Path::new("link")).unwrap_err();
}
#[cfg(windows)]
#[test]
fn walk_through_windows_symlink_dir_rejects() {
use std::os::windows::fs as win_fs;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("root");
fs::create_dir_all(root.join("real")).unwrap();
if require_or_skip(
"symlink_dir",
win_fs::symlink_dir("real", root.join("link")),
)
.is_none()
{
return;
}
let parent = open_anchor(&root).unwrap();
let result = walk_to_parent(&parent, Path::new("link/file.txt"), WalkSide::Extraction);
assert!(
result.is_err(),
"hardened walk MUST reject Windows symlink-dir in path"
);
assert!(
!root.join("real/file.txt").exists(),
"real subdir must remain empty"
);
}
#[cfg(windows)]
#[test]
fn create_file_at_rejects_windows_symlink_file() {
use std::os::windows::fs as win_fs;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("target.txt");
fs::write(&target, "original").unwrap();
if require_or_skip(
"symlink_file",
win_fs::symlink_file(&target, tmp.path().join("link.txt")),
)
.is_none()
{
return;
}
let parent = open_anchor(tmp.path()).unwrap();
let err =
create_file_at(&parent, OsStr::new("link.txt"), INITIAL_FILE_CREATE_MODE).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read_to_string(&target).unwrap(), "original");
}
#[cfg(windows)]
#[test]
fn open_dir_at_rel_rejects_windows_junction() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("target");
fs::create_dir_all(&target).unwrap();
let junction = tmp.path().join("junction");
if require_or_skip("mklink /J", try_make_junction(&target, &junction)).is_none() {
return;
}
let parent = open_anchor(tmp.path()).unwrap();
let err = open_dir_at_rel(&parent, Path::new("junction")).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Symlink in extraction path"),
"junction must be rejected with the symlink-path diagnostic, got: {msg}"
);
}
#[cfg(windows)]
#[test]
fn walk_through_windows_junction_rejects() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("root");
fs::create_dir_all(root.join("real")).unwrap();
let junction = root.join("link");
if require_or_skip(
"mklink /J",
try_make_junction(&root.join("real"), &junction),
)
.is_none()
{
return;
}
let parent = open_anchor(&root).unwrap();
let result = walk_to_parent(&parent, Path::new("link/file.txt"), WalkSide::Extraction);
assert!(
result.is_err(),
"hardened walk MUST reject NTFS junction in path"
);
assert!(
!root.join("real/file.txt").exists(),
"real subdir must remain empty after junction-walk rejection"
);
}
}