use std::ffi::{OsStr, OsString};
use std::io;
use std::path::{Component, Path};
use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
use cap_std::ambient_authority;
use cap_std::fs::{Dir, File, OpenOptions};
use crate::CryptoError;
#[cfg(unix)]
const DIR_CREATE_MODE: u32 = 0o700;
pub(crate) const INITIAL_FILE_CREATE_MODE: u32 = 0o600;
#[cfg(windows)]
pub(super) const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
const SYMLINK_IN_EXTRACTION_PATH: &str = "Symlink in extraction path";
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)
}
#[cfg(windows)]
fn reject_reparse_point(dir: &Dir, name: &OsStr) -> Result<(), CryptoError> {
use cap_std::fs::MetadataExt;
let meta = dir.dir_metadata().map_err(CryptoError::Io)?;
reject_reparse_attributes(meta.file_attributes(), name)
}
#[cfg(windows)]
fn reject_reparse_attributes(file_attributes: u32, name: &OsStr) -> Result<(), CryptoError> {
if file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(CryptoError::InvalidInput(format!(
"{SYMLINK_IN_EXTRACTION_PATH}: {}",
Path::new(name).display()
)));
}
Ok(())
}
fn finalize_dir_open(dir: Dir, _name: &OsStr) -> Result<Dir, CryptoError> {
#[cfg(windows)]
reject_reparse_point(&dir, _name)?;
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}: {diagnostic}"));
}
}
CryptoError::Io(e)
}
pub(crate) fn ensure_dir(parent: &Dir, name: &OsStr) -> Result<Dir, CryptoError> {
match parent.open_dir_nofollow(name) {
Ok(dir) => finalize_dir_open(dir, name),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
create_dir_with_default_mode(parent, name)
}
Err(e) => Err(classify_open_failure(
parent,
name,
e,
SYMLINK_IN_EXTRACTION_PATH,
&Path::new(name).display().to_string(),
)),
}
}
pub(crate) fn mkdir_strict(parent: &Dir, name: &OsStr) -> Result<Dir, CryptoError> {
create_dir_with_default_mode(parent, name)
}
fn create_dir_with_default_mode(parent: &Dir, name: &OsStr) -> Result<Dir, CryptoError> {
create_dir_initial_mode(parent, name)?;
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)
}
#[cfg(unix)]
fn create_dir_initial_mode(parent: &Dir, name: &OsStr) -> Result<(), CryptoError> {
use cap_std::fs::{DirBuilder, DirBuilderExt};
let mut builder = DirBuilder::new();
builder.mode(DIR_CREATE_MODE);
parent
.create_dir_with(name, &builder)
.map_err(CryptoError::Io)
}
#[cfg(not(unix))]
fn create_dir_initial_mode(parent: &Dir, name: &OsStr) -> Result<(), CryptoError> {
parent.create_dir(name).map_err(CryptoError::Io)
}
#[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) -> Result<(Dir, OsString), CryptoError> {
let mut components: Vec<Component<'_>> = rel.components().collect();
let last = components.pop().ok_or(CryptoError::InternalInvariant(
"Internal error: 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 = ensure_dir(&cur, name)?;
}
Ok((cur, final_name))
}
pub(crate) fn walk_to_parent_readonly(
root: &Dir,
rel: &Path,
) -> Result<(Dir, OsString), CryptoError> {
let mut components: Vec<Component<'_>> = rel.components().collect();
let last = components.pop().ok_or(CryptoError::InternalInvariant(
"Internal error: 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)?;
let opened = cur.open_dir_nofollow(name).map_err(|e| {
classify_open_failure(
&cur,
name,
e,
SYMLINK_IN_ARCHIVE_SOURCE,
&Path::new(name).display().to_string(),
)
})?;
cur = finalize_dir_open(opened, name)?;
}
Ok((cur, final_name))
}
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)?;
let opened = cur.open_dir_nofollow(name).map_err(|e| {
classify_open_failure(
&cur,
name,
e,
SYMLINK_IN_EXTRACTION_PATH,
&Path::new(name).display().to_string(),
)
})?;
cur = finalize_dir_open(opened, 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);
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)
}
#[cfg(windows)]
fn finalize_file_open(file: File, name: &OsStr) -> Result<File, CryptoError> {
use cap_std::fs::MetadataExt;
let meta = file.metadata().map_err(CryptoError::Io)?;
reject_reparse_attributes(meta.file_attributes(), name)?;
Ok(file)
}
#[cfg(not(windows))]
fn finalize_file_open(file: File, _name: &OsStr) -> Result<File, CryptoError> {
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: {}",
full.display()
))),
}
}
pub(crate) fn create_file_at(
parent: &Dir,
name: &OsStr,
#[cfg_attr(not(unix), allow(unused_variables))] create_mode: u32,
) -> io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
options.follow(FollowSymlinks::No);
#[cfg(unix)]
{
use cap_fs_ext::OpenOptionsExt;
options.mode(create_mode & super::PERMISSION_BITS_MASK);
}
parent.open_with(name, &options)
}
#[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(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
use std::fs;
use std::path::Path;
#[cfg(unix)]
#[test]
fn ensure_dir_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 = ensure_dir(&parent, OsStr::new("evil")).unwrap_err();
assert!(
err.to_string().contains("Symlink in extraction path"),
"expected symlink-path diagnostic, got: {err}"
);
}
#[cfg(unix)]
#[test]
fn ensure_dir_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 = ensure_dir(&parent, OsStr::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")).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")).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 ensure_dir_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 = ensure_dir(&parent, OsStr::new("escape"));
assert!(result.is_err());
assert!(outside.read_dir().unwrap().next().is_none());
}
#[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"));
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");
}
#[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 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(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)]
fn try_make_junction(target: &Path, junction: &Path) -> io::Result<()> {
let status = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(junction)
.arg(target)
.status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::other(format!(
"mklink /J failed with exit code {status}"
)))
}
}
#[cfg(windows)]
#[test]
fn ensure_dir_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 = ensure_dir(&parent, OsStr::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"));
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 ensure_dir_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 = ensure_dir(&parent, OsStr::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"));
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"
);
}
}