use std::ffi::OsStr;
use std::io;
#[cfg(any(test, target_os = "linux", target_os = "macos"))]
use std::path::Path;
use cap_std::fs::{Dir, File, OpenOptions};
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn open_commit_anchor(path: &Path) -> io::Result<Dir> {
match rustix::fs::open(path, SEARCH_ONLY_FLAGS, rustix::fs::Mode::empty()) {
Ok(handle) => Ok(Dir::from_std_file(std::fs::File::from(handle))),
Err(_) => Dir::open_ambient_dir(path, cap_std::ambient_authority()),
}
}
#[cfg(target_os = "linux")]
const SEARCH_ONLY_FLAGS: rustix::fs::OFlags = rustix::fs::OFlags::PATH
.union(rustix::fs::OFlags::DIRECTORY)
.union(rustix::fs::OFlags::CLOEXEC);
#[cfg(target_os = "macos")]
const SEARCH_ONLY_FLAGS: rustix::fs::OFlags =
rustix::fs::OFlags::from_bits_retain(libc::O_SEARCH as u32).union(rustix::fs::OFlags::CLOEXEC);
#[cfg(all(test, not(any(target_os = "linux", target_os = "macos"))))]
pub(crate) fn open_commit_anchor(path: &Path) -> io::Result<Dir> {
Dir::open_ambient_dir(path, cap_std::ambient_authority())
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) enum FlaggedRename {
Committed,
Unsupported,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn rename_no_replace_at(
anchor: &Dir,
staged: &OsStr,
final_name: &OsStr,
) -> io::Result<FlaggedRename> {
use std::os::fd::AsFd;
use rustix::fs::{RenameFlags, renameat_with};
match renameat_with(
anchor.as_fd(),
staged,
anchor.as_fd(),
final_name,
RenameFlags::NOREPLACE,
) {
Ok(()) => Ok(FlaggedRename::Committed),
Err(e) => {
let error = io::Error::from(e);
if crate::fs::atomic::no_replace_rename_unsupported(&error) {
Ok(FlaggedRename::Unsupported)
} else {
Err(error)
}
}
}
}
pub(crate) const STAGED_FILE_MODE: u32 = 0o600;
#[cfg(unix)]
pub(crate) const STAGED_DIR_MODE: u32 = 0o700;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[cfg(unix)]
pub(crate) enum CommitKind {
File,
#[cfg_attr(not(any(target_os = "linux", target_os = "macos")), allow(dead_code))]
Directory,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[cfg(unix)]
pub(crate) enum CommitRoute {
Renamed,
Linked,
}
#[derive(Debug)]
#[cfg(unix)]
pub(crate) struct CommitFailure {
pub(crate) error: io::Error,
pub(crate) claim_left: bool,
}
#[cfg(unix)]
impl CommitFailure {
pub(crate) fn plain(error: io::Error) -> Self {
Self {
error,
claim_left: false,
}
}
}
#[cfg(unix)]
pub(crate) fn commit_by_link_or_claim(
anchor: &Dir,
staged: &OsStr,
final_name: &OsStr,
kind: CommitKind,
) -> Result<CommitRoute, CommitFailure> {
if kind == CommitKind::File {
match anchor.hard_link(staged, anchor, final_name) {
Ok(()) => return Ok(CommitRoute::Linked),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
return Err(CommitFailure::plain(e));
}
Err(_) => {}
}
}
commit_by_claim_with(anchor, staged, final_name, kind, |_| {})
}
#[cfg(unix)]
pub(crate) fn commit_by_claim_with(
anchor: &Dir,
staged: &OsStr,
final_name: &OsStr,
kind: CommitKind,
after_claim: impl FnOnce(&Dir),
) -> Result<CommitRoute, CommitFailure> {
claim_final_name(anchor, final_name, kind).map_err(CommitFailure::plain)?;
after_claim(anchor);
anchor
.rename(staged, anchor, final_name)
.map(|()| CommitRoute::Renamed)
.map_err(|error| CommitFailure {
error,
claim_left: true,
})
}
#[cfg(unix)]
fn claim_final_name(anchor: &Dir, final_name: &OsStr, kind: CommitKind) -> io::Result<()> {
match kind {
CommitKind::File => create_file_at(anchor, final_name, STAGED_FILE_MODE).map(drop),
CommitKind::Directory => create_dir_at(anchor, final_name),
}
}
pub(crate) fn create_file_at(
parent: &Dir,
name: &OsStr,
#[cfg_attr(not(unix), allow(unused_variables))] mode: u32,
) -> io::Result<File> {
use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt};
let mut options = OpenOptions::new();
options.write(true).create_new(true);
options.follow(FollowSymlinks::No);
#[cfg(unix)]
{
use cap_fs_ext::OpenOptionsExt;
options.mode(mode);
}
parent.open_with(name, &options)
}
#[cfg(unix)]
pub(crate) fn create_dir_at(parent: &Dir, name: &OsStr) -> io::Result<()> {
use cap_std::fs::{DirBuilder, DirBuilderExt};
let mut builder = DirBuilder::new();
builder.mode(STAGED_DIR_MODE);
parent.create_dir_with(name, &builder)
}
#[cfg(not(unix))]
pub(crate) fn create_dir_at(parent: &Dir, name: &OsStr) -> io::Result<()> {
parent.create_dir(name)
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
const WRITE_AND_SEARCH_ONLY_MODE: u32 = 0o300;
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
pub(crate) struct SearchOnlyDir {
_parent: tempfile::TempDir,
path: std::path::PathBuf,
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
impl SearchOnlyDir {
pub(crate) fn new() -> Self {
let parent = tempfile::TempDir::new().unwrap();
let path = parent.path().join("out");
std::fs::create_dir(&path).unwrap();
Self {
_parent: parent,
path,
}
}
pub(crate) fn close_reading(&self) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&self.path,
std::fs::Permissions::from_mode(WRITE_AND_SEARCH_ONLY_MODE),
)
.unwrap();
assert!(
std::fs::read_dir(&self.path).is_err(),
"the directory must not be readable, or the test proves nothing"
);
}
pub(crate) fn path(&self) -> &std::path::Path {
&self.path
}
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
impl Drop for SearchOnlyDir {
fn drop(&mut self) {
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(STAGED_DIR_MODE));
}
}
#[cfg(all(test, unix))]
mod tests {
use std::ffi::OsStr;
use std::fs;
use super::*;
fn anchor(dir: &std::path::Path) -> Dir {
Dir::open_ambient_dir(dir, cap_std::ambient_authority()).unwrap()
}
#[test]
fn a_file_commits_by_link_and_leaves_the_staging_name() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("out.incomplete"), b"payload").unwrap();
let anchor = anchor(tmp.path());
let route = commit_by_link_or_claim(
&anchor,
OsStr::new("out.incomplete"),
OsStr::new("out"),
CommitKind::File,
)
.unwrap();
assert_eq!(route, CommitRoute::Linked);
assert_eq!(fs::read(tmp.path().join("out")).unwrap(), b"payload");
assert!(
tmp.path().join("out.incomplete").exists(),
"the staging name is the caller's to remove"
);
}
#[test]
fn the_claim_route_renames_the_staged_entry_over_its_own_claim() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir(tmp.path().join("out.incomplete")).unwrap();
fs::write(tmp.path().join("out.incomplete").join("f"), b"payload").unwrap();
let anchor = anchor(tmp.path());
let route = commit_by_claim_with(
&anchor,
OsStr::new("out.incomplete"),
OsStr::new("out"),
CommitKind::Directory,
|_| {},
)
.unwrap();
assert_eq!(route, CommitRoute::Renamed);
assert_eq!(
fs::read(tmp.path().join("out").join("f")).unwrap(),
b"payload"
);
assert!(!tmp.path().join("out.incomplete").exists());
}
#[test]
fn an_occupied_final_name_is_refused_and_left_alone() {
for commit_kind in [CommitKind::File, CommitKind::Directory] {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("out.incomplete"), b"payload").unwrap();
fs::write(tmp.path().join("out"), b"existing").unwrap();
let anchor = anchor(tmp.path());
let failure = commit_by_link_or_claim(
&anchor,
OsStr::new("out.incomplete"),
OsStr::new("out"),
commit_kind,
)
.expect_err("an occupied final name must refuse the commit");
assert_eq!(
failure.error.kind(),
io::ErrorKind::AlreadyExists,
"{commit_kind:?}"
);
assert!(!failure.claim_left, "no claim was made, so none was left");
assert_eq!(fs::read(tmp.path().join("out")).unwrap(), b"existing");
assert!(tmp.path().join("out.incomplete").exists());
}
}
#[test]
fn a_failed_rename_leaves_the_claim_and_reports_it() {
for commit_kind in [CommitKind::File, CommitKind::Directory] {
let tmp = tempfile::TempDir::new().unwrap();
let anchor = anchor(tmp.path());
let out = tmp.path().join("out");
let failure = commit_by_claim_with(
&anchor,
OsStr::new("missing.incomplete"),
OsStr::new("out"),
commit_kind,
|_| {},
)
.expect_err("a rename with no staged entry must fail the commit");
assert_eq!(
failure.error.kind(),
io::ErrorKind::NotFound,
"{commit_kind:?}"
);
assert!(failure.claim_left, "{commit_kind:?}");
let left = fs::symlink_metadata(&out).expect("the claim stays at the final name");
match commit_kind {
CommitKind::File => assert!(left.is_file() && left.len() == 0, "{left:?}"),
CommitKind::Directory => assert!(left.is_dir(), "{left:?}"),
}
let retry = commit_by_link_or_claim(
&anchor,
OsStr::new("missing.incomplete"),
OsStr::new("out"),
commit_kind,
)
.expect_err("the name left occupied must refuse the next attempt");
assert_eq!(
retry.error.kind(),
io::ErrorKind::AlreadyExists,
"{commit_kind:?}"
);
assert!(!retry.claim_left, "a refused attempt makes no claim");
assert!(out.exists(), "a refused attempt removes nothing either");
}
}
#[test]
fn an_entry_put_in_place_of_the_claim_survives_the_failed_commit() {
for commit_kind in [CommitKind::File, CommitKind::Directory] {
let tmp = tempfile::TempDir::new().unwrap();
let staged = tmp.path().join("out.incomplete");
match commit_kind {
CommitKind::File => fs::write(&staged, b"payload").unwrap(),
CommitKind::Directory => fs::create_dir(&staged).unwrap(),
}
let anchor = anchor(tmp.path());
let failure = commit_by_claim_with(
&anchor,
OsStr::new("out.incomplete"),
OsStr::new("out"),
commit_kind,
|dir| {
match commit_kind {
CommitKind::File => dir.remove_file("out").unwrap(),
CommitKind::Directory => dir.remove_dir("out").unwrap(),
}
dir.create_dir("out").unwrap();
dir.write(std::path::Path::new("out").join("theirs"), b"planted")
.unwrap();
},
)
.expect_err("a rename over a non-empty directory must fail the commit");
assert!(failure.claim_left, "{commit_kind:?}");
assert_eq!(
fs::read(tmp.path().join("out").join("theirs")).unwrap(),
b"planted",
"an entry put in place of the placeholder must survive ({commit_kind:?})"
);
assert!(
fs::symlink_metadata(&staged).is_ok(),
"the staged entry is the caller's to remove ({commit_kind:?})"
);
}
}
#[test]
fn an_empty_file_put_in_place_of_the_claim_survives_the_failed_commit() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("out.incomplete"), b"payload").unwrap();
let anchor = anchor(tmp.path());
let mut planted_identity = None;
let failure = commit_by_claim_with(
&anchor,
OsStr::new("out.incomplete"),
OsStr::new("out"),
CommitKind::File,
|dir| {
dir.remove_file("out").unwrap();
dir.write("out", b"").unwrap();
planted_identity = crate::fs::atomic::file_identity(&dir.metadata("out").unwrap());
dir.remove_file("out.incomplete").unwrap();
},
)
.expect_err("a rename with no staged entry must fail the commit");
assert!(failure.claim_left);
let left = crate::fs::atomic::file_identity(&anchor.metadata("out").unwrap());
assert!(
planted_identity.is_some(),
"the test needs identities to compare"
);
assert_eq!(
left, planted_identity,
"the planted file must be the one left"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn a_search_only_anchor_cannot_read_the_directory_it_commits_into() {
let out = SearchOnlyDir::new();
fs::write(out.path().join("staged"), b"payload").unwrap();
out.close_reading();
let anchor = open_commit_anchor(out.path()).unwrap();
anchor
.symlink_metadata("staged")
.expect("a commit resolves names through the anchor");
assert!(
anchor.entries().is_err(),
"the anchor must not be able to list the directory"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn the_no_replace_rename_commits_through_a_search_only_anchor() {
let out = SearchOnlyDir::new();
fs::write(out.path().join("staged"), b"payload").unwrap();
out.close_reading();
let anchor = open_commit_anchor(out.path()).unwrap();
assert!(matches!(
rename_no_replace_at(&anchor, OsStr::new("staged"), OsStr::new("out")).unwrap(),
FlaggedRename::Committed
));
assert_eq!(fs::read(out.path().join("out")).unwrap(), b"payload");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn the_no_replace_rename_still_refuses_an_occupied_name_through_a_search_only_anchor() {
let out = SearchOnlyDir::new();
fs::write(out.path().join("staged"), b"payload").unwrap();
fs::write(out.path().join("out"), b"existing").unwrap();
out.close_reading();
let anchor = open_commit_anchor(out.path()).unwrap();
let error = rename_no_replace_at(&anchor, OsStr::new("staged"), OsStr::new("out"))
.expect_err("an occupied final name must refuse the rename");
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(out.path().join("out")).unwrap(), b"existing");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn both_fallback_routes_commit_through_a_search_only_anchor() {
let out = SearchOnlyDir::new();
fs::write(out.path().join("file.incomplete"), b"payload").unwrap();
fs::create_dir(out.path().join("dir.incomplete")).unwrap();
fs::write(out.path().join("dir.incomplete").join("f"), b"inner").unwrap();
out.close_reading();
let anchor = open_commit_anchor(out.path()).unwrap();
let linked = commit_by_link_or_claim(
&anchor,
OsStr::new("file.incomplete"),
OsStr::new("file"),
CommitKind::File,
)
.unwrap();
assert_eq!(linked, CommitRoute::Linked);
anchor.remove_file("file.incomplete").unwrap();
let claimed = commit_by_claim_with(
&anchor,
OsStr::new("dir.incomplete"),
OsStr::new("dir"),
CommitKind::Directory,
|_| {},
)
.unwrap();
assert_eq!(claimed, CommitRoute::Renamed);
assert_eq!(fs::read(out.path().join("file")).unwrap(), b"payload");
assert_eq!(
fs::read(out.path().join("dir").join("f")).unwrap(),
b"inner"
);
}
#[test]
fn a_file_claim_is_created_owner_only() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let anchor = anchor(tmp.path());
let claim = create_file_at(&anchor, OsStr::new("claim"), STAGED_FILE_MODE).unwrap();
drop(claim);
let mode = fs::metadata(tmp.path().join("claim"))
.unwrap()
.permissions()
.mode()
& crate::archive::PERMISSION_BITS_MASK;
assert_eq!(mode, STAGED_FILE_MODE);
}
#[test]
fn a_directory_is_created_owner_only_whatever_the_umask() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let anchor = anchor(tmp.path());
create_dir_at(&anchor, OsStr::new("staged")).unwrap();
let mode = fs::metadata(tmp.path().join("staged"))
.unwrap()
.permissions()
.mode()
& crate::archive::PERMISSION_BITS_MASK;
assert_eq!(mode, STAGED_DIR_MODE);
}
}