use std::ffi::{CStr, 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;
pub const DST_FILE_CREATE_MODE: u32 = 0o600;
pub const DST_DIR_CREATE_MODE: u32 = 0o700;
static STRICT_OPERAND_RESOLUTION: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
tokio::task_local! {
static FD_ADMISSION: throttle::FdAdmission;
}
pub async fn with_fd_admission<F>(admission: throttle::FdAdmission, future: F) -> F::Output
where
F: std::future::Future,
{
FD_ADMISSION.scope(admission, future).await
}
pub(crate) async fn with_optional_fd_admission<F>(
admission: Option<throttle::FdAdmission>,
future: F,
) -> F::Output
where
F: std::future::Future,
{
match admission {
Some(admission) => with_fd_admission(admission, future).await,
None => future.await,
}
}
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,
dev: u64,
ino: 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,
dev: st.st_dev,
ino: st.st_ino,
}
}
#[must_use]
pub fn dev(&self) -> u64 {
self.dev
}
#[must_use]
pub fn ino(&self) -> u64 {
self.ino
}
}
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,
}
#[derive(Clone, Copy, Debug)]
pub struct RemovalSnapshot {
kind: EntryKind,
size: u64,
}
impl RemovalSnapshot {
#[must_use]
pub fn kind(self) -> EntryKind {
self.kind
}
#[must_use]
pub fn size(self) -> u64 {
self.size
}
}
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 into_removal_snapshot(self) -> RemovalSnapshot {
RemovalSnapshot {
kind: self.kind,
size: self.meta.size,
}
}
#[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,
children_may_inherit: Arc<std::sync::atomic::AtomicBool>,
}
impl Dir {
fn opened(fd: OwnedFd, side: congestion::Side) -> Dir {
Dir {
fd: Arc::new(fd),
side,
children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(true)),
}
}
fn mark_children_cannot_inherit(&self) {
self.children_may_inherit
.store(false, std::sync::atomic::Ordering::Relaxed);
}
#[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::opened(fd, side))
.map_err(nix_to_io);
}
}
match openat(AT_FDCWD, &path, flags, mode) {
Ok(fd) => Ok(Dir::opened(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::opened(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::opened(fd, side)))
.map_err(nix_to_io);
}
}
openat(AT_FDCWD, &path, flags, Mode::empty())
.map(|fd| TrustedDir(Dir::opened(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::opened(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 read_acls(&self) -> std::io::Result<Acls> {
read_acls_fd(self.fd.as_fd(), self.side, true).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 verify_same_inode(&self, handle: &Handle) -> std::io::Result<()> {
let meta = self.meta().await?;
if meta.dev() == handle.dev() && meta.ino() == handle.ino() {
Ok(())
} else {
Err(std::io::Error::from_raw_os_error(libc::ESTALE))
}
}
pub async fn secure_as_copier(
&self,
mode: u32,
orig_uid: u32,
orig_gid: u32,
) -> std::io::Result<()> {
let fd = self.fd.clone();
let euid = nix::unistd::geteuid().as_raw();
run_metadata_probed_blocking(self.side, congestion::MetadataOp::Chmod, move || {
let raw = fd.as_fd();
fchown(raw, Some(Uid::from_raw(euid)), None).map_err(nix_to_io)?;
if let Err(chmod_err) = fchmod(raw, Mode::from_bits_truncate(0o700)).map_err(nix_to_io)
{
match fchown(raw, Some(Uid::from_raw(orig_uid)), None)
.map_err(nix_to_io)
.and_then(|()| fstat(raw).map_err(nix_to_io))
{
Ok(st) if st.st_uid == orig_uid => return Err(chmod_err),
Ok(st) => {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"reused-directory lockdown could not restrict the directory (chmod: \
{chmod_err}); the ownership rollback did not take effect (directory \
shows uid={}, expected {orig_uid}) — it is left copier-owned at its \
original mode, refusing to descend (the filesystem may not honor \
chown/chmod)",
st.st_uid,
),
));
}
Err(rb) => {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"reused-directory lockdown could not restrict the directory (chmod: \
{chmod_err}) and could not roll ownership back (chown/stat: {rb}); \
it is left owned by the copier at its original mode — refusing to \
descend (the destination filesystem may be read-only or failing)"
),
));
}
}
}
let st = fstat(raw).map_err(nix_to_io)?;
if st.st_uid != euid || (st.st_mode & 0o7777) != 0o700 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"reused-directory lockdown could not be verified: after chown+chmod the \
directory shows uid={} mode={:#o} (expected uid={} mode={:#o}); the \
filesystem may not honor chown/chmod (e.g. CIFS without unix extensions) — \
refusing to descend",
st.st_uid,
st.st_mode & 0o7777,
euid,
0o700,
),
));
}
if st.st_gid != orig_gid {
fchown(raw, None, Some(Gid::from_raw(orig_gid))).map_err(nix_to_io)?;
}
if mode != 0o700 {
fchmod(raw, Mode::from_bits_truncate(mode)).map_err(nix_to_io)?;
}
let st = fstat(raw).map_err(nix_to_io)?;
if st.st_uid != euid || st.st_gid != orig_gid || (st.st_mode & 0o7777) != mode {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"reused-directory lockdown could not be verified: after takeover the \
directory shows uid={} gid={} mode={:#o} (expected uid={} gid={} \
mode={:#o}); the filesystem may not honor chown/chmod (e.g. CIFS without \
unix extensions), or the copier is not in the directory's group and lacks \
CAP_FSETID to keep setgid — refusing to descend",
st.st_uid,
st.st_gid,
st.st_mode & 0o7777,
euid,
orig_gid,
mode,
),
));
}
Ok(())
})
.await
}
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();
let strict = strict_operand_resolution();
let created =
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)?;
let open_flags =
OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC;
let fd = match openat(
dir.as_fd(),
name_owned.as_bytes(),
open_flags,
Mode::empty(),
)
.map_err(nix_to_io)
{
Ok(fd) => fd,
Err(err) => {
let _ =
unlinkat(dir.as_fd(), name_owned.as_bytes(), UnlinkatFlags::RemoveDir);
return Err(err);
}
};
if strict {
if let Err(err) = apply_one_acl(fd.as_raw_fd(), ACL_DEFAULT_XATTR, None)
.and_then(|()| apply_one_acl(fd.as_raw_fd(), ACL_ACCESS_XATTR, None))
{
let _ =
unlinkat(dir.as_fd(), name_owned.as_bytes(), UnlinkatFlags::RemoveDir);
return Err(err);
}
}
Ok(fd)
})
.await?;
Ok(Dir {
fd: Arc::new(created),
side,
children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(!strict)),
})
}
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();
run_fd_admitted_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
}
pub async fn read_entries_capped(
&self,
cap: usize,
) -> std::io::Result<Option<Vec<(std::ffi::OsString, Option<EntryKind>)>>> {
throttle::get_ops_token().await;
let dir = self.fd.clone();
run_fd_admitted_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;
}
if entries.len() >= cap {
return Ok(None);
}
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(Some(entries))
})
.await
}
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) -> 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();
let strict = strict_operand_resolution();
let may_inherit = Arc::clone(&self.children_may_inherit);
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(DST_FILE_CREATE_MODE);
let file = openat(dir.as_fd(), name.as_bytes(), flags, file_mode)
.map(std::fs::File::from)
.map_err(nix_to_io)?;
if strict && may_inherit.load(std::sync::atomic::Ordering::SeqCst) {
if let Err(err) = apply_one_acl(file.as_raw_fd(), ACL_ACCESS_XATTR, None) {
let _ = unlinkat(dir.as_fd(), name.as_bytes(), UnlinkatFlags::NoRemoveDir);
return Err(err);
}
}
Ok(file)
})
.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))
}
async fn open_preview_operand_parent(path: &Path, 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_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
#[cfg(target_os = "linux")]
if strict_operand_resolution() {
return openat2_no_symlinks(&path, flags)
.map(|fd| Dir::opened(fd, side))
.map_err(nix_to_io);
}
openat(AT_FDCWD, &path, flags, Mode::empty())
.map(|fd| Dir::opened(fd, side))
.map_err(nix_to_io)
})
.await
}
fn preview_scan_absence(error: &std::io::Error) -> bool {
error.kind() == std::io::ErrorKind::NotFound
|| error.kind() == std::io::ErrorKind::NotADirectory
|| matches!(
error.raw_os_error(),
Some(libc::ENOTDIR) | Some(libc::ELOOP)
)
}
fn preview_relative_components(relative: &Path) -> std::io::Result<Vec<std::ffi::OsString>> {
if relative.as_os_str().is_empty() {
return Ok(Vec::new());
}
relative
.as_os_str()
.as_bytes()
.split(|byte| *byte == b'/')
.map(|component| {
let component = OsStr::from_bytes(component);
is_single_component(component)
.then(|| component.to_owned())
.ok_or_else(|| std::io::Error::from_raw_os_error(libc::EINVAL))
})
.collect()
}
pub(crate) async fn open_existing_dir_beneath_operand(
named_root: &Path,
relative: &Path,
side: congestion::Side,
) -> std::io::Result<Option<Dir>> {
let relative = preview_relative_components(relative)?;
let Some((parent_path, root_name)) = split_parent_and_name(named_root) else {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
};
if !is_single_component(root_name) {
return Err(std::io::Error::from_raw_os_error(libc::EINVAL));
}
let parent = match open_preview_operand_parent(parent_path, side).await {
Ok(parent) => parent,
Err(error) if preview_scan_absence(&error) => return Ok(None),
Err(error) => return Err(error),
};
let mut current = match parent.open_dir(root_name).await {
Ok(dir) => dir,
Err(error) if preview_scan_absence(&error) => return Ok(None),
Err(error) => return Err(error),
};
drop(parent);
for component in relative {
current = match current.open_dir(&component).await {
Ok(dir) => dir,
Err(error) if preview_scan_absence(&error) => return Ok(None),
Err(error) => return Err(error),
};
}
Ok(Some(current))
}
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),
}
}
#[derive(Debug)]
enum DefaultAclGuard {
Armed { original: Option<Vec<u8>> },
Disarmed,
}
#[derive(Debug)]
pub struct ReusedDirLock {
pub restore_owner: (u32, u32),
state: Arc<std::sync::Mutex<DefaultAclGuard>>,
fd: Arc<OwnedFd>,
children_may_inherit: Arc<std::sync::atomic::AtomicBool>,
}
impl ReusedDirLock {
fn original_default_acl(&self) -> Option<Vec<u8>> {
match &*self.state.lock().unwrap() {
DefaultAclGuard::Armed { original } => original.clone(),
DefaultAclGuard::Disarmed => None,
}
}
fn disarm(&self) {
*self.state.lock().unwrap() = DefaultAclGuard::Disarmed;
}
async fn write_default_acl_guarded(
&self,
side: congestion::Side,
blob: Vec<u8>,
) -> std::io::Result<()> {
let state = Arc::clone(&self.state);
let fd = Arc::clone(&self.fd);
run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
let guard = state.lock().unwrap();
match &*guard {
DefaultAclGuard::Armed { .. } => {
apply_one_acl(fd.as_raw_fd(), ACL_DEFAULT_XATTR, Some(&blob))
}
DefaultAclGuard::Disarmed => {
tracing::debug!(
"skipped a default-ACL write that lost its race against the lockdown \
guard's restore ({} bytes)",
blob.len(),
);
Ok(())
}
}
})
.await
}
}
impl Drop for ReusedDirLock {
fn drop(&mut self) {
self.children_may_inherit
.store(true, std::sync::atomic::Ordering::SeqCst);
let state = match self.state.try_lock() {
Ok(guard) => guard,
Err(std::sync::TryLockError::WouldBlock) => {
let state = Arc::clone(&self.state);
let fd = Arc::clone(&self.fd);
let flag_note = "detached (a guarded write held the rollback mutex)";
std::thread::spawn(move || {
let guard = state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
rollback_default_acl(guard, &fd, flag_note);
});
return;
}
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
};
rollback_default_acl(state, &self.fd, "inline");
}
}
fn rollback_default_acl(
mut state: std::sync::MutexGuard<'_, DefaultAclGuard>,
fd: &OwnedFd,
how: &str,
) {
{
let DefaultAclGuard::Armed { original } =
std::mem::replace(&mut *state, DefaultAclGuard::Disarmed)
else {
return;
};
let raw = fd.as_raw_fd();
let _ = how;
let path = std::fs::read_link(format!("/proc/self/fd/{raw}"))
.map_or_else(|_| format!("<fd {raw}>"), |p| p.display().to_string());
let restore = apply_one_acl(raw, ACL_DEFAULT_XATTR, original.as_deref());
match (restore, &original) {
(Ok(()), Some(blob)) => tracing::debug!(
"restored the default ACL of reused destination directory {} after an aborted \
copy ({} bytes)",
path,
blob.len(),
),
(Ok(()), None) => tracing::debug!(
"ensured reused destination directory {} carries no default ACL after an aborted \
copy (it had none before it)",
path,
),
(Err(error), Some(blob)) => tracing::warn!(
"could not restore the default ACL of reused destination directory {} after an \
aborted copy: {:#}. It is left with NO default ACL; it had {} bytes ({:02x?}), \
which must be re-applied by hand to restore what entries created beneath it \
inherit.",
path,
&error,
blob.len(),
blob,
),
(Err(error), None) => tracing::warn!(
"could not remove a partially-applied default ACL from reused destination \
directory {} after an aborted copy: {:#}. The directory had NO default ACL \
before the copy; whatever the aborted finalize installed remains and must be \
removed by hand.",
path,
&error,
),
}
}
}
pub async fn lockdown_reused_dir(
dir: &Dir,
handle: &Handle,
) -> std::io::Result<Option<ReusedDirLock>> {
if !strict_operand_resolution() {
return Ok(None);
}
use crate::preserve::Metadata as _;
use std::os::unix::fs::PermissionsExt as _;
let orig_meta = handle.meta();
let restore_owner = (orig_meta.uid(), orig_meta.gid());
dir.verify_same_inode(handle).await?;
let want_setgid = orig_meta.permissions().mode() & 0o2000 != 0;
let interim_mode = if want_setgid { 0o2700 } else { 0o700 };
dir.secure_as_copier(interim_mode, orig_meta.uid(), orig_meta.gid())
.await?;
let original = read_acls_fd(dir.fd.as_fd(), dir.side, true).await?.default;
let needs_strip = original.is_some();
let lock = ReusedDirLock {
restore_owner,
state: Arc::new(std::sync::Mutex::new(DefaultAclGuard::Armed { original })),
fd: Arc::clone(&dir.fd),
children_may_inherit: Arc::clone(&dir.children_may_inherit),
};
if needs_strip {
let raw = dir.fd.as_raw_fd();
crate::walk::run_metadata_probed(dir.side, congestion::MetadataOp::Chmod, async {
apply_one_acl(raw, ACL_DEFAULT_XATTR, None)
})
.await?;
}
dir.mark_children_cannot_inherit();
Ok(Some(lock))
}
const ACL_ACCESS_XATTR: &CStr = c"system.posix_acl_access";
const ACL_DEFAULT_XATTR: &CStr = c"system.posix_acl_default";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Acls {
pub access: Option<Vec<u8>>,
pub default: Option<Vec<u8>>,
}
pub async fn read_acls_fd(
fd: BorrowedFd<'_>,
side: congestion::Side,
want_default: bool,
) -> std::io::Result<Acls> {
let owned = Arc::new(fd.try_clone_to_owned()?);
let names = {
let owned = Arc::clone(&owned);
run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
flistxattr_names(owned.as_raw_fd())
})
.await?
};
let mut acls = Acls::default();
if names_contain(&names, ACL_ACCESS_XATTR) {
let owned = Arc::clone(&owned);
acls.access = run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
fgetxattr_blob(owned.as_raw_fd(), ACL_ACCESS_XATTR)
})
.await?;
}
if want_default && names_contain(&names, ACL_DEFAULT_XATTR) {
acls.default =
run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
fgetxattr_blob(owned.as_raw_fd(), ACL_DEFAULT_XATTR)
})
.await?;
}
Ok(acls)
}
pub async fn apply_acls_fd(
fd: BorrowedFd<'_>,
side: congestion::Side,
acls: &Acls,
want_default: bool,
) -> std::io::Result<()> {
if want_default {
set_or_remove_acl_fd(fd, side, ACL_DEFAULT_XATTR, acls.default.clone()).await?;
}
set_or_remove_acl_fd(fd, side, ACL_ACCESS_XATTR, acls.access.clone()).await
}
async fn set_or_remove_acl_fd(
fd: BorrowedFd<'_>,
side: congestion::Side,
name: &'static CStr,
blob: Option<Vec<u8>>,
) -> std::io::Result<()> {
let owned = fd.try_clone_to_owned()?;
run_metadata_probed_blocking(side, congestion::MetadataOp::Chmod, move || {
apply_one_acl(owned.as_raw_fd(), name, blob.as_deref())
})
.await
}
fn acls_to_apply(want: bool, acls: Option<&Acls>) -> std::io::Result<Option<&Acls>> {
match (want, acls) {
(false, _) => Ok(None),
(true, Some(acls)) => Ok(Some(acls)),
(true, None) => Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"ACL preservation was requested but this copy path does not carry the source's ACLs",
)),
}
}
fn listxattr_retry(
mut call: impl FnMut(*mut libc::c_char, usize) -> isize,
) -> std::io::Result<Vec<u8>> {
let mut buf = [0u8; 256];
let n = call(buf.as_mut_ptr().cast(), buf.len());
if n >= 0 {
return Ok(buf[..n as usize].to_vec());
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EOPNOTSUPP) => Ok(Vec::new()),
Some(libc::ERANGE) => {
let size = call(std::ptr::null_mut(), 0);
if size < 0 {
return Err(std::io::Error::last_os_error());
}
let mut heap = vec![0u8; size as usize];
if heap.is_empty() {
return Ok(heap);
}
let n = call(heap.as_mut_ptr().cast(), heap.len());
if n < 0 {
return Err(std::io::Error::last_os_error());
}
heap.truncate(n as usize);
Ok(heap)
}
_ => Err(err),
}
}
fn flistxattr_names(fd: RawFd) -> std::io::Result<Vec<u8>> {
listxattr_retry(|buf, len| unsafe { libc::flistxattr(fd, buf, len) })
}
fn listxattr_names_at_path(path: &CStr) -> std::io::Result<Vec<u8>> {
let probe = |buf, len| {
unsafe { libc::listxattr(path.as_ptr(), buf, len) } };
listxattr_retry(probe)
}
fn names_contain(list: &[u8], name: &CStr) -> bool {
list.split(|byte| *byte == 0).any(|n| n == name.to_bytes())
}
fn fgetxattr_blob(fd: RawFd, name: &CStr) -> std::io::Result<Option<Vec<u8>>> {
let mut buf = [0u8; 512];
let n = unsafe { libc::fgetxattr(fd, name.as_ptr(), buf.as_mut_ptr().cast(), buf.len()) };
if n >= 0 {
return Ok(Some(buf[..n as usize].to_vec()));
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::ENODATA | libc::EOPNOTSUPP) => Ok(None),
Some(libc::ERANGE) => {
let size = unsafe { libc::fgetxattr(fd, name.as_ptr(), std::ptr::null_mut(), 0) };
if size < 0 {
return Err(std::io::Error::last_os_error());
}
let mut heap = vec![0u8; size as usize];
if heap.is_empty() {
return Ok(Some(heap));
}
let n =
unsafe { libc::fgetxattr(fd, name.as_ptr(), heap.as_mut_ptr().cast(), heap.len()) };
if n < 0 {
return Err(std::io::Error::last_os_error());
}
heap.truncate(n as usize);
Ok(Some(heap))
}
_ => Err(err),
}
}
fn apply_one_acl(fd: RawFd, name: &CStr, blob: Option<&[u8]>) -> std::io::Result<()> {
let Some(blob) = blob else {
if unsafe { libc::fremovexattr(fd, name.as_ptr()) } == 0 {
return Ok(());
}
let err = std::io::Error::last_os_error();
return match err.raw_os_error() {
Some(libc::ENODATA | libc::EOPNOTSUPP) => Ok(()),
_ => Err(err),
};
};
let rc = unsafe {
libc::fsetxattr(
fd,
name.as_ptr(),
blob.as_ptr().cast(),
blob.len(),
0, )
};
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
static ROOT_ACL_PROBED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct RootAclNotice {
pub wanted: bool,
pub file_acl_preserved: bool,
pub dir_acl_preserved: bool,
}
impl RootAclNotice {
#[must_use]
pub fn for_preserve(preserve: &crate::preserve::Settings) -> Self {
Self {
wanted: preserve.requests_preservation(),
file_acl_preserved: preserve.file.acl,
dir_acl_preserved: preserve.dir.acl,
}
}
fn could_warn_for(self, kind: EntryKind) -> bool {
self.could_warn_for_strict(kind, strict_operand_resolution())
}
fn could_warn_for_strict(self, kind: EntryKind, strict: bool) -> bool {
(self.wanted || strict)
&& match kind {
EntryKind::File => !self.file_acl_preserved,
EntryKind::Dir => !self.dir_acl_preserved,
EntryKind::Symlink | EntryKind::Special => false,
}
}
fn could_warn(self) -> bool {
self.could_warn_for(EntryKind::File) || self.could_warn_for(EntryKind::Dir)
}
}
async fn has_acl_via_proc_fd(handle: &Handle, side: congestion::Side) -> std::io::Result<bool> {
let owned = handle.as_fd().try_clone_to_owned()?;
let names = run_metadata_probed_blocking(side, congestion::MetadataOp::Stat, move || {
let proc_path = std::ffi::CString::new(format!("/proc/self/fd/{}", owned.as_raw_fd()))
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
listxattr_names_at_path(&proc_path)
})
.await?;
Ok(names_contain(&names, ACL_ACCESS_XATTR) || names_contain(&names, ACL_DEFAULT_XATTR))
}
#[must_use]
pub fn root_acl_probe_worth_reaching(notice: RootAclNotice) -> bool {
if !notice.could_warn() {
return false;
}
!ROOT_ACL_PROBED.load(std::sync::atomic::Ordering::Relaxed)
}
fn claim_root_acl_probe() -> bool {
!ROOT_ACL_PROBED.swap(true, std::sync::atomic::Ordering::Relaxed)
}
async fn warn_root_acl(handle: &Handle, src_root: &Path, notice: RootAclNotice) {
if !notice.could_warn_for(handle.kind()) {
return;
}
if !claim_root_acl_probe() {
return;
}
match has_acl_via_proc_fd(handle, congestion::Side::Source).await {
Ok(false) => {}
Ok(true) if strict_operand_resolution() => tracing::warn!(
target: crate::NOTICE_TARGET,
"Source root {src_root:?} carries a POSIX ACL that this copy will NOT preserve \
(`--preserve-settings` does not request `acl`). --require-toctou-safe stops the \
DESTINATION tree's ACLs from being inherited, but it does not carry the SOURCE's \
across, so the copy can still end up more permissive than what it copied. Use \
`--preserve-settings=all+acl`. Only the root was checked - entries beneath it may \
carry ACLs this says nothing about."
),
Ok(true) => tracing::warn!(
target: crate::NOTICE_TARGET,
"Source root {src_root:?} carries a POSIX ACL that this copy will NOT preserve \
(`--preserve-settings` does not request `acl`), so the destination may end up more \
permissive than its source. Use `--preserve-settings=all+acl`. Only the root was \
checked - entries beneath it may carry ACLs this says nothing about."
),
Err(err) => {
tracing::debug!("cannot probe {src_root:?} for a POSIX ACL: {err:#}");
}
}
}
pub async fn warn_if_root_acl_unpreserved(handle: &Handle, src_root: &Path, notice: RootAclNotice) {
if !root_acl_probe_worth_reaching(notice) {
return;
}
warn_root_acl(handle, src_root, notice).await;
}
pub async fn warn_if_root_acl_unpreserved_at(
parent: &Dir,
name: &OsStr,
src_root: &Path,
notice: RootAclNotice,
) {
if !root_acl_probe_worth_reaching(notice) {
return;
}
match parent.child(name).await {
Ok(handle) => warn_root_acl(&handle, src_root, notice).await,
Err(err) => {
tracing::debug!("cannot open source root {src_root:?} for a POSIX ACL probe: {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) 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,
acls: Option<&Acls>,
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?;
}
if ut.time {
futimens_fd(
fd,
side,
meta.atime(),
meta.atime_nsec(),
meta.mtime(),
meta.mtime_nsec(),
)
.await?;
}
let acls = acls_to_apply(settings.file.acl, acls)?;
let mode = crate::preserve::masked_mode(settings.file.mode_mask, meta);
if let Some(acls) = acls.filter(|acls| acls.access.is_some()) {
fchmod_fd(fd, side, (mode & 0o7000) | DST_FILE_CREATE_MODE).await?;
apply_acls_fd(fd, side, acls, false).await?;
} else {
if let Some(acls) = acls {
apply_acls_fd(fd, side, acls, false).await?;
}
fchmod_fd(fd, side, mode).await?;
}
Ok(())
}
pub async fn set_dir_metadata_fd<Meta: crate::preserve::Metadata>(
settings: &crate::preserve::Settings,
meta: &Meta,
acls: Option<&Acls>,
dir: &Dir,
) -> std::io::Result<()> {
set_dir_metadata_fd_inner(settings, meta, acls, dir, true).await
}
async fn set_dir_metadata_fd_inner<Meta: crate::preserve::Metadata>(
settings: &crate::preserve::Settings,
meta: &Meta,
acls: Option<&Acls>,
dir: &Dir,
apply_default: bool,
) -> 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 acls = acls_to_apply(settings.dir.acl, acls)?;
let mode = crate::preserve::masked_mode(settings.dir.mode_mask, meta);
if let Some(acls) = acls.filter(|acls| acls.access.is_some()) {
fchmod_fd(fd, side, (mode & 0o7000) | DST_DIR_CREATE_MODE).await?;
apply_acls_fd(fd, side, acls, apply_default).await?;
} else {
if let Some(acls) = acls {
apply_acls_fd(fd, side, acls, apply_default).await?;
}
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_reused_dir_metadata_fd<Meta: crate::preserve::Metadata>(
settings: &crate::preserve::Settings,
meta: &Meta,
acls: Option<&Acls>,
lock: Option<ReusedDirLock>,
dir: &Dir,
) -> std::io::Result<()> {
let Some(lock) = lock else {
return set_dir_metadata_fd(settings, meta, acls, dir).await;
};
let (orig_uid, orig_gid) = lock.restore_owner;
let final_default = if settings.dir.acl {
acls_to_apply(true, acls)?.and_then(|acls| acls.default.clone())
} else {
lock.original_default_acl()
};
if let Some(blob) = final_default {
lock.write_default_acl_guarded(dir.side, blob).await?;
}
let ut = &settings.dir.user_and_time;
{
let uid = (!ut.uid).then_some(orig_uid);
let gid = (!ut.gid).then_some(orig_gid);
if uid.is_some() || gid.is_some() {
fchown_fd(dir.fd.as_fd(), dir.side, uid, gid).await?;
}
}
set_dir_metadata_fd_inner(settings, meta, acls, dir, false).await?;
use crate::preserve::Metadata as _;
use std::os::unix::fs::PermissionsExt as _;
let want_uid = if ut.uid { meta.uid() } else { orig_uid };
let want_gid = if ut.gid { meta.gid() } else { orig_gid };
let want_mode = crate::preserve::masked_mode(settings.dir.mode_mask, meta);
let got = dir.meta().await?;
let got_mode = got.permissions().mode() & 0o7777;
if got.uid() != want_uid || got.gid() != want_gid {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"reused-directory finalize could not be verified: the directory shows uid={} gid={} \
(expected uid={} gid={}); the filesystem may not honor chown/chmod",
got.uid(),
got.gid(),
want_uid,
want_gid,
),
));
}
if got_mode != want_mode {
if want_mode & 0o2000 != 0 && want_mode & !0o2000 == got_mode {
tracing::warn!(
"setgid not applied to reused directory (final mode {:#o} vs source {:#o}): the \
copier is not in the directory's group and lacks CAP_FSETID",
got_mode,
want_mode,
);
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"reused-directory finalize could not be verified: the directory shows \
mode={:#o} (expected {:#o}); the filesystem may not honor chmod",
got_mode, want_mode,
),
));
}
}
lock.disarm();
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(())
}
struct BlockingJob<F> {
work: F,
admission: Option<throttle::BlockingFdAdmissionLease>,
}
type BlockingJobOutput<T> = (
std::io::Result<T>,
Option<throttle::BlockingFdAdmissionLease>,
);
impl<F> BlockingJob<F> {
fn run<T>(self) -> BlockingJobOutput<T>
where
F: FnOnce() -> std::io::Result<T>,
{
let Self { work, admission } = self;
(work(), admission)
}
}
fn take_blocking_job<F>(
shared_job: &std::sync::Mutex<Option<BlockingJob<F>>>,
) -> Option<BlockingJob<F>> {
shared_job
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
}
struct BlockingJobWaiter<F, T> {
shared_job: Arc<std::sync::Mutex<Option<BlockingJob<F>>>>,
handle: tokio::task::JoinHandle<Option<BlockingJobOutput<T>>>,
}
impl<F, T> std::future::Future for BlockingJobWaiter<F, T> {
type Output = Result<Option<BlockingJobOutput<T>>, tokio::task::JoinError>;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
let this = self.get_mut();
std::future::Future::poll(std::pin::Pin::new(&mut this.handle), cx)
}
}
impl<F, T> Drop for BlockingJobWaiter<F, T> {
fn drop(&mut self) {
let queued_job = take_blocking_job(&self.shared_job);
drop(queued_job);
self.handle.abort();
}
}
pub(crate) async fn run_fd_admitted_blocking<F, T>(f: F) -> std::io::Result<T>
where
F: FnOnce() -> std::io::Result<T> + Send + 'static,
T: Send + 'static,
{
let blocking_admission = FD_ADMISSION
.try_with(throttle::FdAdmission::blocking_lease)
.ok();
let shared_job = Arc::new(std::sync::Mutex::new(Some(BlockingJob {
work: f,
admission: blocking_admission,
})));
let worker_job = Arc::clone(&shared_job);
let handle =
tokio::task::spawn_blocking(move || take_blocking_job(&worker_job).map(BlockingJob::run));
let (result, _blocking_admission) = BlockingJobWaiter { shared_job, handle }
.await
.map_err(std::io::Error::other)?
.expect("blocking job disappeared while its waiter was alive");
result
}
pub 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,
{
throttle::get_ops_token().await;
run_metadata_probed_blocking_no_rate(side, op, f).await
}
pub async fn run_metadata_probed_blocking_no_rate<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,
{
let ops_permit = throttle::ops_in_flight_permit(crate::walk::meta_resource(side, op)).await;
let probe = congestion::Probe::start_metadata(side, op);
run_fd_admitted_blocking(move || {
let result = f();
match &result {
Ok(_) => probe.complete_ok(0),
Err(_) => probe.discard(),
}
drop(ops_permit);
result
})
.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;
const EVERY_KIND: [EntryKind; 4] = [
EntryKind::File,
EntryKind::Dir,
EntryKind::Symlink,
EntryKind::Special,
];
#[tokio::test]
async fn preview_operand_descent_rejects_a_symlink_in_the_relative_prefix() -> anyhow::Result<()>
{
let fixture = testutils::create_temp_dir().await?;
let named_root = fixture.join("named-root");
let outside = fixture.join("outside");
tokio::fs::create_dir_all(&named_root).await?;
tokio::fs::create_dir_all(outside.join("level-two")).await?;
tokio::fs::symlink(&outside, named_root.join("level-one")).await?;
let opened = open_existing_dir_beneath_operand(
&named_root,
Path::new("level-one/level-two"),
congestion::Side::Destination,
)
.await?;
assert!(opened.is_none());
Ok(())
}
#[tokio::test]
async fn preview_operand_relative_path_accepts_empty_and_rejects_non_normal_components()
-> anyhow::Result<()> {
let fixture = testutils::create_temp_dir().await?;
let named_root = fixture.join("named-root");
tokio::fs::create_dir(&named_root).await?;
assert!(
open_existing_dir_beneath_operand(
&named_root,
Path::new(""),
congestion::Side::Destination,
)
.await?
.is_some(),
"an empty relative path must open the named root"
);
for invalid in ["/absolute", ".", "..", "one/./two", "one/../two"] {
let error = open_existing_dir_beneath_operand(
&named_root,
Path::new(invalid),
congestion::Side::Destination,
)
.await
.expect_err("a non-normal relative path must be rejected");
assert_eq!(error.raw_os_error(), Some(libc::EINVAL), "{invalid:?}");
}
Ok(())
}
mod max_files_in_flight_tests {
use super::*;
use anyhow::Context as _;
use futures::FutureExt as _;
struct BlockingDropFd {
_file: std::fs::File,
drop_started: Option<tokio::sync::oneshot::Sender<()>>,
release_drop: std::sync::mpsc::Receiver<()>,
_completion: crate::testutils::CompletionSignal,
}
impl Drop for BlockingDropFd {
fn drop(&mut self) {
if let Some(drop_started) = self.drop_started.take() {
let _ = drop_started.send(());
}
self.release_drop
.recv()
.expect("drop release sender must stay alive");
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct QueuedFdDropObservation {
fd_was_closed: bool,
admission_was_retained: bool,
}
struct QueuedFdCapture {
file: Option<std::fs::File>,
probe: crate::testutils::FdIdentityProbe,
observation: Arc<std::sync::Mutex<Option<std::io::Result<QueuedFdDropObservation>>>>,
}
impl Drop for QueuedFdCapture {
fn drop(&mut self) {
drop(self.file.take());
let admission_was_retained = throttle::open_file_permit().now_or_never().is_none();
*self
.observation
.lock()
.expect("queued fd drop observation lock poisoned") =
Some(self.probe.original_is_closed().map(|fd_was_closed| {
QueuedFdDropObservation {
fd_was_closed,
admission_was_retained,
}
}));
}
}
#[test]
fn dropping_queued_blocking_work_closes_fd_before_returning_admission() -> anyhow::Result<()>
{
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.max_blocking_threads(1)
.enable_all()
.build()?;
runtime.block_on(async {
let root = crate::testutils::create_temp_dir().await?;
let file_path = root.join("queued-capture");
tokio::fs::write(&file_path, b"x").await?;
let admission = crate::testutils::AdmissionLimit::new().await;
admission.set_files_in_flight(1);
let open_file_guard = throttle::open_file_permit().await;
let file = std::fs::File::open(file_path)?;
let raw_fd = file.as_raw_fd();
let probe = match crate::testutils::FdIdentityProbe::capture(raw_fd) {
Ok(probe) => probe,
Err(error) => {
drop(file);
drop(open_file_guard);
drop(admission);
let cleanup_result = tokio::fs::remove_dir_all(root).await;
return match cleanup_result {
Ok(()) => Err(error.into()),
Err(cleanup_error) => Err(anyhow::Error::new(error).context(format!(
"queued fd fixture did not clean up: {cleanup_error:#}"
))),
};
}
};
let drop_observation = Arc::new(std::sync::Mutex::new(None));
let (blocker_started_tx, blocker_started_rx) = tokio::sync::oneshot::channel();
let (release_blocker_tx, release_blocker_rx) = std::sync::mpsc::channel();
let blocker = tokio::task::spawn_blocking(move || {
let _ = blocker_started_tx.send(());
release_blocker_rx
.recv()
.expect("blocking worker release sender must stay alive");
});
blocker_started_rx
.await
.context("the sole blocking worker did not start")?;
let (queued_started_tx, queued_started_rx) = tokio::sync::oneshot::channel();
{
let admission = open_file_guard.admission();
let queued_capture = QueuedFdCapture {
file: Some(file),
probe,
observation: Arc::clone(&drop_observation),
};
let mut waiter = Box::pin(with_fd_admission(admission, async move {
run_fd_admitted_blocking(move || {
let _queued_capture = queued_capture;
let _ = queued_started_tx.send(());
Ok(())
})
.await
}));
assert!(
futures::poll!(waiter.as_mut()).is_pending(),
"the admitted blocking closure must queue behind the occupied worker"
);
drop(open_file_guard);
}
let fd_was_closed_before_release = probe.original_is_closed();
let drop_observation_before_release = drop_observation
.lock()
.expect("queued fd drop observation lock poisoned")
.take()
.transpose();
let mut next_permit = Box::pin(throttle::open_file_permit());
let (admission_returned_before_release, returned_admission) =
match futures::poll!(next_permit.as_mut()) {
std::task::Poll::Ready(permit) => (true, Some(permit)),
std::task::Poll::Pending => (false, None),
};
let release_result = release_blocker_tx.send(());
let blocker_result = blocker.await;
let queued_start_result =
tokio::time::timeout(std::time::Duration::from_secs(5), queued_started_rx)
.await;
let capacity_error = match returned_admission {
Some(permit) => {
drop(permit);
None
}
None => tokio::time::timeout(
std::time::Duration::from_secs(5),
next_permit.as_mut(),
)
.await
.map(drop)
.map_err(anyhow::Error::new)
.map_err(|error| {
error.context("queued blocking work did not eventually return admission")
})
.err(),
};
drop(admission);
let cleanup_result = tokio::fs::remove_dir_all(root).await;
let release_error = release_result.err().map(|error| {
anyhow::Error::new(error)
.context("the blocking worker ended before its release")
});
let blocker_error = blocker_result
.err()
.map(|error| anyhow::Error::new(error).context("the blocking worker panicked"));
let fd_was_closed_before_release = fd_was_closed_before_release?;
let drop_observation_before_release = drop_observation_before_release?;
if let Some(error) = capacity_error {
return Err(error);
}
if let Some(error) = release_error {
return Err(error);
}
if let Some(error) = blocker_error {
return Err(error);
}
cleanup_result?;
assert!(
fd_was_closed_before_release,
"dropping the queued waiter left its captured fd open"
);
assert!(
admission_returned_before_release,
"dropping the queued waiter retained fd admission"
);
assert_eq!(
drop_observation_before_release,
Some(QueuedFdDropObservation {
fd_was_closed: true,
admission_was_retained: true,
}),
"the queued work must close its fd before its admission lease drops"
);
assert!(
matches!(queued_start_result, Ok(Err(_))),
"the dropped queued blocking work started"
);
Ok(())
})
}
#[tokio::test]
async fn cancelled_unprobed_waiter_keeps_admission_until_fd_owner_finishes()
-> anyhow::Result<()> {
let root = crate::testutils::create_temp_dir().await?;
let file_path = root.join("held-unprobed");
tokio::fs::write(&file_path, b"x").await?;
let admission = crate::testutils::AdmissionLimit::new().await;
admission.set_files_in_flight(1);
let open_file_guard = throttle::open_file_permit().await;
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let (completion, completion_rx) = crate::testutils::CompletionSignal::new();
let task = tokio::spawn(async move {
let admission = open_file_guard.admission();
with_fd_admission(admission, async move {
let _open_file_guard = open_file_guard;
run_fd_admitted_blocking(move || {
let _completion = completion;
let _file = std::fs::File::open(file_path)?;
let _ = started_tx.send(());
release_rx.recv().map_err(std::io::Error::other)?;
Ok(())
})
.await
})
.await
});
let started_result = started_rx.await;
task.abort();
let waiter_was_cancelled = matches!(task.await, Err(error) if error.is_cancelled());
let mut second_permit = Box::pin(throttle::open_file_permit());
let admission_was_retained =
started_result.is_ok() && futures::poll!(second_permit.as_mut()).is_pending();
let release_result = release_tx.send(());
let (completion_result, permit) = crate::testutils::await_completion_and_capacity(
completion_rx,
second_permit.as_mut(),
)
.await;
started_result.context("detached unprobed work did not start")?;
release_result.context("detached unprobed work ended before its release")?;
completion_result.context("detached unprobed work did not report completion")?;
drop(permit);
assert!(waiter_was_cancelled);
assert!(
admission_was_retained,
"cancelling the waiter released admission while detached work held an fd"
);
Ok(())
}
#[tokio::test]
async fn cancelled_metadata_waiter_keeps_admission_until_fd_owner_finishes()
-> anyhow::Result<()> {
let root = crate::testutils::create_temp_dir().await?;
let file_path = root.join("held");
tokio::fs::write(&file_path, b"x").await?;
let admission = crate::testutils::AdmissionLimit::new().await;
admission.set_files_in_flight(1);
let stat_resource =
throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
admission.set_max_ops_in_flight(stat_resource, 1);
let open_file_guard = throttle::open_file_permit().await;
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let (completion, completion_rx) = crate::testutils::CompletionSignal::new();
let task = tokio::spawn(async move {
let admission = open_file_guard.admission();
with_fd_admission(admission, async move {
let _open_file_guard = open_file_guard;
run_metadata_probed_blocking(
congestion::Side::Source,
congestion::MetadataOp::Stat,
move || {
let _completion = completion;
let _file = std::fs::File::open(file_path)?;
let _ = started_tx.send(());
release_rx.recv().map_err(std::io::Error::other)?;
Ok(())
},
)
.await
})
.await
});
let started_result = started_rx.await;
task.abort();
let waiter_was_cancelled = matches!(task.await, Err(error) if error.is_cancelled());
let second_permit = throttle::open_file_permit();
tokio::pin!(second_permit);
let admission_was_retained =
started_result.is_ok() && futures::poll!(second_permit.as_mut()).is_pending();
let second_stat_permit = throttle::ops_in_flight_permit(stat_resource);
tokio::pin!(second_stat_permit);
let metadata_was_retained =
started_result.is_ok() && futures::poll!(second_stat_permit.as_mut()).is_pending();
let release_result = release_tx.send(());
let (completion_result, (permit, stat_permit)) =
crate::testutils::await_completion_and_capacity(
completion_rx,
futures::future::join(second_permit.as_mut(), second_stat_permit.as_mut()),
)
.await;
started_result.context("detached metadata work did not start")?;
release_result.context("detached metadata work ended before its release")?;
completion_result.context("detached metadata work did not report completion")?;
drop(permit);
drop(stat_permit);
assert!(waiter_was_cancelled);
assert!(
admission_was_retained,
"cancelling the waiter released admission while detached work held an fd"
);
assert!(
metadata_was_retained,
"cancelling the waiter released metadata capacity while its syscall was live"
);
Ok(())
}
#[tokio::test]
async fn abandoned_metadata_output_keeps_admission_until_returned_fd_drops()
-> anyhow::Result<()> {
let root = crate::testutils::create_temp_dir().await?;
let file_path = root.join("returned");
tokio::fs::write(&file_path, b"x").await?;
let admission = crate::testutils::AdmissionLimit::new().await;
admission.set_files_in_flight(1);
let open_file_guard = throttle::open_file_permit().await;
let (work_started_tx, work_started_rx) = tokio::sync::oneshot::channel();
let (release_work_tx, release_work_rx) = std::sync::mpsc::channel();
let (drop_started_tx, drop_started_rx) = tokio::sync::oneshot::channel();
let (release_drop_tx, release_drop_rx) = std::sync::mpsc::channel();
let (completion, completion_rx) = crate::testutils::CompletionSignal::new();
let task = tokio::spawn(async move {
let admission = open_file_guard.admission();
with_fd_admission(admission, async move {
let _open_file_guard = open_file_guard;
run_metadata_probed_blocking(
congestion::Side::Source,
congestion::MetadataOp::Stat,
move || {
let file = std::fs::File::open(file_path)?;
let _ = work_started_tx.send(());
release_work_rx.recv().map_err(std::io::Error::other)?;
Ok(BlockingDropFd {
_file: file,
drop_started: Some(drop_started_tx),
release_drop: release_drop_rx,
_completion: completion,
})
},
)
.await
})
.await
});
let work_started_result = work_started_rx.await;
task.abort();
let waiter_was_cancelled = matches!(task.await, Err(error) if error.is_cancelled());
let release_work_result = release_work_tx.send(());
let drop_started_result = drop_started_rx.await;
let admission_was_retained = {
let second_permit = throttle::open_file_permit();
tokio::pin!(second_permit);
drop_started_result.is_ok() && futures::poll!(second_permit.as_mut()).is_pending()
};
let release_drop_result = release_drop_tx.send(());
let (completion_result, permit) = crate::testutils::await_completion_and_capacity(
completion_rx,
throttle::open_file_permit(),
)
.await;
drop(permit);
let cleanup_result = tokio::fs::remove_dir_all(root).await;
work_started_result.context("detached metadata work did not start")?;
release_work_result.context("detached metadata work ended before its release")?;
drop_started_result.context("the abandoned returned fd did not begin dropping")?;
release_drop_result.context("the abandoned returned fd ended before its release")?;
completion_result.context("the abandoned returned fd did not report completion")?;
cleanup_result?;
assert!(
waiter_was_cancelled,
"the async metadata waiter must observe cancellation"
);
assert!(
admission_was_retained,
"admission was released before the abandoned returned fd was dropped"
);
Ok(())
}
#[tokio::test]
async fn abandoned_unprobed_output_keeps_admission_until_returned_fd_drops()
-> anyhow::Result<()> {
let root = crate::testutils::create_temp_dir().await?;
let file_path = root.join("returned-unprobed");
tokio::fs::write(&file_path, b"x").await?;
let admission = crate::testutils::AdmissionLimit::new().await;
admission.set_files_in_flight(1);
let open_file_guard = throttle::open_file_permit().await;
let (work_started_tx, work_started_rx) = tokio::sync::oneshot::channel();
let (release_work_tx, release_work_rx) = std::sync::mpsc::channel();
let (drop_started_tx, drop_started_rx) = tokio::sync::oneshot::channel();
let (release_drop_tx, release_drop_rx) = std::sync::mpsc::channel();
let (completion, completion_rx) = crate::testutils::CompletionSignal::new();
let task = tokio::spawn(async move {
let admission = open_file_guard.admission();
with_fd_admission(admission, async move {
let _open_file_guard = open_file_guard;
run_fd_admitted_blocking(move || {
let file = std::fs::File::open(file_path)?;
let _ = work_started_tx.send(());
release_work_rx.recv().map_err(std::io::Error::other)?;
Ok(BlockingDropFd {
_file: file,
drop_started: Some(drop_started_tx),
release_drop: release_drop_rx,
_completion: completion,
})
})
.await
})
.await
});
let work_started_result = work_started_rx.await;
task.abort();
let waiter_was_cancelled = matches!(task.await, Err(error) if error.is_cancelled());
let release_work_result = release_work_tx.send(());
let drop_started_result = drop_started_rx.await;
let admission_was_retained = {
let second_permit = throttle::open_file_permit();
tokio::pin!(second_permit);
drop_started_result.is_ok() && futures::poll!(second_permit.as_mut()).is_pending()
};
let release_drop_result = release_drop_tx.send(());
let (completion_result, permit) = crate::testutils::await_completion_and_capacity(
completion_rx,
throttle::open_file_permit(),
)
.await;
drop(permit);
let cleanup_result = tokio::fs::remove_dir_all(root).await;
work_started_result.context("detached unprobed work did not start")?;
release_work_result.context("detached unprobed work ended before its release")?;
drop_started_result.context("the abandoned returned fd did not begin dropping")?;
release_drop_result.context("the abandoned returned fd ended before its release")?;
completion_result.context("the abandoned returned fd did not report completion")?;
cleanup_result?;
assert!(
waiter_was_cancelled,
"the async waiter must observe cancellation"
);
assert!(
admission_was_retained,
"admission was released before the abandoned returned fd was dropped"
);
Ok(())
}
}
#[test]
fn a_run_that_asked_for_nothing_wants_no_root_acl_notice() {
let notice = RootAclNotice::for_preserve(&crate::preserve::preserve_none());
assert!(!notice.wanted);
for kind in EVERY_KIND {
assert!(
!notice.could_warn_for_strict(kind, false),
"a {kind:?} root warned under settings that asked for no preservation"
);
}
}
#[test]
fn strict_resolution_arms_the_notice_on_its_own() {
let notice = RootAclNotice::for_preserve(&crate::preserve::preserve_none());
assert!(notice.could_warn_for_strict(EntryKind::Dir, true));
assert!(notice.could_warn_for_strict(EntryKind::File, true));
}
#[test]
fn the_notice_consults_the_setting_for_the_roots_own_kind() {
let file_only = RootAclNotice {
wanted: true,
file_acl_preserved: true,
dir_acl_preserved: false,
};
assert!(!file_only.could_warn_for_strict(EntryKind::File, false));
assert!(file_only.could_warn_for_strict(EntryKind::Dir, false));
assert!(
file_only.could_warn(),
"a directory root can still warn, so the kind-unknown pre-check must not skip the probe"
);
}
#[test]
fn a_root_that_cannot_carry_an_acl_never_warns() {
let armed = RootAclNotice {
wanted: true,
file_acl_preserved: false,
dir_acl_preserved: false,
};
for kind in [EntryKind::Symlink, EntryKind::Special] {
assert!(!armed.could_warn_for_strict(kind, true));
}
}
#[test]
fn preserving_both_kinds_acls_leaves_nothing_to_warn_about() {
let notice = RootAclNotice::for_preserve(&crate::preserve::preserve_all_with_acls());
assert!(notice.wanted, "`all+acl` did ask for preservation");
assert!(
!notice.could_warn(),
"both kinds' ACLs are carried across, so even an armed run has nothing to say — and \
must not pay the probe to find that out"
);
}
#[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(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot change ownership or set-id modes"
)]
#[tokio::test]
async fn secure_as_copier_takes_ownership_restricts_mode_and_preserves_gid()
-> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let plain = root.make_dir(OsStr::new("reused_plain"), 0o755).await?;
let before = plain.meta().await?;
plain
.secure_as_copier(0o700, before.uid(), before.gid())
.await?;
let after = plain.meta().await?;
assert_eq!(
after.permissions().mode() & 0o7777,
0o700,
"mode restricted"
);
assert_eq!(
after.uid(),
nix::unistd::geteuid().as_raw(),
"owned by copier"
);
assert_eq!(after.gid(), before.gid(), "gid unchanged when it matches");
let sg = root.make_dir(OsStr::new("reused_setgid"), 0o755).await?;
std::fs::set_permissions(
tmp.join("foo/reused_setgid"),
std::fs::Permissions::from_mode(0o2755),
)?;
let sg_before = sg.meta().await?;
assert_eq!(
sg_before.permissions().mode() & 0o2000,
0o2000,
"test setup: S_ISGID is set before lockdown"
);
sg.secure_as_copier(0o2700, sg_before.uid(), sg_before.gid())
.await?;
let sg_after = sg.meta().await?;
assert_eq!(
sg_after.permissions().mode() & 0o7777,
0o2700,
"S_ISGID + 0o700 both preserved"
);
assert_eq!(
sg_after.gid(),
sg_before.gid(),
"gid preserved under setgid"
);
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)).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,
children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(true)),
};
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")).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")).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")).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")).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")).await?;
dst_file.write_all(b"destination")?;
dst_file.flush()?;
let settings = crate::preserve::preserve_all();
set_file_metadata_fd(
&settings,
&src_meta,
None,
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(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot change ownership or set-id modes"
)]
#[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")).await?;
dst_file.write_all(b"x")?;
dst_file.flush()?;
let settings = crate::preserve::preserve_all();
set_file_metadata_fd(
&settings,
&src_meta,
None,
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_file_metadata_fd_keeps_the_file_owner_only_when_utimens_fails()
-> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
struct RejectedTimestamps;
impl crate::preserve::Metadata for RejectedTimestamps {
fn uid(&self) -> u32 {
unsafe { libc::getuid() }
}
fn gid(&self) -> u32 {
unsafe { libc::getgid() }
}
fn atime(&self) -> i64 {
0
}
fn atime_nsec(&self) -> i64 {
0
}
fn mtime(&self) -> i64 {
0
}
fn mtime_nsec(&self) -> i64 {
2_000_000_000
}
fn permissions(&self) -> std::fs::Permissions {
std::fs::Permissions::from_mode(0o4755)
}
}
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let dst_file = root.create_file(OsStr::new("late_chmod")).await?;
let dst_path = tmp.join("foo/late_chmod");
assert_eq!(
std::fs::metadata(&dst_path)?.permissions().mode() & 0o7777,
DST_FILE_CREATE_MODE,
"every destination file starts owner-only"
);
let error = set_file_metadata_fd(
&crate::preserve::preserve_all(),
&RejectedTimestamps,
None,
dst_file.as_fd(),
congestion::Side::Destination,
)
.await
.expect_err("an out-of-range nanosecond field must fail futimens");
assert_eq!(error.raw_os_error(), Some(libc::EINVAL));
drop(dst_file);
assert_eq!(
std::fs::metadata(&dst_path)?.permissions().mode() & 0o7777,
DST_FILE_CREATE_MODE,
"a failure during metadata application must not leave the final mode behind"
);
Ok(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot change ownership or set-id modes"
)]
#[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, None, &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 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?;
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:#}"
);
}
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");
}
}
const ACL_USER_OBJ: u16 = 0x01;
const ACL_USER: u16 = 0x02;
const ACL_GROUP_OBJ: u16 = 0x04;
const ACL_MASK: u16 = 0x10;
const ACL_OTHER: u16 = 0x20;
const ACL_UNDEFINED_ID: u32 = 0xffff_ffff;
fn encode_acl(entries: &[(u16, u16, u32)]) -> Vec<u8> {
let mut out = 2u32.to_le_bytes().to_vec();
for &(tag, perm, id) in entries {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&perm.to_le_bytes());
out.extend_from_slice(&id.to_le_bytes());
}
out
}
fn restrictive_access_acl() -> Vec<u8> {
encode_acl(&[
(ACL_USER_OBJ, 7, ACL_UNDEFINED_ID),
(ACL_USER, 0, 65534),
(ACL_GROUP_OBJ, 0, ACL_UNDEFINED_ID),
(ACL_MASK, 5, ACL_UNDEFINED_ID),
(ACL_OTHER, 5, ACL_UNDEFINED_ID),
])
}
fn permissive_acl() -> Vec<u8> {
encode_acl(&[
(ACL_USER_OBJ, 7, ACL_UNDEFINED_ID),
(ACL_USER, 7, 65534),
(ACL_GROUP_OBJ, 5, ACL_UNDEFINED_ID),
(ACL_MASK, 7, ACL_UNDEFINED_ID),
(ACL_OTHER, 5, ACL_UNDEFINED_ID),
])
}
fn rejected_access_acl() -> Vec<u8> {
encode_acl(&[(ACL_USER, 7, 65534)])
}
fn set_xattr_at(path: &std::path::Path, name: &CStr, value: &[u8]) {
let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
let rc = unsafe {
libc::setxattr(
cpath.as_ptr(),
name.as_ptr(),
value.as_ptr().cast(),
value.len(),
0,
)
};
assert_eq!(
rc,
0,
"fixture setxattr({name:?}) on {path:?} failed: {} — this filesystem cannot hold \
POSIX ACLs, so these tests cannot run here",
std::io::Error::last_os_error()
);
}
fn get_xattr_at(path: &std::path::Path, name: &CStr) -> Option<Vec<u8>> {
let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
let size =
unsafe { libc::getxattr(cpath.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0) };
if size < 0 {
let err = std::io::Error::last_os_error();
assert_eq!(
err.raw_os_error(),
Some(libc::ENODATA),
"getxattr({name:?}) on {path:?} failed with {err} — only ENODATA means \"no such \
attribute\"; anything else means the check never happened"
);
return None;
}
let mut buf = vec![0u8; size as usize];
if buf.is_empty() {
return Some(buf);
}
let n = unsafe {
libc::getxattr(
cpath.as_ptr(),
name.as_ptr(),
buf.as_mut_ptr().cast(),
buf.len(),
)
};
assert!(
n >= 0,
"getxattr({name:?}) on {path:?} failed after its size was read: {}",
std::io::Error::last_os_error()
);
buf.truncate(n as usize);
Some(buf)
}
fn mode_of(path: &std::path::Path) -> u32 {
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(path).unwrap().permissions().mode() & 0o7777
}
#[cfg_attr(rcp_nix_sandbox, ignore = "Nix sandbox cannot write POSIX ACL xattrs")]
#[tokio::test]
async fn read_acls_fd_round_trips_an_access_acl() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let src_path = tmp.join("foo/acl_src");
tokio::fs::write(&src_path, b"x").await?;
let blob = restrictive_access_acl();
set_xattr_at(&src_path, ACL_ACCESS_XATTR, &blob);
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let (file, _) = root.open_file_read(OsStr::new("acl_src")).await?;
let acls = read_acls_fd(file.as_fd(), congestion::Side::Source, false).await?;
assert_eq!(acls.access.as_deref(), Some(blob.as_slice()));
assert_eq!(acls.default, None, "a file has no default ACL to read");
Ok(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot provide both POSIX ACL and user xattrs this test requires"
)]
#[tokio::test]
async fn read_acls_fd_reads_an_acl_larger_than_the_stack_buffers() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let src_path = tmp.join("foo/big_acl");
tokio::fs::write(&src_path, b"x").await?;
let mut entries = vec![(ACL_USER_OBJ, 7, ACL_UNDEFINED_ID)];
entries.extend((0..80).map(|i| (ACL_USER, 5, 100_000 + i)));
entries.extend([
(ACL_GROUP_OBJ, 0, ACL_UNDEFINED_ID),
(ACL_MASK, 5, ACL_UNDEFINED_ID),
(ACL_OTHER, 0, ACL_UNDEFINED_ID),
]);
let blob = encode_acl(&entries);
assert!(blob.len() > 512, "fixture must overflow the read buffer");
set_xattr_at(&src_path, ACL_ACCESS_XATTR, &blob);
for i in 0..30 {
let name = std::ffi::CString::new(format!("user.padding_attribute_number_{i:03}"))?;
set_xattr_at(&src_path, &name, b"v");
}
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let (file, _) = root.open_file_read(OsStr::new("big_acl")).await?;
let acls = read_acls_fd(file.as_fd(), congestion::Side::Source, false).await?;
assert_eq!(acls.access.as_deref(), Some(blob.as_slice()));
Ok(())
}
#[tokio::test]
async fn read_acls_fd_reports_no_acl_when_there_is_none() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root = Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Source).await?;
let (file, _) = root.open_file_read(OsStr::new("0.txt")).await?;
let acls = read_acls_fd(file.as_fd(), congestion::Side::Source, false).await?;
assert_eq!(acls, Acls::default(), "an entry with no xattrs has no ACL");
Ok(())
}
#[cfg_attr(rcp_nix_sandbox, ignore = "Nix sandbox cannot write POSIX ACL xattrs")]
#[tokio::test]
async fn read_acls_fd_reads_a_directorys_default_acl_only_when_asked() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let dir_path = tmp.join("foo/acl_dir");
tokio::fs::create_dir(&dir_path).await?;
let access = restrictive_access_acl();
let default = permissive_acl();
set_xattr_at(&dir_path, ACL_ACCESS_XATTR, &access);
set_xattr_at(&dir_path, ACL_DEFAULT_XATTR, &default);
let dir = Dir::open_root_dir(&dir_path, false, congestion::Side::Source).await?;
let both = dir.read_acls().await?;
assert_eq!(both.access.as_deref(), Some(access.as_slice()));
assert_eq!(both.default.as_deref(), Some(default.as_slice()));
let access_only = read_acls_fd(dir.fd.as_fd(), congestion::Side::Source, false).await?;
assert_eq!(access_only.access.as_deref(), Some(access.as_slice()));
assert_eq!(access_only.default, None);
Ok(())
}
#[cfg_attr(rcp_nix_sandbox, ignore = "Nix sandbox cannot write POSIX ACL xattrs")]
#[tokio::test]
async fn apply_acls_fd_installs_the_source_acl_and_clears_what_the_source_lacked()
-> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let dst_path = tmp.join("foo/applied");
tokio::fs::write(&dst_path, b"x").await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let (file, _) = root.open_file_read(OsStr::new("applied")).await?;
let blob = restrictive_access_acl();
apply_acls_fd(
file.as_fd(),
congestion::Side::Destination,
&Acls {
access: Some(blob.clone()),
default: None,
},
false,
)
.await?;
assert_eq!(
get_xattr_at(&dst_path, ACL_ACCESS_XATTR).as_deref(),
Some(blob.as_slice())
);
apply_acls_fd(
file.as_fd(),
congestion::Side::Destination,
&Acls::default(),
false,
)
.await?;
assert_eq!(get_xattr_at(&dst_path, ACL_ACCESS_XATTR), None);
apply_acls_fd(
file.as_fd(),
congestion::Side::Destination,
&Acls::default(),
false,
)
.await?;
Ok(())
}
#[tokio::test]
async fn apply_acls_fd_fails_a_set_the_filesystem_cannot_hold_but_not_a_remove()
-> anyhow::Result<()> {
let probe = std::fs::File::open("/proc/self/environ")?;
let error = apply_acls_fd(
probe.as_fd(),
congestion::Side::Destination,
&Acls {
access: Some(restrictive_access_acl()),
default: None,
},
false,
)
.await
.expect_err("a destination that cannot hold the source's ACL must fail the entry");
assert_eq!(error.raw_os_error(), Some(libc::EOPNOTSUPP));
apply_acls_fd(
probe.as_fd(),
congestion::Side::Destination,
&Acls::default(),
false,
)
.await
.expect("clearing an ACL a filesystem cannot hold is already satisfied");
Ok(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
)]
#[tokio::test]
async fn set_file_metadata_fd_applies_a_setuid_source_mode_and_its_acl() -> 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/acl_setuid_src");
tokio::fs::write(&src_path, b"x").await?;
std::fs::set_permissions(&src_path, std::fs::Permissions::from_mode(0o4700))?;
let blob = restrictive_access_acl();
set_xattr_at(&src_path, ACL_ACCESS_XATTR, &blob);
assert_eq!(mode_of(&src_path), 0o4755, "fixture source mode");
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let src_handle = root.child(OsStr::new("acl_setuid_src")).await?;
let src_meta = src_handle.meta().clone();
let mut dst_file = root.create_file(OsStr::new("acl_setuid_dst")).await?;
dst_file.write_all(b"x")?;
dst_file.flush()?;
set_file_metadata_fd(
&crate::preserve::preserve_all_with_acls(),
&src_meta,
Some(&Acls {
access: Some(blob.clone()),
default: None,
}),
dst_file.as_fd(),
congestion::Side::Destination,
)
.await?;
drop(dst_file);
let dst_path = tmp.join("foo/acl_setuid_dst");
assert_eq!(
mode_of(&dst_path),
mode_of(&src_path),
"the special bits come from the chmod and the rwx bits from the ACL; both must land"
);
assert_eq!(mode_of(&dst_path), 0o4755);
assert_eq!(
get_xattr_at(&dst_path, ACL_ACCESS_XATTR).as_deref(),
Some(blob.as_slice()),
"the destination must not be more permissive than its source"
);
Ok(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
)]
#[tokio::test]
async fn set_file_metadata_fd_keeps_the_file_owner_only_when_the_acl_fails()
-> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = testutils::setup_test_dir().await?;
let src_path = tmp.join("foo/acl_fail_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_meta = root.child(OsStr::new("acl_fail_src")).await?.meta().clone();
let dst_file = root.create_file(OsStr::new("acl_fail_dst")).await?;
let dst_path = tmp.join("foo/acl_fail_dst");
let error = set_file_metadata_fd(
&crate::preserve::preserve_all_with_acls(),
&src_meta,
Some(&Acls {
access: Some(rejected_access_acl()),
default: None,
}),
dst_file.as_fd(),
congestion::Side::Destination,
)
.await
.expect_err("an ACL the kernel rejects must fail the entry");
assert_eq!(error.raw_os_error(), Some(libc::EINVAL));
drop(dst_file);
assert_eq!(
mode_of(&dst_path),
0o4000 | DST_FILE_CREATE_MODE,
"the chmod preceding a source ACL must carry the special bits ALONE — a failed ACL \
application left the file at a mode it had not earned"
);
Ok(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
)]
#[tokio::test]
async fn set_file_metadata_fd_clears_an_inherited_acl_and_still_applies_the_full_mode()
-> 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/plain_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_meta = root
.child(OsStr::new("plain_setuid_src"))
.await?
.meta()
.clone();
let mut dst_file = root.create_file(OsStr::new("plain_setuid_dst")).await?;
dst_file.write_all(b"x")?;
dst_file.flush()?;
let dst_path = tmp.join("foo/plain_setuid_dst");
set_xattr_at(&dst_path, ACL_ACCESS_XATTR, &permissive_acl());
set_file_metadata_fd(
&crate::preserve::preserve_all_with_acls(),
&src_meta,
Some(&Acls::default()),
dst_file.as_fd(),
congestion::Side::Destination,
)
.await?;
drop(dst_file);
assert_eq!(
get_xattr_at(&dst_path, ACL_ACCESS_XATTR),
None,
"a source with no ACL must leave the destination with no ACL"
);
assert_eq!(
mode_of(&dst_path),
0o4755,
"without a source ACL the chmod is the widening step and applies the FULL mode"
);
Ok(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
)]
#[tokio::test]
async fn set_dir_metadata_fd_installs_access_and_default_acls() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let src_dir_path = tmp.join("foo/acl_src_dir");
tokio::fs::create_dir(&src_dir_path).await?;
let access = restrictive_access_acl();
let default = permissive_acl();
set_xattr_at(&src_dir_path, ACL_ACCESS_XATTR, &access);
set_xattr_at(&src_dir_path, ACL_DEFAULT_XATTR, &default);
let src_meta = root.child(OsStr::new("acl_src_dir")).await?.meta().clone();
let dst_dir = root.make_dir(OsStr::new("acl_dst_dir"), 0o700).await?;
let dst_path = tmp.join("foo/acl_dst_dir");
set_dir_metadata_fd(
&crate::preserve::preserve_all_with_acls(),
&src_meta,
Some(&Acls {
access: Some(access.clone()),
default: Some(default.clone()),
}),
&dst_dir,
)
.await?;
assert_eq!(
get_xattr_at(&dst_path, ACL_ACCESS_XATTR).as_deref(),
Some(access.as_slice())
);
assert_eq!(
get_xattr_at(&dst_path, ACL_DEFAULT_XATTR).as_deref(),
Some(default.as_slice()),
"the default ACL decides what CHILDREN inherit; dropping it changes the destination \
tree's inheritance policy"
);
assert_eq!(mode_of(&dst_path), mode_of(&src_dir_path));
assert_eq!(mode_of(&dst_path), 0o755);
Ok(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
)]
#[tokio::test]
async fn set_dir_metadata_fd_keeps_the_dir_owner_only_when_the_default_acl_fails()
-> 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 src_dir_path = tmp.join("foo/acl_dfail_src");
tokio::fs::create_dir(&src_dir_path).await?;
std::fs::set_permissions(&src_dir_path, std::fs::Permissions::from_mode(0o755))?;
let src_meta = root
.child(OsStr::new("acl_dfail_src"))
.await?
.meta()
.clone();
let dst_dir = root.make_dir(OsStr::new("acl_dfail_dst"), 0o700).await?;
let error = set_dir_metadata_fd(
&crate::preserve::preserve_all_with_acls(),
&src_meta,
Some(&Acls {
access: Some(permissive_acl()),
default: Some(rejected_access_acl()),
}),
&dst_dir,
)
.await
.expect_err("a default ACL the kernel rejects must fail the entry");
assert_eq!(error.raw_os_error(), Some(libc::EINVAL));
assert_eq!(
mode_of(&tmp.join("foo/acl_dfail_dst")),
DST_DIR_CREATE_MODE,
"the access ACL must be applied LAST: a failing default apply left the directory wider"
);
Ok(())
}
#[cfg_attr(
rcp_nix_sandbox,
ignore = "Nix sandbox cannot write POSIX ACL xattrs or set-id modes"
)]
#[tokio::test]
async fn set_reused_dir_metadata_fd_verifies_a_setgid_source_with_an_acl() -> 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 src_dir_path = tmp.join("foo/setgid_acl_src");
tokio::fs::create_dir(&src_dir_path).await?;
std::fs::set_permissions(&src_dir_path, std::fs::Permissions::from_mode(0o2700))?;
let access = restrictive_access_acl();
set_xattr_at(&src_dir_path, ACL_ACCESS_XATTR, &access);
let src_meta = root
.child(OsStr::new("setgid_acl_src"))
.await?
.meta()
.clone();
assert_eq!(
mode_of(&src_dir_path),
0o2755,
"fixture must keep setgid alongside the ACL (whose MASK set the group bits)"
);
let dst_dir = root.make_dir(OsStr::new("setgid_acl_dst"), 0o700).await?;
let (uid, gid) = {
let meta = dst_dir.meta().await?;
use crate::preserve::Metadata as _;
(meta.uid(), meta.gid())
};
set_reused_dir_metadata_fd(
&crate::preserve::preserve_all_with_acls(),
&src_meta,
Some(&Acls {
access: Some(access.clone()),
default: None,
}),
Some(ReusedDirLock {
restore_owner: (uid, gid),
state: Arc::new(std::sync::Mutex::new(DefaultAclGuard::Armed {
original: None,
})),
fd: Arc::clone(&dst_dir.fd),
children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}),
&dst_dir,
)
.await?;
let dst_path = tmp.join("foo/setgid_acl_dst");
assert_eq!(
mode_of(&dst_path),
0o2755,
"setgid from the chmod, rwx from the ACL"
);
assert_eq!(
get_xattr_at(&dst_path, ACL_ACCESS_XATTR).as_deref(),
Some(access.as_slice())
);
Ok(())
}
#[tokio::test]
async fn appliers_refuse_a_call_site_that_did_not_carry_acls() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let src_meta = root.child(OsStr::new("0.txt")).await?.meta().clone();
let dst_file = root.create_file(OsStr::new("no_acls_carried")).await?;
let error = set_file_metadata_fd(
&crate::preserve::preserve_all_with_acls(),
&src_meta,
None,
dst_file.as_fd(),
congestion::Side::Destination,
)
.await
.expect_err("requesting ACL preservation on a path that carries no ACLs must fail");
assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
set_file_metadata_fd(
&crate::preserve::preserve_all(),
&src_meta,
None,
dst_file.as_fd(),
congestion::Side::Destination,
)
.await?;
Ok(())
}
#[cfg_attr(rcp_nix_sandbox, ignore = "Nix sandbox cannot write POSIX ACL xattrs")]
#[tokio::test]
async fn guarded_default_acl_write_is_skipped_once_disarmed() -> anyhow::Result<()> {
let tmp = testutils::setup_test_dir().await?;
let root =
Dir::open_root_dir(&tmp.join("foo"), false, congestion::Side::Destination).await?;
let dir = root.make_dir(OsStr::new("locked"), 0o700).await?;
let lock = ReusedDirLock {
restore_owner: (0, 0),
state: Arc::new(std::sync::Mutex::new(DefaultAclGuard::Disarmed)),
fd: Arc::clone(&dir.fd),
children_may_inherit: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
lock.write_default_acl_guarded(congestion::Side::Destination, restrictive_access_acl())
.await?;
assert_eq!(
get_xattr_at(&tmp.join("foo/locked"), ACL_DEFAULT_XATTR),
None,
"a disarmed guard must refuse the write — landing it would undo the restore"
);
Ok(())
}
}