use std::ffi::OsStr;
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::sync::Arc;
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use nix::fcntl::{AT_FDCWD, AtFlags, OFlag, openat, readlinkat};
use nix::sys::stat::{FileStat, Mode, fchmod, fstat, fstatat, futimens, mkdirat};
use nix::sys::time::TimeSpec;
use nix::unistd::{Gid, Uid, UnlinkatFlags, fchown, fchownat, linkat, symlinkat, unlinkat};
use crate::walk::EntryKind;
static STRICT_OPERAND_RESOLUTION: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn enable_strict_operand_resolution() {
STRICT_OPERAND_RESOLUTION.store(true, std::sync::atomic::Ordering::Release);
}
#[must_use]
pub fn strict_operand_resolution() -> bool {
STRICT_OPERAND_RESOLUTION.load(std::sync::atomic::Ordering::Acquire)
}
#[cfg(target_os = "linux")]
fn openat2_no_symlinks(path: &Path, flags: OFlag) -> nix::Result<OwnedFd> {
use nix::fcntl::{OpenHow, ResolveFlag, openat2};
let how = OpenHow::new()
.flags(flags)
.resolve(ResolveFlag::RESOLVE_NO_SYMLINKS);
let mut attempts = 0;
loop {
match openat2(AT_FDCWD, path, how) {
Err(nix::errno::Errno::EAGAIN) if attempts < 4 => attempts += 1,
other => return other,
}
}
}
#[cfg(target_os = "linux")]
#[must_use]
pub fn openat2_available() -> bool {
static PROBE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*PROBE.get_or_init(|| {
!matches!(
openat2_no_symlinks(Path::new("/"), OFlag::O_PATH | OFlag::O_CLOEXEC),
Err(nix::errno::Errno::ENOSYS)
)
})
}
#[cfg(not(target_os = "linux"))]
#[must_use]
pub fn openat2_available() -> bool {
false
}
#[derive(Clone, Debug)]
pub struct FileMeta {
uid: u32,
gid: u32,
atime: i64,
atime_nsec: i64,
mtime: i64,
mtime_nsec: i64,
ctime: i64,
ctime_nsec: i64,
mode: u32,
size: u64,
}
impl FileMeta {
fn from_stat(st: &FileStat) -> Self {
Self {
uid: st.st_uid,
gid: st.st_gid,
atime: st.st_atime,
atime_nsec: st.st_atime_nsec,
mtime: st.st_mtime,
mtime_nsec: st.st_mtime_nsec,
ctime: st.st_ctime,
ctime_nsec: st.st_ctime_nsec,
mode: st.st_mode,
size: st.st_size as u64,
}
}
}
impl crate::preserve::Metadata for FileMeta {
fn uid(&self) -> u32 {
self.uid
}
fn gid(&self) -> u32 {
self.gid
}
fn atime(&self) -> i64 {
self.atime
}
fn atime_nsec(&self) -> i64 {
self.atime_nsec
}
fn mtime(&self) -> i64 {
self.mtime
}
fn mtime_nsec(&self) -> i64 {
self.mtime_nsec
}
fn permissions(&self) -> std::fs::Permissions {
use std::os::unix::fs::PermissionsExt;
std::fs::Permissions::from_mode(self.mode)
}
fn ctime(&self) -> i64 {
self.ctime
}
fn ctime_nsec(&self) -> i64 {
self.ctime_nsec
}
fn size(&self) -> u64 {
self.size
}
}
fn kind_from_stat(st: &FileStat) -> EntryKind {
let mode = st.st_mode;
let ifmt = mode & libc::S_IFMT;
match ifmt {
libc::S_IFREG => EntryKind::File,
libc::S_IFDIR => EntryKind::Dir,
libc::S_IFLNK => EntryKind::Symlink,
_ => EntryKind::Special,
}
}
#[derive(Debug)]
pub struct Handle {
fd: OwnedFd,
kind: EntryKind,
dev: u64,
ino: u64,
meta: FileMeta,
}
impl Handle {
#[must_use]
pub fn kind(&self) -> EntryKind {
self.kind
}
#[must_use]
pub fn dev(&self) -> u64 {
self.dev
}
#[must_use]
pub fn ino(&self) -> u64 {
self.ino
}
#[must_use]
pub fn meta(&self) -> &FileMeta {
&self.meta
}
#[must_use]
pub fn as_fd(&self) -> BorrowedFd<'_> {
self.fd.as_fd()
}
pub fn try_clone(&self) -> std::io::Result<Handle> {
Ok(Handle {
fd: self.fd.try_clone()?,
kind: self.kind,
dev: self.dev,
ino: self.ino,
meta: self.meta.clone(),
})
}
pub async fn read_symlink(
&self,
side: congestion::Side,
) -> std::io::Result<(std::path::PathBuf, FileMeta)> {
let target = read_link_handle(self, side).await?;
Ok((target, self.meta.clone()))
}
}
#[derive(Debug)]
pub struct Dir {
fd: Arc<OwnedFd>,
side: congestion::Side,
}
impl Dir {
#[must_use]
pub fn side(&self) -> congestion::Side {
self.side
}
pub async fn open_root_dir(
path: &Path,
dereference: bool,
side: congestion::Side,
) -> std::io::Result<Dir> {
let path = path.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
let mode = Mode::empty();
#[cfg(target_os = "linux")]
{
if strict_operand_resolution() {
if dereference {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--dereference cannot be combined with strict operand resolution",
));
}
return openat2_no_symlinks(&path, flags)
.map(|fd| Dir {
fd: Arc::new(fd),
side,
})
.map_err(nix_to_io);
}
}
match openat(AT_FDCWD, &path, flags, mode) {
Ok(fd) => Ok(Dir {
fd: Arc::new(fd),
side,
}),
Err(nix::errno::Errno::ELOOP) if dereference => {
let follow_flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
openat(AT_FDCWD, &path, follow_flags, mode)
.map(|fd| Dir {
fd: Arc::new(fd),
side,
})
.map_err(nix_to_io)
}
Err(e) => Err(nix_to_io(e)),
}
})
.await
}
pub async fn open_parent_dir(
path: &Path,
side: congestion::Side,
) -> std::io::Result<TrustedDir> {
let path = path.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
#[cfg(target_os = "linux")]
{
if strict_operand_resolution() {
return openat2_no_symlinks(&path, flags)
.map(|fd| {
TrustedDir(Dir {
fd: Arc::new(fd),
side,
})
})
.map_err(nix_to_io);
}
}
openat(AT_FDCWD, &path, flags, Mode::empty())
.map(|fd| {
TrustedDir(Dir {
fd: Arc::new(fd),
side,
})
})
.map_err(nix_to_io)
})
.await
}
pub async fn open_dir(&self, name: &OsStr) -> std::io::Result<Dir> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let dir = self.fd.clone();
let side = self.side;
let name = name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
openat(dir.as_fd(), name.as_bytes(), flags, Mode::empty())
.map(|fd| Dir {
fd: Arc::new(fd),
side,
})
.map_err(nix_to_io)
})
.await
}
pub async fn open_file_read(&self, name: &OsStr) -> std::io::Result<(std::fs::File, FileMeta)> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let dir = self.fd.clone();
let side = self.side;
let name = name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
let flags = OFlag::O_RDONLY | OFlag::O_NOFOLLOW | OFlag::O_NONBLOCK | OFlag::O_CLOEXEC;
let fd =
openat(dir.as_fd(), name.as_bytes(), flags, Mode::empty()).map_err(nix_to_io)?;
let st = fstat(&fd).map_err(nix_to_io)?;
if kind_from_stat(&st) != EntryKind::File {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let meta = FileMeta::from_stat(&st);
let file = std::fs::File::from(fd);
Ok((file, meta))
})
.await
}
pub async fn meta(&self) -> std::io::Result<FileMeta> {
let dir = self.fd.clone();
let side = self.side;
run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
let st = fstat(dir.as_fd()).map_err(nix_to_io)?;
Ok(FileMeta::from_stat(&st))
})
.await
}
pub async fn child(&self, name: &OsStr) -> std::io::Result<Handle> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let dir = self.fd.clone();
let side = self.side;
let name = name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
let flags = OFlag::O_PATH | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
let fd =
openat(dir.as_fd(), name.as_bytes(), flags, Mode::empty()).map_err(nix_to_io)?;
let st = fstatat(&fd, "", AtFlags::AT_EMPTY_PATH).map_err(nix_to_io)?;
let kind = kind_from_stat(&st);
let dev = st.st_dev;
let ino = st.st_ino;
let meta = FileMeta::from_stat(&st);
Ok(Handle {
fd,
kind,
dev,
ino,
meta,
})
})
.await
}
pub async fn recheck(&self, name: &OsStr, expected: &Handle) -> std::io::Result<Handle> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let fresh = self.child(name).await?;
if fresh.dev() == expected.dev() && fresh.ino() == expected.ino() {
Ok(fresh)
} else {
Err(std::io::Error::from_raw_os_error(libc::ESTALE))
}
}
pub async fn make_dir(&self, name: &OsStr, mode: u32) -> std::io::Result<Dir> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let dir = self.fd.clone();
let side = self.side;
let name_owned = name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::MkDir, move || {
mkdirat(
dir.as_fd(),
name_owned.as_bytes(),
Mode::from_bits_truncate(mode),
)
.map_err(nix_to_io)
})
.await?;
self.open_dir(name).await
}
pub async fn read_entries(
&self,
) -> std::io::Result<Vec<(std::ffi::OsString, Option<EntryKind>)>> {
throttle::get_ops_token().await;
let dir = self.fd.clone();
tokio::task::spawn_blocking(move || {
let dup_raw: RawFd =
nix::fcntl::fcntl(dir.as_fd(), nix::fcntl::FcntlArg::F_DUPFD_CLOEXEC(0))
.map_err(nix_to_io)?;
let dup_owned = unsafe { OwnedFd::from_raw_fd(dup_raw) };
let mut nix_dir = nix::dir::Dir::from_fd(dup_owned).map_err(nix_to_io)?;
let mut entries = Vec::new();
for entry_result in nix_dir.iter() {
let entry = entry_result.map_err(nix_to_io)?;
let name_cstr = entry.file_name();
if name_cstr == c"." || name_cstr == c".." {
continue;
}
let name = std::ffi::OsStr::from_bytes(name_cstr.to_bytes()).to_owned();
let kind = entry.file_type().map(|t| match t {
nix::dir::Type::Directory => EntryKind::Dir,
nix::dir::Type::Symlink => EntryKind::Symlink,
nix::dir::Type::File => EntryKind::File,
_ => EntryKind::Special,
});
entries.push((name, kind));
}
Ok(entries)
})
.await
.map_err(std::io::Error::other)?
}
pub async fn unlink_at(&self, name: &OsStr) -> std::io::Result<()> {
self.unlink_at_on(name, self.side).await
}
pub(crate) async fn unlink_at_on(
&self,
name: &OsStr,
side: congestion::Side,
) -> std::io::Result<()> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let dir = self.fd.clone();
let name = name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::Unlink, move || {
unlinkat(dir.as_fd(), name.as_bytes(), UnlinkatFlags::NoRemoveDir).map_err(nix_to_io)
})
.await
}
pub async fn rmdir_at(&self, name: &OsStr) -> std::io::Result<()> {
self.rmdir_at_on(name, self.side).await
}
pub(crate) async fn rmdir_at_on(
&self,
name: &OsStr,
side: congestion::Side,
) -> std::io::Result<()> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let dir = self.fd.clone();
let name = name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::RmDir, move || {
unlinkat(dir.as_fd(), name.as_bytes(), UnlinkatFlags::RemoveDir).map_err(nix_to_io)
})
.await
}
pub async fn symlink_at(&self, name: &OsStr, target: &Path) -> std::io::Result<Handle> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let dir = self.fd.clone();
let side = self.side;
let name = name.to_owned();
let target = target.to_owned();
let name_for_child = name.clone();
run_metadata_probed_blocking(side, congestion::MetadataOp::Symlink, move || {
symlinkat(target.as_os_str().as_bytes(), dir.as_fd(), name.as_bytes())
.map_err(nix_to_io)
})
.await?;
let handle = self.child(&name_for_child).await?;
if handle.kind() != EntryKind::Symlink {
return Err(std::io::Error::from_raw_os_error(libc::ENOENT));
}
Ok(handle)
}
pub async fn read_link_at(&self, name: &OsStr) -> std::io::Result<std::path::PathBuf> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let dir = self.fd.clone();
let side = self.side;
let name = name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::ReadLink, move || {
readlinkat(dir.as_fd(), name.as_bytes())
.map(std::path::PathBuf::from)
.map_err(nix_to_io)
})
.await
}
pub async fn hard_link_at(
&self,
name: &OsStr,
dst: &Dir,
dst_name: &OsStr,
) -> std::io::Result<()> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
if !is_single_component(dst_name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let src_dir = self.fd.clone();
let dst_dir = dst.fd.clone();
let side = dst.side;
let name = name.to_owned();
let dst_name = dst_name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::HardLink, move || {
linkat(
src_dir.as_fd(),
name.as_bytes(),
dst_dir.as_fd(),
dst_name.as_bytes(),
AtFlags::empty(),
)
.map_err(nix_to_io)
})
.await
}
pub async fn hard_link_handle_at(
&self,
src_handle: &Handle,
dst_name: &OsStr,
) -> std::io::Result<()> {
if !is_single_component(dst_name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let src_owned = src_handle.as_fd().try_clone_to_owned()?;
let dst_dir = self.fd.clone();
let side = self.side;
let dst_name = dst_name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::HardLink, move || {
let proc_path = format!("/proc/self/fd/{}", src_owned.as_raw_fd());
linkat(
AT_FDCWD,
proc_path.as_str(),
dst_dir.as_fd(),
dst_name.as_bytes(),
AtFlags::AT_SYMLINK_FOLLOW,
)
.map_err(nix_to_io)
})
.await
}
pub async fn create_file(&self, name: &OsStr, mode: u32) -> std::io::Result<std::fs::File> {
if !is_single_component(name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let dir = self.fd.clone();
let side = self.side;
let name = name.to_owned();
run_metadata_probed_blocking(side, congestion::MetadataOp::OpenCreate, move || {
let flags = OFlag::O_CREAT
| OFlag::O_EXCL
| OFlag::O_WRONLY
| OFlag::O_NOFOLLOW
| OFlag::O_CLOEXEC;
let file_mode = Mode::from_bits_truncate(mode);
openat(dir.as_fd(), name.as_bytes(), flags, file_mode)
.map(std::fs::File::from)
.map_err(nix_to_io)
})
.await
}
}
#[derive(Debug)]
pub struct TrustedDir(Dir);
impl TrustedDir {
#[must_use]
pub fn into_tree(self) -> Dir {
self.0
}
}
fn split_parent_and_name(path: &Path) -> Option<(&Path, &OsStr)> {
let name = path.file_name()?;
let parent = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
Some((parent, name))
}
pub async fn strict_probe_dst_kind(
path: &Path,
side: congestion::Side,
) -> std::io::Result<Option<EntryKind>> {
let Some((parent, name)) = split_parent_and_name(path) else {
return Ok(None);
};
match Dir::open_parent_dir(parent, side).await {
Ok(parent) => match parent.into_tree().child(name).await {
Ok(handle) => Ok(Some(handle.kind())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
},
Err(err)
if err.kind() == std::io::ErrorKind::NotFound
|| err.raw_os_error() == Some(libc::ENOTDIR) =>
{
Ok(None)
}
Err(err) => Err(err),
}
}
async fn fchown_fd(
fd: BorrowedFd<'_>,
side: congestion::Side,
uid: Option<u32>,
gid: Option<u32>,
) -> std::io::Result<()> {
let owned = fd.try_clone_to_owned()?;
run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
fchown(
owned.as_fd(),
uid.map(Uid::from_raw),
gid.map(Gid::from_raw),
)
.map_err(nix_to_io)
})
.await
}
async fn fchmod_fd(fd: BorrowedFd<'_>, side: congestion::Side, mode: u32) -> std::io::Result<()> {
let owned = fd.try_clone_to_owned()?;
run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
fchmod(owned.as_fd(), Mode::from_bits_truncate(mode)).map_err(nix_to_io)
})
.await
}
async fn futimens_fd(
fd: BorrowedFd<'_>,
side: congestion::Side,
atime: i64,
atime_nsec: i64,
mtime: i64,
mtime_nsec: i64,
) -> std::io::Result<()> {
let owned = fd.try_clone_to_owned()?;
run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
let atime_spec = TimeSpec::new(atime, atime_nsec);
let mtime_spec = TimeSpec::new(mtime, mtime_nsec);
futimens(owned.as_fd(), &atime_spec, &mtime_spec).map_err(nix_to_io)
})
.await
}
pub(crate) async fn fchown_handle(
handle: &Handle,
side: congestion::Side,
uid: Option<u32>,
gid: Option<u32>,
) -> std::io::Result<()> {
let owned = handle.as_fd().try_clone_to_owned()?;
run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
fchownat(
owned.as_fd(),
"",
uid.map(Uid::from_raw),
gid.map(Gid::from_raw),
AtFlags::AT_EMPTY_PATH | AtFlags::AT_SYMLINK_NOFOLLOW,
)
.map_err(nix_to_io)
})
.await
}
pub(crate) async fn chmod_via_proc_fd(
handle: &Handle,
side: congestion::Side,
mode: u32,
) -> std::io::Result<()> {
let owned = handle.as_fd().try_clone_to_owned()?;
run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
let proc_path = format!("/proc/self/fd/{}", owned.as_raw_fd());
nix::sys::stat::fchmodat(
AT_FDCWD,
proc_path.as_str(),
Mode::from_bits_truncate(mode),
nix::sys::stat::FchmodatFlags::FollowSymlink,
)
.map_err(nix_to_io)
})
.await
}
pub(crate) fn chmod_via_proc_fd_sync(fd: BorrowedFd<'_>, mode: u32) -> std::io::Result<()> {
let proc_path = format!("/proc/self/fd/{}", fd.as_raw_fd());
nix::sys::stat::fchmodat(
AT_FDCWD,
proc_path.as_str(),
Mode::from_bits_truncate(mode),
nix::sys::stat::FchmodatFlags::FollowSymlink,
)
.map_err(nix_to_io)
}
pub(crate) async fn stat_meta_via_proc_fd(
handle: &Handle,
side: congestion::Side,
) -> std::io::Result<std::fs::Metadata> {
let owned = handle.as_fd().try_clone_to_owned()?;
run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
let proc_path = format!("/proc/self/fd/{}", owned.as_raw_fd());
std::fs::metadata(proc_path)
})
.await
}
pub async fn read_link_handle(
handle: &Handle,
side: congestion::Side,
) -> std::io::Result<std::path::PathBuf> {
use std::os::unix::ffi::OsStringExt;
let owned = handle.as_fd().try_clone_to_owned()?;
run_metadata_probed_blocking(side, congestion::MetadataOp::ReadLink, move || {
let mut buf = vec![0u8; libc::PATH_MAX as usize];
let n = unsafe {
libc::readlinkat(
owned.as_raw_fd(),
c"".as_ptr(),
buf.as_mut_ptr().cast::<libc::c_char>(),
buf.len(),
)
};
if n < 0 {
return Err(std::io::Error::last_os_error());
}
buf.truncate(n as usize);
Ok(std::path::PathBuf::from(std::ffi::OsString::from_vec(buf)))
})
.await
}
async fn symlink_utimes_fd(
handle: &Handle,
side: congestion::Side,
atime: i64,
atime_nsec: i64,
mtime: i64,
mtime_nsec: i64,
) -> std::io::Result<()> {
let owned = handle.as_fd().try_clone_to_owned()?;
run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
let times: [libc::timespec; 2] = [
libc::timespec {
tv_sec: atime,
tv_nsec: atime_nsec,
},
libc::timespec {
tv_sec: mtime,
tv_nsec: mtime_nsec,
},
];
let res = unsafe {
libc::utimensat(
owned.as_raw_fd(),
c"".as_ptr(),
times.as_ptr(),
libc::AT_EMPTY_PATH,
)
};
if res == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
})
.await
}
pub async fn set_file_metadata_fd<Meta: crate::preserve::Metadata>(
settings: &crate::preserve::Settings,
meta: &Meta,
fd: BorrowedFd<'_>,
side: congestion::Side,
) -> std::io::Result<()> {
let ut = &settings.file.user_and_time;
if ut.uid || ut.gid {
let uid = if ut.uid { Some(meta.uid()) } else { None };
let gid = if ut.gid { Some(meta.gid()) } else { None };
fchown_fd(fd, side, uid, gid).await?;
}
let mode = crate::preserve::masked_mode(settings.file.mode_mask, meta);
fchmod_fd(fd, side, mode).await?;
if ut.time {
futimens_fd(
fd,
side,
meta.atime(),
meta.atime_nsec(),
meta.mtime(),
meta.mtime_nsec(),
)
.await?;
}
Ok(())
}
pub async fn set_dir_metadata_fd<Meta: crate::preserve::Metadata>(
settings: &crate::preserve::Settings,
meta: &Meta,
dir: &Dir,
) -> std::io::Result<()> {
let side = dir.side();
let fd = dir.fd.as_fd();
let ut = &settings.dir.user_and_time;
if ut.uid || ut.gid {
let uid = if ut.uid { Some(meta.uid()) } else { None };
let gid = if ut.gid { Some(meta.gid()) } else { None };
fchown_fd(fd, side, uid, gid).await?;
}
let mode = crate::preserve::masked_mode(settings.dir.mode_mask, meta);
fchmod_fd(fd, side, mode).await?;
if ut.time {
futimens_fd(
fd,
side,
meta.atime(),
meta.atime_nsec(),
meta.mtime(),
meta.mtime_nsec(),
)
.await?;
}
Ok(())
}
pub async fn set_symlink_metadata_fd<Meta: crate::preserve::Metadata>(
settings: &crate::preserve::Settings,
meta: &Meta,
handle: &Handle,
side: congestion::Side,
) -> std::io::Result<()> {
let ut = &settings.symlink.user_and_time;
if ut.uid || ut.gid {
let uid = if ut.uid { Some(meta.uid()) } else { None };
let gid = if ut.gid { Some(meta.gid()) } else { None };
fchown_handle(handle, side, uid, gid).await?;
}
if ut.time {
symlink_utimes_fd(
handle,
side,
meta.atime(),
meta.atime_nsec(),
meta.mtime(),
meta.mtime_nsec(),
)
.await?;
}
Ok(())
}
async fn run_metadata_probed_blocking<F, T>(
side: congestion::Side,
op: congestion::MetadataOp,
f: F,
) -> std::io::Result<T>
where
F: FnOnce() -> std::io::Result<T> + Send + 'static,
T: Send + 'static,
{
crate::walk::run_metadata_probed(side, op, async {
tokio::task::spawn_blocking(f)
.await
.map_err(std::io::Error::other)?
})
.await
}
fn nix_to_io(e: nix::errno::Errno) -> std::io::Error {
std::io::Error::from_raw_os_error(e as i32)
}
fn is_single_component(name: &OsStr) -> bool {
if name.is_empty() || name == "." || name == ".." {
return false;
}
!name.as_bytes().contains(&b'/')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::preserve::Metadata;
use crate::testutils;
use std::io::Read;
#[tokio::test]
async fn child_classifies_file_dir_symlink_and_rejects_nofollow() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
assert_eq!(
root.child(OsStr::new("0.txt")).await?.kind(),
EntryKind::File
);
assert_eq!(root.child(OsStr::new("bar")).await?.kind(), EntryKind::Dir);
tokio::fs::symlink("0.txt", tmp.join("foo/lnk")).await?;
assert_eq!(
root.child(OsStr::new("lnk")).await?.kind(),
EntryKind::Symlink
);
tokio::fs::symlink("/etc", tmp.join("foo/evil")).await?;
assert!(root.open_dir(OsStr::new("evil")).await.is_err());
Ok(())
}
#[tokio::test]
async fn open_dir_succeeds_on_real_directory() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let bar = root.open_dir(OsStr::new("bar")).await?;
assert_eq!(
bar.child(OsStr::new("1.txt")).await?.kind(),
EntryKind::File
);
Ok(())
}
#[tokio::test]
async fn open_parent_dir_follows_symlinked_prefix_but_descendants_stay_hardened()
-> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
tokio::fs::symlink("foo", tmp.join("foo_link")).await?;
let parent = Dir::open_parent_dir(&tmp.join("foo_link"), congestion::Side::Source).await?;
let tree = parent.into_tree();
assert_eq!(
tree.child(OsStr::new("0.txt")).await?.kind(),
EntryKind::File
);
assert!(
Dir::open_root_dir(&tmp.join("foo_link"), false, congestion::Side::Source)
.await
.is_err()
);
tokio::fs::symlink("/etc", tmp.join("foo/evil_below")).await?;
assert!(tree.open_dir(OsStr::new("evil_below")).await.is_err());
Ok(())
}
#[tokio::test]
async fn rejects_multi_component_names() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
for bad in ["bar/1.txt", "..", ".", ""] {
let child_err = root.child(OsStr::new(bad)).await.unwrap_err();
assert_eq!(child_err.raw_os_error(), Some(libc::EINVAL));
let dir_err = root.open_dir(OsStr::new(bad)).await.unwrap_err();
assert_eq!(dir_err.raw_os_error(), Some(libc::EINVAL));
let file_err = root.open_file_read(OsStr::new(bad)).await.unwrap_err();
assert_eq!(file_err.raw_os_error(), Some(libc::EINVAL));
let create_err = root.create_file(OsStr::new(bad), 0o644).await.unwrap_err();
assert_eq!(create_err.raw_os_error(), Some(libc::EINVAL));
}
Ok(())
}
#[tokio::test]
async fn operations_remain_valid_after_original_dir_dropped() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let shared = Dir {
fd: root.fd.clone(),
side: root.side,
};
drop(root);
assert_eq!(
shared.child(OsStr::new("0.txt")).await?.kind(),
EntryKind::File
);
let bar = shared.open_dir(OsStr::new("bar")).await?;
assert_eq!(
bar.child(OsStr::new("2.txt")).await?.kind(),
EntryKind::File
);
Ok(())
}
#[tokio::test]
async fn open_file_read_reads_regular_file() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let (mut file, meta) = root.open_file_read(OsStr::new("0.txt")).await?;
assert_eq!(meta.size(), 1);
let mut buf = String::new();
file.read_to_string(&mut buf)?;
assert_eq!(buf, "0");
Ok(())
}
#[tokio::test]
async fn open_file_read_rejects_fifo_without_blocking() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let fifo_path = tmp.join("foo/test.fifo");
nix::unistd::mkfifo(
&fifo_path,
nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
)?;
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
root.open_file_read(OsStr::new("test.fifo")),
)
.await;
assert!(result.is_ok(), "open_file_read blocked on FIFO (timed out)");
assert!(
result.unwrap().is_err(),
"open_file_read must reject a FIFO"
);
Ok(())
}
#[tokio::test]
async fn open_file_read_rejects_symlink() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
tokio::fs::symlink("0.txt", tmp.join("foo/link_to_0")).await?;
let result = root.open_file_read(OsStr::new("link_to_0")).await;
assert!(result.is_err(), "open_file_read must reject a symlink");
Ok(())
}
#[tokio::test]
async fn create_file_creates_new_writable_file() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let mut file = root.create_file(OsStr::new("new.txt"), 0o644).await?;
use std::io::Write;
file.write_all(b"hello safedir")?;
drop(file);
let content = std::fs::read(tmp.join("foo/new.txt"))?;
assert_eq!(content, b"hello safedir");
Ok(())
}
#[tokio::test]
async fn create_file_fails_if_exists() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let err = root
.create_file(OsStr::new("0.txt"), 0o644)
.await
.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EEXIST),
"expected EEXIST, got {err:#}"
);
Ok(())
}
#[tokio::test]
async fn make_dir_creates_and_returns_usable_dir() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let sub = root.make_dir(OsStr::new("sub"), 0o755).await?;
sub.create_file(OsStr::new("child.txt"), 0o644).await?;
let entries = sub.read_entries().await?;
let names: Vec<_> = entries
.iter()
.map(|(n, _)| n.to_string_lossy().into_owned())
.collect();
assert!(
names.contains(&"child.txt".to_string()),
"child.txt not found in {names:?}"
);
Ok(())
}
#[tokio::test]
async fn make_dir_rejects_multi_component_names() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
for bad in ["a/b", "..", ".", ""] {
let err = root.make_dir(OsStr::new(bad), 0o755).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EINVAL),
"expected EINVAL for {:?}, got {err:#}",
bad
);
}
Ok(())
}
#[tokio::test]
async fn read_entries_lists_children_with_dtype_hints() -> anyhow::Result<()> {
use std::collections::HashMap;
let tmp = testutils::setup_test_dir().await?;
let fixture = tmp.join("foo/fixture_dir");
tokio::fs::create_dir(&fixture).await?;
tokio::fs::write(fixture.join("afile.txt"), "x").await?;
tokio::fs::create_dir(fixture.join("asubdir")).await?;
tokio::fs::symlink("afile.txt", fixture.join("alink")).await?;
let root = Dir::open_root_dir(&fixture, false, congestion::Side::Source).await?;
let entries = root.read_entries().await?;
let map: HashMap<String, Option<EntryKind>> = entries
.into_iter()
.map(|(n, k)| (n.to_string_lossy().into_owned(), k))
.collect();
assert_eq!(map.len(), 3, "expected 3 entries, got {map:?}");
assert_eq!(
map.get("afile.txt"),
Some(&Some(EntryKind::File)),
"afile.txt wrong"
);
assert_eq!(
map.get("asubdir"),
Some(&Some(EntryKind::Dir)),
"asubdir wrong"
);
assert_eq!(
map.get("alink"),
Some(&Some(EntryKind::Symlink)),
"alink wrong"
);
Ok(())
}
#[tokio::test]
async fn read_entries_does_not_close_self_fd() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let first = root.read_entries().await?;
assert!(!first.is_empty(), "first read_entries returned empty");
let second = root.read_entries().await?;
let mut first_names: Vec<_> = first.iter().map(|(name, _)| name.clone()).collect();
let mut second_names: Vec<_> = second.iter().map(|(name, _)| name.clone()).collect();
first_names.sort();
second_names.sort();
assert_eq!(
first_names, second_names,
"second read_entries differs from first"
);
root.child(OsStr::new("0.txt")).await?;
Ok(())
}
#[tokio::test]
async fn create_file_refuses_existing_symlink() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let link_path = tmp.join("foo/evil_link");
let target_path = tmp.join("foo/should_not_be_created");
tokio::fs::symlink(&target_path, &link_path).await?;
let err = root
.create_file(OsStr::new("evil_link"), 0o644)
.await
.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EEXIST),
"expected EEXIST, got {err:#}"
);
assert!(
!target_path.exists(),
"symlink target was unexpectedly created"
);
Ok(())
}
#[tokio::test]
async fn unlink_at_removes_file() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
root.unlink_at(OsStr::new("0.txt")).await?;
let err = root.child(OsStr::new("0.txt")).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::ENOENT),
"expected ENOENT after unlink, got {err:#}"
);
Ok(())
}
#[tokio::test]
async fn unlink_at_on_symlink_removes_link_not_target() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
tokio::fs::write(tmp.join("foo/sentinel.txt"), b"alive").await?;
tokio::fs::symlink("sentinel.txt", tmp.join("foo/lnk")).await?;
root.unlink_at(OsStr::new("lnk")).await?;
let err = root.child(OsStr::new("lnk")).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::ENOENT),
"expected ENOENT for removed link, got {err:#}"
);
let content = tokio::fs::read(tmp.join("foo/sentinel.txt")).await?;
assert_eq!(content, b"alive", "sentinel.txt was unexpectedly removed");
Ok(())
}
#[tokio::test]
async fn rmdir_at_removes_empty_dir_and_rejects_nonempty() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
tokio::fs::create_dir(tmp.join("foo/empty_sub")).await?;
root.rmdir_at(OsStr::new("empty_sub")).await?;
let err = root.child(OsStr::new("empty_sub")).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::ENOENT),
"expected ENOENT after rmdir, got {err:#}"
);
let err = root.rmdir_at(OsStr::new("bar")).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::ENOTEMPTY),
"expected ENOTEMPTY for non-empty dir, got {err:#}"
);
let err = root.rmdir_at(OsStr::new("0.txt")).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::ENOTDIR),
"expected ENOTDIR for regular file, got {err:#}"
);
Ok(())
}
#[tokio::test]
async fn symlink_at_creates_link_and_returns_pinned_handle() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let target = std::path::Path::new("some/arbitrary/target");
let handle = root.symlink_at(OsStr::new("mylink"), target).await?;
assert_eq!(
handle.kind(),
EntryKind::Symlink,
"symlink_at must return a Symlink handle"
);
let read_back = root.read_link_at(OsStr::new("mylink")).await?;
assert_eq!(
read_back, target,
"read_link_at returned wrong target: {read_back:?}"
);
Ok(())
}
#[tokio::test]
async fn read_link_handle_reads_target_from_pinned_handle() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let target = std::path::Path::new("some/arbitrary/target");
tokio::fs::symlink(target, tmp.join("foo/mylink")).await?;
let handle = root.child(OsStr::new("mylink")).await?;
assert_eq!(handle.kind(), EntryKind::Symlink);
let read_back = read_link_handle(&handle, congestion::Side::Source).await?;
assert_eq!(read_back, target, "wrong target: {read_back:?}");
let file_handle = root.child(OsStr::new("0.txt")).await?;
assert!(
read_link_handle(&file_handle, congestion::Side::Source)
.await
.is_err(),
"read_link_handle on a non-symlink must fail"
);
Ok(())
}
#[tokio::test]
async fn read_symlink_returns_target_and_meta_from_one_handle() -> anyhow::Result<()> {
use crate::preserve::Metadata as _;
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let target = std::path::Path::new("some/target");
tokio::fs::symlink(target, tmp.join("foo/lnk")).await?;
let handle = root.child(OsStr::new("lnk")).await?;
let (read_target, meta) = handle.read_symlink(congestion::Side::Source).await?;
assert_eq!(read_target, target);
assert_eq!(meta.uid(), handle.meta().uid());
assert_eq!(meta.mtime(), handle.meta().mtime());
Ok(())
}
#[tokio::test]
async fn dir_meta_returns_opened_dir_fstat() -> anyhow::Result<()> {
use crate::preserve::Metadata as _;
let tmp = testutils::setup_test_dir().await?;
let bar = Dir::open_root_dir(&tmp.join("foo/bar"), false, congestion::Side::Source).await?;
let meta = bar.meta().await?;
let std_meta = std::fs::metadata(tmp.join("foo/bar"))?;
assert_eq!(meta.uid(), std::os::unix::fs::MetadataExt::uid(&std_meta));
assert_eq!(meta.gid(), std::os::unix::fs::MetadataExt::gid(&std_meta));
Ok(())
}
#[tokio::test]
async fn hard_link_at_creates_hardlink() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
tokio::fs::create_dir(tmp.join("foo/src_sub")).await?;
tokio::fs::create_dir(tmp.join("foo/dst_sub")).await?;
tokio::fs::write(tmp.join("foo/src_sub/orig.txt"), b"hardlink test").await?;
let src =
Dir::open_root_dir(&tmp.join("foo/src_sub"), false, congestion::Side::Source).await?;
let dst = Dir::open_root_dir(
&tmp.join("foo/dst_sub"),
false,
congestion::Side::Destination,
)
.await?;
src.hard_link_at(OsStr::new("orig.txt"), &dst, OsStr::new("link.txt"))
.await?;
let orig_handle = src.child(OsStr::new("orig.txt")).await?;
let link_handle = dst.child(OsStr::new("link.txt")).await?;
assert_eq!(orig_handle.kind(), EntryKind::File, "orig must be a file");
assert_eq!(link_handle.kind(), EntryKind::File, "link must be a file");
assert_eq!(
orig_handle.ino(),
link_handle.ino(),
"hard link must share the inode"
);
Ok(())
}
#[tokio::test]
async fn hard_link_at_does_not_follow_source_symlink() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
tokio::fs::create_dir(tmp.join("foo/src_hl")).await?;
tokio::fs::create_dir(tmp.join("foo/dst_hl")).await?;
tokio::fs::write(tmp.join("foo/src_hl/real.txt"), b"target").await?;
tokio::fs::symlink("real.txt", tmp.join("foo/src_hl/sym.txt")).await?;
let src =
Dir::open_root_dir(&tmp.join("foo/src_hl"), false, congestion::Side::Source).await?;
let dst = Dir::open_root_dir(
&tmp.join("foo/dst_hl"),
false,
congestion::Side::Destination,
)
.await?;
let result = src
.hard_link_at(OsStr::new("sym.txt"), &dst, OsStr::new("new_link.txt"))
.await;
match result {
Ok(()) => {
let new_handle = dst.child(OsStr::new("new_link.txt")).await?;
assert_eq!(
new_handle.kind(),
EntryKind::Symlink,
"hard_link_at must link the symlink itself, not its target"
);
let real_meta = std::fs::metadata(tmp.join("foo/src_hl/real.txt"))?;
use std::os::unix::fs::MetadataExt;
assert_eq!(
real_meta.nlink(),
1,
"real.txt must not gain a new hard link"
);
}
Err(ref e) if e.raw_os_error() == Some(libc::EPERM) => {
let real_meta = std::fs::metadata(tmp.join("foo/src_hl/real.txt"))?;
use std::os::unix::fs::MetadataExt;
assert_eq!(
real_meta.nlink(),
1,
"real.txt must not gain a new hard link"
);
}
Err(e) => {
return Err(anyhow::anyhow!(
"unexpected error from hard_link_at on symlink: {e:#}"
));
}
}
Ok(())
}
#[tokio::test]
async fn hard_link_handle_at_links_pinned_inode_after_name_swap() -> anyhow::Result<()> {
use std::os::unix::fs::MetadataExt;
let tmp = testutils::create_temp_dir().await?;
tokio::fs::create_dir(tmp.join("src")).await?;
tokio::fs::create_dir(tmp.join("dst")).await?;
tokio::fs::write(tmp.join("src/entry"), b"ORIGINAL").await?;
let orig_ino = tokio::fs::metadata(tmp.join("src/entry")).await?.ino();
let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
let dst =
Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
let handle = src.child(OsStr::new("entry")).await?;
assert_eq!(handle.kind(), EntryKind::File);
tokio::fs::write(tmp.join("src/decoy"), b"DECOY_SECRET").await?;
tokio::fs::rename(tmp.join("src/decoy"), tmp.join("src/entry")).await?;
let decoy_ino = tokio::fs::metadata(tmp.join("src/entry")).await?.ino();
assert_ne!(orig_ino, decoy_ino, "decoy must be a different inode");
match dst.hard_link_handle_at(&handle, OsStr::new("linked")).await {
Ok(()) => {
let lm = tokio::fs::symlink_metadata(tmp.join("dst/linked")).await?;
assert!(
lm.file_type().is_file(),
"linked entry must be a regular file"
);
assert_eq!(
lm.ino(),
orig_ino,
"hard_link_handle_at must link the PINNED original inode, never the \
swapped-in decoy (the by-name link would have linked the decoy here)"
);
let content = tokio::fs::read_to_string(tmp.join("dst/linked")).await?;
assert_eq!(
content, "ORIGINAL",
"must reflect the original inode's content"
);
assert_ne!(content, "DECOY_SECRET");
}
Err(e) => {
assert!(
!tmp.join("dst/linked").exists(),
"no destination entry may exist when the link failed closed (got {e:#})"
);
}
}
Ok(())
}
#[tokio::test]
async fn hard_link_handle_at_refuses_directory() -> anyhow::Result<()> {
let tmp = testutils::create_temp_dir().await?;
tokio::fs::create_dir(tmp.join("src")).await?;
tokio::fs::create_dir(tmp.join("dst")).await?;
tokio::fs::create_dir(tmp.join("src/adir")).await?;
let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
let dst =
Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
let dir_handle = src.child(OsStr::new("adir")).await?;
assert_eq!(dir_handle.kind(), EntryKind::Dir);
let result = dst
.hard_link_handle_at(&dir_handle, OsStr::new("linked_dir"))
.await;
assert!(
result.is_err(),
"hard_link_handle_at must refuse to hard-link a directory"
);
assert!(
!tmp.join("dst/linked_dir").exists(),
"no destination entry may be created for a directory hard link"
);
Ok(())
}
#[tokio::test]
async fn hard_link_handle_at_never_links_swapped_in_fifo() -> anyhow::Result<()> {
use std::os::unix::fs::FileTypeExt;
let tmp = testutils::create_temp_dir().await?;
tokio::fs::create_dir(tmp.join("src")).await?;
tokio::fs::create_dir(tmp.join("dst")).await?;
tokio::fs::write(tmp.join("src/entry"), b"REALFILE").await?;
let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
let dst =
Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
let handle = src.child(OsStr::new("entry")).await?;
assert_eq!(handle.kind(), EntryKind::File);
tokio::fs::remove_file(tmp.join("src/entry")).await?;
nix::unistd::mkfifo(
&tmp.join("src/entry"),
nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
)?;
match dst.hard_link_handle_at(&handle, OsStr::new("linked")).await {
Ok(()) => {
let lm = tokio::fs::symlink_metadata(tmp.join("dst/linked")).await?;
assert!(
lm.file_type().is_file(),
"linked entry must be the pinned regular file, never the swapped-in FIFO"
);
assert!(
!lm.file_type().is_fifo(),
"the destination must never be a special (the by-name link would link the FIFO)"
);
let content = tokio::fs::read_to_string(tmp.join("dst/linked")).await?;
assert_eq!(content, "REALFILE");
}
Err(_) => {
assert!(
!tmp.join("dst/linked").exists(),
"no destination entry may exist when the link failed closed"
);
}
}
Ok(())
}
#[tokio::test]
async fn hard_link_handle_at_stable_file_happy_path() -> anyhow::Result<()> {
let tmp = testutils::create_temp_dir().await?;
tokio::fs::create_dir(tmp.join("src")).await?;
tokio::fs::create_dir(tmp.join("dst")).await?;
tokio::fs::write(tmp.join("src/f"), b"STABLE").await?;
let src = Dir::open_root_dir(&tmp.join("src"), false, congestion::Side::Source).await?;
let dst =
Dir::open_root_dir(&tmp.join("dst"), false, congestion::Side::Destination).await?;
let handle = src.child(OsStr::new("f")).await?;
dst.hard_link_handle_at(&handle, OsStr::new("f_link"))
.await?;
let orig = src.child(OsStr::new("f")).await?;
let linked = dst.child(OsStr::new("f_link")).await?;
assert_eq!(linked.kind(), EntryKind::File);
assert_eq!(orig.ino(), linked.ino(), "hard link must share the inode");
let content = tokio::fs::read_to_string(tmp.join("dst/f_link")).await?;
assert_eq!(content, "STABLE");
Ok(())
}
#[tokio::test]
async fn set_file_metadata_fd_applies_owner_mode_time() -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let tmp = testutils::setup_test_dir().await?;
let src_path = tmp.join("foo/src_meta.txt");
tokio::fs::write(&src_path, b"source").await?;
std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o741))?;
let src_mtime = filetime::FileTime::from_unix_time(1_000_000_000, 123_456_789);
filetime::set_file_mtime(&src_path, src_mtime)?;
filetime::set_file_atime(
&src_path,
filetime::FileTime::from_unix_time(1_000_000_500, 0),
)?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let src_handle = root.child(OsStr::new("src_meta.txt")).await?;
let src_meta = src_handle.meta().clone();
let mut dst_file = root.create_file(OsStr::new("dst_meta.txt"), 0o600).await?;
dst_file.write_all(b"destination")?;
dst_file.flush()?;
let settings = crate::preserve::preserve_all();
set_file_metadata_fd(
&settings,
&src_meta,
dst_file.as_fd(),
congestion::Side::Destination,
)
.await?;
drop(dst_file);
let dst_md = std::fs::metadata(tmp.join("foo/dst_meta.txt"))?;
assert_eq!(
dst_md.permissions().mode() & 0o7777,
0o741,
"destination mode mismatch"
);
use std::os::unix::fs::MetadataExt;
assert_eq!(
MetadataExt::mtime(&dst_md),
1_000_000_000,
"mtime seconds mismatch"
);
assert_eq!(
MetadataExt::mtime_nsec(&dst_md),
123_456_789,
"mtime nanos mismatch"
);
assert_eq!(MetadataExt::uid(&dst_md), src_meta.uid(), "uid mismatch");
assert_eq!(MetadataExt::gid(&dst_md), src_meta.gid(), "gid mismatch");
Ok(())
}
#[tokio::test]
async fn set_file_metadata_fd_ordering_preserves_setuid() -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let tmp = testutils::setup_test_dir().await?;
let src_path = tmp.join("foo/setuid_src");
tokio::fs::write(&src_path, b"x").await?;
std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o4755))?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let src_handle = root.child(OsStr::new("setuid_src")).await?;
let src_meta = src_handle.meta().clone();
assert_eq!(
src_meta.permissions().mode() & 0o7777,
0o4755,
"source setuid bit was not set up correctly"
);
let mut dst_file = root.create_file(OsStr::new("setuid_dst"), 0o600).await?;
dst_file.write_all(b"x")?;
dst_file.flush()?;
let settings = crate::preserve::preserve_all();
set_file_metadata_fd(
&settings,
&src_meta,
dst_file.as_fd(),
congestion::Side::Destination,
)
.await?;
drop(dst_file);
let dst_md = std::fs::metadata(tmp.join("foo/setuid_dst"))?;
assert_eq!(
dst_md.permissions().mode() & 0o7777,
0o4755,
"setuid bit was lost — chown must run before chmod"
);
Ok(())
}
#[tokio::test]
async fn set_dir_metadata_fd_applies() -> anyhow::Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let tmp = testutils::setup_test_dir().await?;
let src_dir_path = tmp.join("foo/src_dir");
tokio::fs::create_dir(&src_dir_path).await?;
std::fs::set_permissions(&src_dir_path, std::fs::Permissions::from_mode(0o2750))?;
filetime::set_file_mtime(
&src_dir_path,
filetime::FileTime::from_unix_time(1_111_111_111, 222_000_000),
)?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let src_handle = root.child(OsStr::new("src_dir")).await?;
let src_meta = src_handle.meta().clone();
let dst_dir = root.make_dir(OsStr::new("dst_dir"), 0o700).await?;
let settings = crate::preserve::preserve_all();
set_dir_metadata_fd(&settings, &src_meta, &dst_dir).await?;
let dst_md = std::fs::metadata(tmp.join("foo/dst_dir"))?;
assert_eq!(
dst_md.permissions().mode() & 0o7777,
0o2750,
"destination dir mode mismatch"
);
assert_eq!(
MetadataExt::mtime(&dst_md),
1_111_111_111,
"dir mtime seconds mismatch"
);
assert_eq!(
MetadataExt::mtime_nsec(&dst_md),
222_000_000,
"dir mtime nanos mismatch"
);
Ok(())
}
#[tokio::test]
async fn set_symlink_metadata_fd_changes_link_not_target() -> anyhow::Result<()> {
use std::os::unix::fs::MetadataExt;
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let target_path = tmp.join("foo/sentinel_target.txt");
tokio::fs::write(&target_path, b"keep my mtime").await?;
let target_mtime = filetime::FileTime::from_unix_time(1_500_000_000, 0);
filetime::set_file_mtime(&target_path, target_mtime)?;
let target_before = std::fs::metadata(&target_path)?;
let link = root
.symlink_at(
OsStr::new("the_link"),
std::path::Path::new("sentinel_target.txt"),
)
.await?;
let src_link_path = tmp.join("foo/src_link");
tokio::fs::symlink("sentinel_target.txt", &src_link_path).await?;
let src_link_mtime = filetime::FileTime::from_unix_time(1_234_567_890, 0);
filetime::set_symlink_file_times(
&src_link_path,
filetime::FileTime::from_unix_time(1_234_500_000, 0),
src_link_mtime,
)?;
let src_meta = root.child(OsStr::new("src_link")).await?.meta().clone();
let settings = crate::preserve::preserve_all();
set_symlink_metadata_fd(&settings, &src_meta, &link, congestion::Side::Destination).await?;
let link_md = std::fs::symlink_metadata(tmp.join("foo/the_link"))?;
assert_eq!(
MetadataExt::mtime(&link_md),
1_234_567_890,
"link's own mtime was not applied"
);
let target_after = std::fs::metadata(&target_path)?;
assert_eq!(
MetadataExt::mtime(&target_after),
MetadataExt::mtime(&target_before),
"target mtime changed — utimensat followed the symlink!"
);
assert_eq!(
MetadataExt::mtime_nsec(&target_after),
MetadataExt::mtime_nsec(&target_before),
"target mtime_nsec changed — utimensat followed the symlink!"
);
Ok(())
}
#[tokio::test]
async fn recheck_succeeds_when_unchanged() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let h = root.child(OsStr::new("0.txt")).await?;
let fresh = root.recheck(OsStr::new("0.txt"), &h).await?;
assert_eq!(
fresh.dev(),
h.dev(),
"recheck: dev mismatch on unchanged entry"
);
assert_eq!(
fresh.ino(),
h.ino(),
"recheck: ino mismatch on unchanged entry"
);
Ok(())
}
#[tokio::test]
async fn recheck_fails_when_swapped_to_different_inode() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
tokio::fs::write(tmp.join("foo/f"), b"original").await?;
let h = root.child(OsStr::new("f")).await?;
let original_ino = h.ino();
root.unlink_at(OsStr::new("f")).await?;
root.create_file(OsStr::new("f"), 0o644).await?;
let fresh_via_child = root.child(OsStr::new("f")).await?;
assert_ne!(
fresh_via_child.ino(),
original_ino,
"test setup error: new file has same inode as old one"
);
let err = root.recheck(OsStr::new("f"), &h).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::ESTALE),
"expected ESTALE on inode swap, got {err:#}"
);
Ok(())
}
#[tokio::test]
async fn recheck_fails_when_swapped_to_symlink() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
tokio::fs::write(tmp.join("foo/g"), b"regular").await?;
let h = root.child(OsStr::new("g")).await?;
root.unlink_at(OsStr::new("g")).await?;
root.symlink_at(OsStr::new("g"), std::path::Path::new("0.txt"))
.await?;
let err = root.recheck(OsStr::new("g"), &h).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::ESTALE),
"expected ESTALE on symlink swap, got {err:#}"
);
Ok(())
}
#[tokio::test]
async fn new_methods_reject_multi_component_names() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let dst =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let any_handle = root.child(OsStr::new("0.txt")).await?;
for bad in ["a/b", "..", ".", ""] {
let bad_os = OsStr::new(bad);
let err = root.unlink_at(bad_os).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EINVAL),
"unlink_at: expected EINVAL for {bad:?}, got {err:#}"
);
let err = root.rmdir_at(bad_os).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EINVAL),
"rmdir_at: expected EINVAL for {bad:?}, got {err:#}"
);
let err = root
.symlink_at(bad_os, std::path::Path::new("irrelevant"))
.await
.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EINVAL),
"symlink_at(name): expected EINVAL for {bad:?}, got {err:#}"
);
let err = root.read_link_at(bad_os).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EINVAL),
"read_link_at: expected EINVAL for {bad:?}, got {err:#}"
);
let err = root
.hard_link_at(bad_os, &dst, OsStr::new("good"))
.await
.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EINVAL),
"hard_link_at(name): expected EINVAL for {bad:?}, got {err:#}"
);
let err = root
.hard_link_at(OsStr::new("good"), &dst, bad_os)
.await
.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EINVAL),
"hard_link_at(dst_name): expected EINVAL for {bad:?}, got {err:#}"
);
let err = root.recheck(bad_os, &any_handle).await.unwrap_err();
assert_eq!(
err.raw_os_error(),
Some(libc::EINVAL),
"recheck(name): expected EINVAL for {bad:?}, got {err:#}"
);
}
Ok(())
}
#[tokio::test]
async fn chmod_via_proc_fd_changes_mode_of_zero_mode_file() -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let path = tmp.join("foo/locked.txt");
tokio::fs::write(&path, b"locked").await?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))?;
let handle = root.child(OsStr::new("locked.txt")).await?;
assert_eq!(handle.kind(), EntryKind::File, "fixture must be a file");
chmod_via_proc_fd(&handle, congestion::Side::Destination, 0o640).await?;
let md = std::fs::symlink_metadata(&path)?;
assert_eq!(
md.permissions().mode() & 0o7777,
0o640,
"chmod_via_proc_fd must change the mode of a 0000-mode file"
);
Ok(())
}
#[tokio::test]
async fn stat_meta_via_proc_fd_on_symlink_resolves_link_not_target() -> anyhow::Result<()> {
use std::os::unix::fs::MetadataExt;
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let target_path = tmp.join("foo/stat_target.txt");
tokio::fs::write(&target_path, b"target body").await?;
filetime::set_file_mtime(
&target_path,
filetime::FileTime::from_unix_time(1_700_000_000, 0),
)?;
let link_path = tmp.join("foo/stat_link");
tokio::fs::symlink("stat_target.txt", &link_path).await?;
filetime::set_symlink_file_times(
&link_path,
filetime::FileTime::from_unix_time(1_600_000_000, 0),
filetime::FileTime::from_unix_time(1_600_000_123, 0),
)?;
let handle = root.child(OsStr::new("stat_link")).await?;
assert_eq!(
handle.kind(),
EntryKind::Symlink,
"fixture must classify as a symlink"
);
let md = stat_meta_via_proc_fd(&handle, congestion::Side::Source).await?;
assert!(
md.file_type().is_symlink(),
"stat_meta_via_proc_fd followed the symlink to its target (got a non-symlink)"
);
assert_eq!(
MetadataExt::mtime(&md),
1_600_000_123,
"expected the LINK's own mtime; a target-following stat would return 1_700_000_000"
);
Ok(())
}
#[test]
fn openat2_probe_is_stable() {
let first = openat2_available();
assert_eq!(first, openat2_available(), "probe must be stable");
if !first {
eprintln!("this kernel lacks openat2(2); strict-mode tests skip themselves");
}
}
}