From be766cba0e3dd8e59ca71e1c724f78f175591ad3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:27:30 -0700 Subject: [PATCH 1/4] Name the entry that blocks a full copy --- crates/core/src/lib.rs | 10 +++ crates/core/src/rpc.rs | 1 + crates/core/src/strategy/apfs.rs | 142 +++++++++++++++++++++++++++---- npm/rift-snapshot/index.d.ts | 1 + 4 files changed, 136 insertions(+), 18 deletions(-) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2e4b878..24b72c8 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -51,6 +51,16 @@ pub enum Error { MissingMarker(PathBuf), #[error("unsupported filesystem entry: {0}")] UnsupportedEntry(PathBuf), + #[error( + "cannot clone {root}: {path} is {problem}, so the whole-tree clone is refused. \ + Run `chmod {permission} {path}`, or remove it if it is empty and untracked" + )] + BlockedEntry { + root: PathBuf, + path: PathBuf, + problem: &'static str, + permission: &'static str, + }, #[error("unsafe Git source: {0}")] UnsafeGit(String), #[error("directory is not managed by rift: {0}")] diff --git a/crates/core/src/rpc.rs b/crates/core/src/rpc.rs index e77bdad..26f2bf1 100644 --- a/crates/core/src/rpc.rs +++ b/crates/core/src/rpc.rs @@ -94,6 +94,7 @@ impl From for Failure { } Error::MissingMarker(path) => ("missing_marker", Some(path.clone())), Error::UnsupportedEntry(path) => ("unsupported_entry", Some(path.clone())), + Error::BlockedEntry { path, .. } => ("blocked_entry", Some(path.clone())), Error::UnsafeGit(_) => ("unsafe_git", None), Error::NotManaged(path) => ("not_managed", Some(path.clone())), Error::MarkerMismatch(path) => ("marker_mismatch", Some(path.clone())), diff --git a/crates/core/src/strategy/apfs.rs b/crates/core/src/strategy/apfs.rs index b2e08f0..8912e65 100644 --- a/crates/core/src/strategy/apfs.rs +++ b/crates/core/src/strategy/apfs.rs @@ -9,7 +9,7 @@ pub(super) struct ApfsStrategy; impl Strategy for ApfsStrategy { fn copy_directory(&self, from: &Path, to: &Path, mode: CopyMode) -> Result<()> { match mode { - CopyMode::All => clone_path_apfs(from, to), + CopyMode::All => clone_tree_apfs(from, to), CopyMode::Filtered => clone_filtered_directory_apfs(from, to), } } @@ -78,29 +78,135 @@ fn clone_filtered_directory_apfs(from: &Path, to: &Path) -> Result<()> { Ok(()) } +/// Clones a directory tree in one `clonefile` call. The kernel refuses the +/// whole call with `EACCES` when any file inside is unreadable or any directory +/// inside is unreadable or unsearchable, even one the caller owns, and the +/// error names only the root. A refused call leaves nothing behind, so the +/// source is scanned for the entry to name instead. The source is not changed. +fn clone_tree_apfs(from: &Path, to: &Path) -> Result<()> { + clonefile(&c_path(from)?, &c_path(to)?).map_err(|error| { + if matches!(error.raw_os_error(), Some(libc::EACCES | libc::EPERM)) + && let Some(blocked) = find_blocked_entry(from) + { + return blocked; + } + clone_error(from, to, error) + }) +} + +/// Finds the first entry that makes a whole-tree clone of `root` fail. +/// `node_modules` directories are large and rarely the cause, so they are +/// searched only after the rest of the tree comes up clean. +fn find_blocked_entry(root: &Path) -> Option { + let mut deferred = Vec::new(); + scan_for_blocked_entry(root, root, Some(&mut deferred)).or_else(|| { + deferred + .iter() + .find_map(|path| scan_for_blocked_entry(root, path, None)) + }) +} + +fn scan_for_blocked_entry( + root: &Path, + from: &Path, + mut deferred: Option<&mut Vec>, +) -> Option { + let mut entries = WalkDir::new(from).follow_links(false).into_iter(); + while let Some(entry) = entries.next() { + let entry = match entry { + Ok(entry) => entry, + Err(error) => match error.path() { + Some(path) => return Some(blocked_entry(root, path, "an unreadable entry", "u+r")), + None => continue, + }, + }; + let path = entry.path(); + let file_type = entry.file_type(); + if file_type.is_dir() { + match (accessible(path, libc::R_OK), accessible(path, libc::X_OK)) { + (true, true) => {} + (true, false) => { + return Some(blocked_entry( + root, + path, + "a directory without search permission", + "u+x", + )); + } + (false, true) => { + return Some(blocked_entry(root, path, "an unreadable directory", "u+r")); + } + (false, false) => { + return Some(blocked_entry( + root, + path, + "an unreadable directory without search permission", + "u+rx", + )); + } + } + if entry.depth() > 0 + && entry.file_name() == "node_modules" + && let Some(deferred) = deferred.as_deref_mut() + { + deferred.push(path.to_path_buf()); + entries.skip_current_dir(); + } + } else if file_type.is_file() && !accessible(path, libc::R_OK) { + return Some(blocked_entry(root, path, "an unreadable file", "u+r")); + } + } + None +} + +fn accessible(path: &Path, mode: libc::c_int) -> bool { + let Ok(path) = c_path(path) else { + return true; + }; + // SAFETY: `path` is a null-terminated C string that lives for the call. + unsafe { libc::access(path.as_ptr(), mode) == 0 } +} + +fn blocked_entry( + root: &Path, + path: &Path, + problem: &'static str, + permission: &'static str, +) -> Error { + Error::BlockedEntry { + root: root.to_path_buf(), + path: path.to_path_buf(), + problem, + permission, + } +} + fn clone_path_apfs(from: &Path, to: &Path) -> Result<()> { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; + clonefile(&c_path(from)?, &c_path(to)?).map_err(|error| clone_error(from, to, error)) +} - let source = CString::new(from.as_os_str().as_bytes()) - .map_err(|_| Error::Path(format!("path contains a null byte: {}", from.display())))?; - let destination = CString::new(to.as_os_str().as_bytes()) - .map_err(|_| Error::Path(format!("path contains a null byte: {}", to.display())))?; - // SAFETY: `source` and `destination` are null-terminated C strings - // built above, and both live for the duration of the call. - let result = unsafe { libc::clonefile(source.as_ptr(), destination.as_ptr(), 0) }; - if result == 0 { +fn clonefile(source: &std::ffi::CStr, destination: &std::ffi::CStr) -> std::io::Result<()> { + // SAFETY: `source` and `destination` are null-terminated C strings that + // live for the duration of the call. + if unsafe { libc::clonefile(source.as_ptr(), destination.as_ptr(), 0) } == 0 { return Ok(()); } - let error = std::io::Error::last_os_error(); + Err(std::io::Error::last_os_error()) +} + +/// Only a filesystem or volume that cannot clone means copy-on-write is +/// unavailable. Other errors, such as permissions, `ENOSPC` or `EIO`, are +/// reported as a failed clone of `from`. +fn clone_error(from: &Path, to: &Path, error: std::io::Error) -> Error { if error.kind() == std::io::ErrorKind::AlreadyExists { - return Err(Error::AlreadyExists(to.to_path_buf())); + return Error::AlreadyExists(to.to_path_buf()); + } + match error.raw_os_error() { + Some(libc::ENOTSUP | libc::EOPNOTSUPP | libc::EXDEV) => { + Error::CowUnavailable(format!("failed to clone {}: {}", from.display(), error)) + } + _ => io_at("clone", from, error), } - Err(Error::CowUnavailable(format!( - "failed to clone {}: {}", - from.display(), - error - ))) } /// Replays metadata onto a directory or symlink created fresh at `to`. Cloned diff --git a/npm/rift-snapshot/index.d.ts b/npm/rift-snapshot/index.d.ts index e355b0c..de1c6bd 100644 --- a/npm/rift-snapshot/index.d.ts +++ b/npm/rift-snapshot/index.d.ts @@ -33,6 +33,7 @@ export type RiftErrorCode = | "workspace_not_initialized" | "missing_marker" | "unsupported_entry" + | "blocked_entry" | "unsafe_git" | "not_managed" | "marker_mismatch" From 736ade44f587915715afd95893d532e4c217d070 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:37:47 -0700 Subject: [PATCH 2/4] Keep the clone error and blame only denied entries --- crates/core/src/lib.rs | 20 ++++- crates/core/src/strategy/apfs.rs | 145 +++++++++++++++++++++---------- 2 files changed, 115 insertions(+), 50 deletions(-) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 24b72c8..75f802f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -52,14 +52,17 @@ pub enum Error { #[error("unsupported filesystem entry: {0}")] UnsupportedEntry(PathBuf), #[error( - "cannot clone {root}: {path} is {problem}, so the whole-tree clone is refused. \ - Run `chmod {permission} {path}`, or remove it if it is empty and untracked" + "clone failed for {root}: {source}. Found {path}, {problem}, which blocks whole-tree \ + cloning. Run `chmod {permission} {}`, or remove it if it is empty and untracked", + shell_quote(.path) )] BlockedEntry { root: PathBuf, path: PathBuf, problem: &'static str, permission: &'static str, + #[source] + source: std::io::Error, }, #[error("unsafe Git source: {0}")] UnsafeGit(String), @@ -88,6 +91,19 @@ pub enum Error { }, } +/// Quotes `path` for a POSIX shell, leaving plain paths unquoted. +fn shell_quote(path: &Path) -> String { + let path = path.to_string_lossy(); + if !path.is_empty() + && path + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"_@%+=:,./-".contains(&byte)) + { + return path.into_owned(); + } + format!("'{}'", path.replace('\'', "'\\''")) +} + pub struct Create { pub from: PathBuf, pub name: Option, diff --git a/crates/core/src/strategy/apfs.rs b/crates/core/src/strategy/apfs.rs index 8912e65..43dd7c5 100644 --- a/crates/core/src/strategy/apfs.rs +++ b/crates/core/src/strategy/apfs.rs @@ -82,67 +82,109 @@ fn clone_filtered_directory_apfs(from: &Path, to: &Path) -> Result<()> { /// whole call with `EACCES` when any file inside is unreadable or any directory /// inside is unreadable or unsearchable, even one the caller owns, and the /// error names only the root. A refused call leaves nothing behind, so the -/// source is scanned for the entry to name instead. The source is not changed. +/// source is scanned for a likely culprit. The source is not changed. fn clone_tree_apfs(from: &Path, to: &Path) -> Result<()> { - clonefile(&c_path(from)?, &c_path(to)?).map_err(|error| { - if matches!(error.raw_os_error(), Some(libc::EACCES | libc::EPERM)) - && let Some(blocked) = find_blocked_entry(from) - { - return blocked; - } - clone_error(from, to, error) - }) + clonefile(&c_path(from)?, &c_path(to)?).map_err(|error| refused_clone_error(from, to, error)) +} + +/// Keeps the kernel's error and adds an entry that blocks whole-tree cloning +/// when one is found. The source is blamed only when the destination parent +/// looks writable and the scan finds an entry the caller is denied. Any other +/// scan failure is inconclusive. +fn refused_clone_error(from: &Path, to: &Path, error: std::io::Error) -> Error { + if !is_denied(&error) || destination_refuses(to) { + return clone_error(from, to, error); + } + match find_blocked_entry(from) { + Ok(Some(blocked)) => Error::BlockedEntry { + root: from.to_path_buf(), + path: blocked.path, + problem: blocked.problem, + permission: blocked.permission, + source: error, + }, + Ok(None) | Err(_) => clone_error(from, to, error), + } } -/// Finds the first entry that makes a whole-tree clone of `root` fail. -/// `node_modules` directories are large and rarely the cause, so they are -/// searched only after the rest of the tree comes up clean. -fn find_blocked_entry(root: &Path) -> Option { +/// The kernel also refuses a clone with `EACCES` or `EPERM` when the +/// destination parent is not writable or is immutable or append-only. +fn destination_refuses(to: &Path) -> bool { + use std::os::macos::fs::MetadataExt; + + let parent = match to.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + if !matches!(accessible(parent, libc::W_OK | libc::X_OK), Ok(true)) { + return true; + } + let flags = libc::UF_IMMUTABLE | libc::UF_APPEND | libc::SF_IMMUTABLE | libc::SF_APPEND; + fs::metadata(parent).map_or(true, |metadata| metadata.st_flags() & flags != 0) +} + +struct Blocked { + path: std::path::PathBuf, + problem: &'static str, + permission: &'static str, +} + +/// Finds the first entry the caller is denied, which makes a whole-tree clone +/// of `root` fail. `node_modules` directories are large and rarely the cause, +/// so they are searched only after the rest of the tree comes up clean. An +/// `Err` means the scan could not finish, so nothing can be blamed. +fn find_blocked_entry(root: &Path) -> std::io::Result> { let mut deferred = Vec::new(); - scan_for_blocked_entry(root, root, Some(&mut deferred)).or_else(|| { - deferred - .iter() - .find_map(|path| scan_for_blocked_entry(root, path, None)) - }) + if let Some(blocked) = scan_for_blocked_entry(root, Some(&mut deferred))? { + return Ok(Some(blocked)); + } + for path in &deferred { + if let Some(blocked) = scan_for_blocked_entry(path, None)? { + return Ok(Some(blocked)); + } + } + Ok(None) } fn scan_for_blocked_entry( - root: &Path, from: &Path, mut deferred: Option<&mut Vec>, -) -> Option { +) -> std::io::Result> { let mut entries = WalkDir::new(from).follow_links(false).into_iter(); while let Some(entry) = entries.next() { let entry = match entry { Ok(entry) => entry, - Err(error) => match error.path() { - Some(path) => return Some(blocked_entry(root, path, "an unreadable entry", "u+r")), - None => continue, - }, + Err(error) => { + let denied = error.io_error().is_some_and(is_denied); + return match error.path() { + Some(path) if denied => Ok(Some(blocked(path, "an unreadable entry", "u+r"))), + _ => Err(error + .into_io_error() + .unwrap_or_else(|| std::io::Error::other("filesystem loop"))), + }; + } }; let path = entry.path(); let file_type = entry.file_type(); if file_type.is_dir() { - match (accessible(path, libc::R_OK), accessible(path, libc::X_OK)) { + match (accessible(path, libc::R_OK)?, accessible(path, libc::X_OK)?) { (true, true) => {} (true, false) => { - return Some(blocked_entry( - root, + return Ok(Some(blocked( path, "a directory without search permission", "u+x", - )); + ))); } (false, true) => { - return Some(blocked_entry(root, path, "an unreadable directory", "u+r")); + return Ok(Some(blocked(path, "an unreadable directory", "u+r"))); } (false, false) => { - return Some(blocked_entry( - root, + return Ok(Some(blocked( path, "an unreadable directory without search permission", "u+rx", - )); + ))); } } if entry.depth() > 0 @@ -152,29 +194,36 @@ fn scan_for_blocked_entry( deferred.push(path.to_path_buf()); entries.skip_current_dir(); } - } else if file_type.is_file() && !accessible(path, libc::R_OK) { - return Some(blocked_entry(root, path, "an unreadable file", "u+r")); + } else if file_type.is_file() && !accessible(path, libc::R_OK)? { + return Ok(Some(blocked(path, "an unreadable file", "u+r"))); } } - None + Ok(None) } -fn accessible(path: &Path, mode: libc::c_int) -> bool { - let Ok(path) = c_path(path) else { - return true; - }; +fn is_denied(error: &std::io::Error) -> bool { + matches!(error.raw_os_error(), Some(libc::EACCES | libc::EPERM)) +} + +/// Returns whether the caller has `mode` access to `path`. Only `EACCES` and +/// `EPERM` mean access is denied; any other failure is returned as an error. +fn accessible(path: &Path, mode: libc::c_int) -> std::io::Result { + let path = c_path(path).map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, error.to_string()) + })?; // SAFETY: `path` is a null-terminated C string that lives for the call. - unsafe { libc::access(path.as_ptr(), mode) == 0 } + if unsafe { libc::access(path.as_ptr(), mode) } == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + if is_denied(&error) { + return Ok(false); + } + Err(error) } -fn blocked_entry( - root: &Path, - path: &Path, - problem: &'static str, - permission: &'static str, -) -> Error { - Error::BlockedEntry { - root: root.to_path_buf(), +fn blocked(path: &Path, problem: &'static str, permission: &'static str) -> Blocked { + Blocked { path: path.to_path_buf(), problem, permission, From 7c7ef62d69402db5578487db7ff95d85d8a9e189 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:39:17 -0700 Subject: [PATCH 3/4] Do not treat an append-only destination parent as refusing --- crates/core/src/strategy/apfs.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/core/src/strategy/apfs.rs b/crates/core/src/strategy/apfs.rs index 43dd7c5..a7051f8 100644 --- a/crates/core/src/strategy/apfs.rs +++ b/crates/core/src/strategy/apfs.rs @@ -108,7 +108,8 @@ fn refused_clone_error(from: &Path, to: &Path, error: std::io::Error) -> Error { } /// The kernel also refuses a clone with `EACCES` or `EPERM` when the -/// destination parent is not writable or is immutable or append-only. +/// destination parent is not writable or is immutable. An append-only parent +/// still accepts a new entry, so it does not refuse the clone. fn destination_refuses(to: &Path) -> bool { use std::os::macos::fs::MetadataExt; @@ -119,7 +120,7 @@ fn destination_refuses(to: &Path) -> bool { if !matches!(accessible(parent, libc::W_OK | libc::X_OK), Ok(true)) { return true; } - let flags = libc::UF_IMMUTABLE | libc::UF_APPEND | libc::SF_IMMUTABLE | libc::SF_APPEND; + let flags = libc::UF_IMMUTABLE | libc::SF_IMMUTABLE; fs::metadata(parent).map_or(true, |metadata| metadata.st_flags() & flags != 0) } From 5ed7304b6e55339839f1a7ff6ff96a77a760d985 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:04:12 -0700 Subject: [PATCH 4/4] Test the error for blocked full copies (#3) * Test the error for blocked full copies * Test quoting, destination refusals, inconclusive scans and append-only parents --- crates/core/src/strategy/apfs.rs | 291 +++++++++++++++++++++++++++++++ crates/core/src/tests.rs | 37 ++++ 2 files changed, 328 insertions(+) diff --git a/crates/core/src/strategy/apfs.rs b/crates/core/src/strategy/apfs.rs index a7051f8..e28df79 100644 --- a/crates/core/src/strategy/apfs.rs +++ b/crates/core/src/strategy/apfs.rs @@ -433,6 +433,7 @@ fn c_path(path: &Path) -> Result { #[cfg(test)] mod tests { use super::*; + use std::os::fd::AsRawFd; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use tempfile::TempDir; @@ -457,6 +458,296 @@ mod tests { assert!(!destination.exists()); } + /// Regression: the kernel refuses a whole-tree `clonefile` with `EACCES` + /// when any directory inside lacks the search bit, even one the caller + /// owns. Two empty `0644` directories in a checkout made every + /// `rift create --copy-all` from it fail with an error that named only the + /// root and blamed copy-on-write support. + #[test] + fn full_copy_names_a_directory_without_search_permission() { + if unsafe { libc::geteuid() } == 0 { + return; + } + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + let unsearchable = source.join("lib"); + fs::create_dir_all(source.join("node_modules/pkg")).unwrap(); + fs::write(source.join("node_modules/pkg/index.js"), "module").unwrap(); + fs::create_dir(&unsearchable).unwrap(); + fs::set_permissions(&unsearchable, fs::Permissions::from_mode(0o644)).unwrap(); + + let error = ApfsStrategy + .copy_directory(&source, &destination, CopyMode::All) + .unwrap_err(); + + assert!( + matches!(&error, Error::BlockedEntry { path, permission: "u+x", source, .. } + if path == &unsearchable && is_denied(source)), + "{error:?}" + ); + let message = error.to_string(); + assert!( + message.starts_with(&format!("clone failed for {}: ", source.display())), + "{message}" + ); + assert!( + message.contains(&format!("chmod u+x {}", unsearchable.display())), + "{message}" + ); + assert!(!message.contains("copy-on-write"), "{message}"); + assert!(!destination.exists()); + assert_eq!( + fs::metadata(&unsearchable).unwrap().permissions().mode() & 0o777, + 0o644 + ); + fs::set_permissions(&unsearchable, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + fs::read_to_string(source.join("node_modules/pkg/index.js")).unwrap(), + "module" + ); + } + + /// `node_modules` is searched last, but a blocked entry inside it is still + /// named. + #[test] + fn full_copy_names_a_blocked_entry_inside_node_modules() { + if unsafe { libc::geteuid() } == 0 { + return; + } + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + let unreadable = source.join("node_modules/pkg/index.js"); + fs::create_dir_all(source.join("node_modules/pkg")).unwrap(); + fs::write(&unreadable, "module").unwrap(); + fs::write(source.join("file.txt"), "hello").unwrap(); + fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o200)).unwrap(); + + let result = ApfsStrategy.copy_directory(&source, &destination, CopyMode::All); + + fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o644)).unwrap(); + let error = result.unwrap_err(); + assert!( + matches!(&error, Error::BlockedEntry { path, permission: "u+r", .. } if path == &unreadable), + "{error:?}" + ); + assert!(!destination.exists()); + } + + /// Only a filesystem that cannot clone means copy-on-write is unavailable. + #[test] + fn clone_errors_name_copy_on_write_only_when_it_is_unavailable() { + let from = Path::new("/source"); + let to = Path::new("/destination"); + for errno in [libc::ENOSPC, libc::EIO, libc::EACCES] { + let message = + clone_error(from, to, std::io::Error::from_raw_os_error(errno)).to_string(); + assert!( + message.starts_with("clone failed for /source: "), + "{message}" + ); + } + for errno in [libc::EXDEV, libc::ENOTSUP] { + assert!(matches!( + clone_error(from, to, std::io::Error::from_raw_os_error(errno)), + Error::CowUnavailable(_) + )); + } + } + + /// The suggested command is quoted for the shell, so a path with spaces + /// or metacharacters can be pasted as is. + #[test] + fn full_copy_quotes_the_suggested_command() { + if unsafe { libc::geteuid() } == 0 { + return; + } + let temp = TempDir::new().unwrap(); + let source = temp.path().join("it's a $source"); + let destination = temp.path().join("destination"); + let unsearchable = source.join("lib dir;touch pwned"); + fs::create_dir_all(&unsearchable).unwrap(); + fs::set_permissions(&unsearchable, fs::Permissions::from_mode(0o644)).unwrap(); + + let message = ApfsStrategy + .copy_directory(&source, &destination, CopyMode::All) + .unwrap_err() + .to_string(); + + let command = message + .split('`') + .nth(1) + .unwrap_or_else(|| panic!("{message}")); + assert!(command.starts_with("chmod u+x '"), "{message}"); + let status = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(command) + .current_dir(temp.path()) + .status() + .unwrap(); + assert!(status.success(), "{message}"); + assert_eq!( + fs::metadata(&unsearchable).unwrap().permissions().mode() & 0o777, + 0o744 + ); + assert!(!temp.path().join("pwned").exists()); + } + + /// The kernel refuses a clone into an immutable or unwritable destination + /// parent too. A blocked entry in the source must not hide that: the + /// kernel's error is returned unchanged and the source is not blamed. + #[test] + fn full_copy_does_not_blame_the_source_when_the_destination_refuses() { + if unsafe { libc::geteuid() } == 0 { + return; + } + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + let parent = temp.path().join("parent"); + let destination = parent.join("destination"); + let unsearchable = source.join("lib"); + fs::create_dir_all(&unsearchable).unwrap(); + fs::set_permissions(&unsearchable, fs::Permissions::from_mode(0o644)).unwrap(); + fs::create_dir(&parent).unwrap(); + let parent_path = c_path(&parent).unwrap(); + + // SAFETY: `parent_path` is a null-terminated C string that lives for + // each call. + assert_eq!( + unsafe { libc::chflags(parent_path.as_ptr(), libc::UF_IMMUTABLE as _) }, + 0 + ); + let immutable = ApfsStrategy.copy_directory(&source, &destination, CopyMode::All); + assert_eq!(unsafe { libc::chflags(parent_path.as_ptr(), 0) }, 0); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o555)).unwrap(); + let unwritable = ApfsStrategy.copy_directory(&source, &destination, CopyMode::All); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap(); + + for result in [immutable, unwritable] { + let error = result.unwrap_err(); + assert!( + matches!(&error, Error::IoAt { operation: "clone", path, source: cause } + if path == &source && is_denied(cause)), + "{error:?}" + ); + assert!(!error.to_string().contains("chmod"), "{error}"); + } + assert!(!destination.exists()); + } + + /// An append-only destination parent still accepts a new entry, so it + /// must not hide a blocked entry in the source. Once the entry is fixed, + /// the clone succeeds under the same parent. + #[test] + fn full_copy_blames_the_source_when_the_destination_parent_is_append_only() { + if unsafe { libc::geteuid() } == 0 { + return; + } + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + let parent = temp.path().join("parent"); + let destination = parent.join("destination"); + let unsearchable = source.join("lib"); + fs::create_dir_all(&unsearchable).unwrap(); + fs::write(source.join("file.txt"), "hello").unwrap(); + fs::set_permissions(&unsearchable, fs::Permissions::from_mode(0o644)).unwrap(); + fs::create_dir(&parent).unwrap(); + let parent_path = c_path(&parent).unwrap(); + + // SAFETY: `parent_path` is a null-terminated C string that lives for + // each call. + assert_eq!( + unsafe { libc::chflags(parent_path.as_ptr(), libc::UF_APPEND as _) }, + 0 + ); + let blocked = ApfsStrategy.copy_directory(&source, &destination, CopyMode::All); + fs::set_permissions(&unsearchable, fs::Permissions::from_mode(0o755)).unwrap(); + let fixed = ApfsStrategy.copy_directory(&source, &destination, CopyMode::All); + let parent_flags = + std::os::macos::fs::MetadataExt::st_flags(&fs::metadata(&parent).unwrap()); + // The flag is cleared before asserting so the temporary directory can + // be removed. + assert_eq!(unsafe { libc::chflags(parent_path.as_ptr(), 0) }, 0); + + let error = blocked.unwrap_err(); + assert!( + matches!(&error, Error::BlockedEntry { path, permission: "u+x", source: cause, .. } + if path == &unsearchable && is_denied(cause)), + "{error:?}" + ); + assert!( + error + .to_string() + .contains(&format!("chmod u+x {}", unsearchable.display())), + "{error}" + ); + fixed.unwrap(); + assert_ne!(parent_flags & libc::UF_APPEND, 0); + assert_eq!( + fs::read_to_string(destination.join("file.txt")).unwrap(), + "hello" + ); + } + + /// A scan that fails for any reason other than a denied entry, here + /// `EMFILE`, is inconclusive. The kernel's error is returned and no + /// healthy entry is blamed. The file limit is lowered in a child process + /// so other tests keep their descriptors. + #[test] + fn full_copy_keeps_the_clone_error_when_the_scan_is_inconclusive() { + const CHILD: &str = "RIFT_TEST_LOW_FILE_LIMIT"; + if std::env::var_os(CHILD).is_none() { + let test = format!( + "{}::full_copy_keeps_the_clone_error_when_the_scan_is_inconclusive", + module_path!().split_once("::").unwrap().1 + ); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([test.as_str(), "--exact", "--test-threads=1"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("1 passed"), + "{stdout}{}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + let deep = (0..16).fold(source.clone(), |path, level| path.join(level.to_string())); + fs::create_dir_all(&deep).unwrap(); + fs::write(deep.join("file.txt"), "hello").unwrap(); + let next_descriptor = fs::File::open("/dev/null").unwrap().as_raw_fd(); + let limit = libc::rlimit { + rlim_cur: next_descriptor as libc::rlim_t + 2, + rlim_max: libc::RLIM_INFINITY, + }; + // SAFETY: `limit` is a valid `rlimit` that lives for the call. + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limit) }, 0); + + let scan = find_blocked_entry(&source); + let error = refused_clone_error( + &source, + &destination, + std::io::Error::from_raw_os_error(libc::EACCES), + ); + + assert_eq!( + scan.err().and_then(|error| error.raw_os_error()), + Some(libc::EMFILE) + ); + assert!( + matches!(&error, Error::IoAt { operation: "clone", path, source: cause } + if path == &source && cause.raw_os_error() == Some(libc::EACCES)), + "{error:?}" + ); + } + #[test] fn integration_environment_is_required_by_ci() { if std::env::var_os("RIFT_REQUIRE_APFS_TESTS").is_some() { diff --git a/crates/core/src/tests.rs b/crates/core/src/tests.rs index ec46f85..fd8df44 100644 --- a/crates/core/src/tests.rs +++ b/crates/core/src/tests.rs @@ -1251,6 +1251,43 @@ fn unwritable_destination_parent_failure_leaves_no_child_or_registry_row() { assert!(manager.list(&source).unwrap().is_empty()); } +/// A full copy refused by the kernel leaves no child, no registry row, and an +/// unchanged source. +#[cfg(target_os = "macos")] +#[test] +fn blocked_full_copy_leaves_no_child_or_registry_row() { + if running_as_root() { + return; + } + let temp = TempDir::new().unwrap(); + let source = source(&temp); + let unsearchable = source.join("lib"); + let mut manager = Manager::open(temp.path().join("registry.sqlite")).unwrap(); + manager.init(&source).unwrap(); + fs::create_dir(&unsearchable).unwrap(); + fs::set_permissions(&unsearchable, fs::Permissions::from_mode(0o644)).unwrap(); + + let result = manager.create_with_options( + create_input(source.clone(), "full"), + create_options(CopyMode::All, HookMode::Skip), + ); + + let mode = fs::metadata(&unsearchable).unwrap().permissions().mode() & 0o777; + fs::set_permissions(&unsearchable, fs::Permissions::from_mode(0o755)).unwrap(); + let error = result.unwrap_err(); + assert!( + matches!(&error, Error::BlockedEntry { path, .. } if path == &unsearchable), + "{error:?}" + ); + assert_eq!(mode, 0o644); + assert!(!child_path(&source, "full").exists()); + assert!(manager.list(&source).unwrap().is_empty()); + assert_eq!( + fs::read_to_string(source.join("file.txt")).unwrap(), + "hello" + ); +} + #[test] fn unavailable_cow_does_not_create_a_child() { let temp = TempDir::new().unwrap();