Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/cli/src/frontend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ where
Out: Write,
Err: Write,
{
// Resolve the process umask before any mode dispatch. The daemon installs a
// seccomp filter whose worker allowlist has no `umask(2)`, and a
// non-allowlisted syscall is answered with EPERM, so a first read taken
// later - inside a sandboxed worker - would cache -1 and collapse
// `dest_mode()`'s new-file result to mode 000.
// upstream: main.c:1797 `umask(orig_umask = umask(0));` runs in main()
// before any privilege drop or sandbox setup.
#[cfg(unix)]
metadata::init_orig_umask();

let mut args: Vec<OsString> = arguments.into_iter().map(Into::into).collect();
if args.is_empty() {
args.push(OsString::from(ProgramName::OcRsync.as_str()));
Expand Down
2 changes: 2 additions & 0 deletions crates/metadata/src/apply/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ mod platform_warn;
mod timestamps;

pub use ownership::group_is_settable;
#[cfg(unix)]
pub use permissions::init_orig_umask;

#[cfg(test)]
mod tests;
Expand Down
109 changes: 98 additions & 11 deletions crates/metadata/src/apply/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,35 @@ fn fchmod_libc(
.map_err(|errno| MetadataError::new(action, destination, io::Error::from(errno)))
}

/// Returns the process umask, cached for thread safety.
/// Process-wide `orig_umask`, captured once. See [`init_orig_umask`].
#[cfg(unix)]
static ORIG_UMASK: std::sync::OnceLock<u32> = std::sync::OnceLock::new();

/// Captures the process umask into the process-wide cache.
///
/// Must be called from the program entry point, BEFORE the daemon installs its
/// seccomp filter. `umask(2)` is not on the worker allowlist
/// (`daemon::seccomp::worker_seccomp_allowlist`), and a non-allowlisted syscall
/// is failed with `EPERM` rather than killing the process, so a *lazy* first
/// read inside a sandboxed worker gets `-1` back. Caching that sentinel makes
/// `dflt_perms` (`ACCESSPERMS & ~orig_umask`) zero, which collapses
/// `dest_mode()`'s new-file result to mode `000` on every write path.
///
/// Capturing eagerly at startup - the same place and for the same reason as
/// upstream - keeps the sandbox allowlist minimal and removes the ordering
/// hazard entirely: by the time any filter is installed the value is already
/// resolved.
///
/// Idempotent: the first call wins, later calls are no-ops.
///
/// upstream: `main.c` stores `orig_umask` once at startup. We query it
/// the first time a permission application needs the umask and cache the
/// result so the double set-and-restore syscall happens at most once per
/// process.
/// # Upstream Reference
///
/// - `main.c:1797` - `umask(orig_umask = umask(0));` runs in `main()` before
/// any privilege drop or sandbox setup.
#[cfg(unix)]
#[allow(unsafe_code)]
fn cached_umask() -> u32 {
use std::sync::OnceLock;
static UMASK: OnceLock<u32> = OnceLock::new();
*UMASK.get_or_init(|| {
pub fn init_orig_umask() {
ORIG_UMASK.get_or_init(|| {
// SAFETY: umask is a standard POSIX call. We set it to 0 to read
// the current value, then immediately restore it. This is a
// well-known pattern (used by upstream rsync main.c, GNU coreutils,
Expand All @@ -76,8 +93,46 @@ fn cached_umask() -> u32 {
// modifications.
let old = unsafe { libc::umask(0) };
unsafe { libc::umask(old) };
old as u32
})
sanitize_umask(old as u32)
});
}

/// Default umask assumed when the `umask(2)` query itself failed.
#[cfg(unix)]
const FALLBACK_UMASK: u32 = 0o022;

/// Rejects a `umask(2)` return value that cannot be a umask.
///
/// A umask is 9 significant bits, so anything outside `0o777` means the query
/// failed rather than answered - a seccomp filter that fails a non-allowlisted
/// syscall with `EPERM` hands back `-1`, which as a `u32` is `u32::MAX`. Caching
/// that would make `dflt_perms` (`ACCESSPERMS & ~orig_umask`) zero and chmod
/// every newly created destination to mode `000`, so fall back to the POSIX
/// default instead of propagating a sentinel into `dest_mode()`.
///
/// Defence in depth only: [`init_orig_umask`] runs before any sandbox is
/// installed, so a correctly wired binary never reaches the fallback.
#[cfg(unix)]
const fn sanitize_umask(raw: u32) -> u32 {
if raw & !0o777 == 0 {
raw
} else {
FALLBACK_UMASK
}
}

/// Returns the process umask captured by [`init_orig_umask`].
///
/// Falls back to capturing on first use for callers that never ran the entry
/// point (unit tests, library embedders). Production binaries prime this from
/// `main` so the value is resolved before any sandbox is installed.
#[cfg(unix)]
fn cached_umask() -> u32 {
if let Some(umask) = ORIG_UMASK.get() {
return *umask;
}
init_orig_umask();
*ORIG_UMASK.get().unwrap_or(&0o022)
}

/// Returns the default permission seed for a child created under `parent`.
Expand Down Expand Up @@ -1283,4 +1338,36 @@ mod tests {
let result = apply_permissions_with_chmod(&dest, &source_meta, &options, None);
assert!(result.is_err(), "expected Err with -p active, got Ok");
}

/// A real umask must survive verbatim: the sanitiser exists to reject a
/// failed query, not to second-guess the process's actual mask.
#[cfg(unix)]
#[test]
fn sanitize_umask_passes_every_real_umask_through() {
for raw in 0..=0o777u32 {
assert_eq!(
super::sanitize_umask(raw),
raw,
"{raw:o} is a valid 9-bit umask and must not be rewritten",
);
}
}

/// `umask(2)` failing returns `-1`, which as a `u32` is `u32::MAX`. Caching
/// it would make `dflt_perms` = `0o777 & !u32::MAX` = 0 and chmod every new
/// destination to mode 000, so it must be rejected. This is the value a
/// seccomp filter answering `EPERM` actually produced on the daemon
/// receiver.
#[cfg(unix)]
#[test]
fn sanitize_umask_rejects_a_failed_query() {
assert_eq!(super::sanitize_umask(u32::MAX), super::FALLBACK_UMASK);
assert_ne!(
0o777 & !super::sanitize_umask(u32::MAX),
0,
"a rejected sentinel must not yield dflt_perms == 0",
);
// Any value with bits above the 9 umask bits is equally impossible.
assert_eq!(super::sanitize_umask(0o1000), super::FALLBACK_UMASK);
}
}
2 changes: 1 addition & 1 deletion crates/metadata/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ pub use apply::group_is_settable;
#[cfg(unix)]
pub use apply::{
apply_dest_mode_pre_transfer, apply_file_metadata_with_fd,
apply_file_metadata_with_fd_if_changed, transfer_root_chmod_self_lock,
apply_file_metadata_with_fd_if_changed, init_orig_umask, transfer_root_chmod_self_lock,
};
pub use apply::{
apply_directory_metadata, apply_directory_metadata_with_options, apply_file_metadata,
Expand Down
24 changes: 9 additions & 15 deletions crates/transfer/tests/daemon_dest_mode_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,22 +193,16 @@ fn existing_destination_keeps_its_mode_on_every_write_path() {
/// cannot silently regress the other - the two share a single code path and a
/// single upstream rule.
///
/// IGNORED - this cell currently FAILS, and the failure is real. It is a
/// SEPARATE defect from the one this PR fixes, so it is registered here rather
/// than deleted: the assertion is correct and upstream-derived, and un-ignoring
/// it is the acceptance gate for that fix.
///
/// Measured: built `--all-features` across the WORKSPACE (what CI does), a new
/// destination lands mode 000 on every write path, because `entry.permissions()`
/// reaches the commit as 0 - a source chmod'd 0777 still lands 000, so the
/// formula is fine and the entry mode is not. A default-feature build of the
/// same source gives the correct 0600, and upstream 3.4.4 gives 0600 in both.
/// Ruled out: incremental-flist (`--no-inc-recursive` still yields 000),
/// `default_perms_for_dir` (faithful to acls.c:1084-1139), and `cached_umask`.
/// The remaining work is to find which workspace feature drops the mode from the
/// receiver's flist entry.
/// This cell regressed under a workspace `--all-features` build, which is the
/// only configuration that enables `daemon-seccomp`. `umask(2)` is absent from
/// the worker allowlist, and a non-allowlisted syscall is answered with EPERM,
/// so the receiver's first (lazy) umask read returned -1. Cached as `u32::MAX`,
/// that made `dflt_perms` = `0o777 & !u32::MAX` = 0, collapsing this branch's
/// `flist_mode & (~CHMOD_BITS | dflt_perms)` to mode 000 on every write path.
/// The existing-destination cell was unaffected because its branch never reads
/// `dflt_perms`. Fixed by capturing the umask at startup as upstream does
/// (`main.c:1797`), before any sandbox is installed.
#[test]
#[ignore = "separate unfixed defect: entry.permissions() is 0 under a workspace --all-features build; see the doc comment"]
fn new_destination_takes_the_masked_source_mode_on_every_write_path() {
let expected = masked_source_mode();
for extra in [&[][..], &["--inplace"][..], &["--append-verify"][..]] {
Expand Down
Loading