Skip to content

fix(metadata): capture orig_umask before the daemon sandbox is installed - #7259

Merged
oferchen merged 1 commit into
masterfrom
fix/orig-umask-before-sandbox
Aug 11, 2026
Merged

fix(metadata): capture orig_umask before the daemon sandbox is installed#7259
oferchen merged 1 commit into
masterfrom
fix/orig-umask-before-sandbox

Conversation

@oferchen

@oferchen oferchen commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Symptom

A brand-new destination landed mode 000 on every write path (plain temp+rename included) when a daemon received it — but only in a build with daemon-seccomp enabled. An existing destination was unaffected.

Root cause

Every link measured on Linux:

# Link Evidence
1 daemon-seccomp is not a default feature (crates/daemon/Cargo.toml, default = []) only --all-features enables it — why this reproduced under the workspace test build and never under cargo build --bin
2 worker_seccomp_allowlist() has no SYS_umask grep -c SYS_umask = 0
3 Non-allowlisted syscalls are failed, not fatal: SeccompAction::Errno(libc::EPERM) so libc::umask(0) returns -1
4 cached_umask() read lazily inside the sandboxed worker and cached -1 as u32 observed cached_umask=37777777777 = u32::MAX
5 dflt_perms = 0o777 & !u32::MAX 0
6 dest_mode() new-file branch flist_mode & (~CHMOD_BITS | dflt_perms) 0o600 & (!0o7777 | 0) = 0 → chmod 000

The existing-destination branch is (flist_mode & ~CHMOD_BITS) | (stat_mode & CHMOD_BITS) and never reads dflt_perms, which is exactly why only new files broke.

The divergence is when the umask is read

Both of oc's dest_mode() formulas were already faithful — only the input was poisoned. Upstream resolves the umask once in main():

umask(orig_umask = umask(0));      /* main.c:1797 */

before any privilege drop or sandbox setup. oc resolved it on first use, which in the daemon is after the seccomp filter is installed.

Fix

Mirror upstream: prime the value at CLI entry, before any mode dispatch, so the sandbox can never observe an unresolved cache. This keeps the seccomp allowlist minimal instead of widening it, and removes the ordering hazard that a future sandbox tightening would otherwise re-trigger.

sanitize_umask() additionally rejects a value that cannot be a umask (more than 9 significant bits), so a failed query can never again be cached as a sentinel that silently zeroes dflt_perms. That is defence in depth only — see the load-bearing check below.

Verification

  • Red check — reverting only the production change reproduces left: 0, right: 384.
  • Load-bearing check — the two mechanisms are indistinguishable under umask 022, so they were separated: with umask 077 and an 0644 source the destination lands 0600, i.e. the real umask is honoured. The sanitiser's 0o022 fallback would have produced 0644. So the eager capture, not the fallback, is what fixes the daemon.
  • cargo nextest run --workspace --all-features32385 passed, 0 failed.
  • cargo fmt --all -- --check clean; cargo clippy --locked --workspace --all-targets --all-features --no-deps -- -D warnings clean.
  • sanitize_umask gets its own deterministic unit tests: every one of the 512 real umasks passes through unchanged, and u32::MAX is rejected with an explicit assertion that the result cannot yield dflt_perms == 0.

Un-ignores new_destination_takes_the_masked_source_mode_on_every_write_path, which #7258 landed #[ignore]d as the acceptance gate for precisely this fix.

Class

A lazily-initialised process global whose initialising syscall a sandbox can silently fail, with the failure sentinel then cached forever. Other OnceLock + syscall pairs are worth an audit for the same shape — noted for follow-up, not swept here.

Side finding — RETRACTED

An earlier draft of this body claimed --debug=all emits nothing for the permission path and called it a coverage gap. That was wrong. Upstream emits no debug line for the chmod/dest_mode decision either — its only relevant emits are rsync.c:537-545 (set uid of / set gid of, DEBUG_GTE(OWN,1)) and acls.c:1133 (DEBUG_GTE(ACL,1)), and oc already mirrors both (crates/protocol/src/idlist/trace.rs:114,124 and trace_default_perms_for_dir). The zero output in my repro was correct behaviour: no ownership change and no default ACL, so neither emit fires. No gap, nothing to close.

A brand-new destination landed mode 000 on every write path when the
daemon received it, but only in a build with `daemon-seccomp` enabled.

Chain, measured end to end:

  1. `daemon-seccomp` is not a default feature, so only `--all-features`
     turns it on - which is why this reproduced under the workspace test
     build and never under a plain `cargo build --bin`.
  2. `worker_seccomp_allowlist()` does not list `SYS_umask`.
  3. A non-allowlisted syscall is failed with `SeccompAction::Errno(EPERM)`
     rather than killing the worker, so `libc::umask(0)` returns -1.
  4. `cached_umask()` took its first reading lazily, inside the sandboxed
     worker, and cached `-1 as u32` = `u32::MAX` for the process lifetime.
  5. `dflt_perms` = `0o777 & !u32::MAX` = 0.
  6. `dest_mode()`'s new-file branch, `flist_mode & (~CHMOD_BITS |
     dflt_perms)`, therefore collapsed to 0 and chmod'd the file to 000.

An existing destination was unaffected: that branch is `(flist_mode &
~CHMOD_BITS) | (stat_mode & CHMOD_BITS)` and never reads `dflt_perms`.

Both of oc's `dest_mode()` formulas were already faithful. The divergence
was *when* the umask is read: upstream resolves it once in `main()`
(main.c:1797 `umask(orig_umask = umask(0));`) before any privilege drop or
sandbox setup, while oc resolved it on first use. Mirror upstream by
priming the value at CLI entry, before any mode dispatch, so the sandbox
can never observe an unresolved cache. This keeps the seccomp allowlist
minimal rather than widening it, and removes the ordering hazard that any
future sandbox tightening would otherwise re-trigger.

`sanitize_umask()` additionally rejects a value that cannot be a umask
(more than 9 significant bits), so a failed query can never again be
cached as a sentinel that silently zeroes `dflt_perms`. That is defence in
depth only - the eager capture is what makes the daemon correct, verified
by measurement below.

Verified on Linux with `--all-features`:

  - `daemon_dest_mode_parity` both cells pass; reverting only the
    production change reproduces `left: 0, right: 384`.
  - Load-bearing check: under `umask 077` with an 0644 source the
    destination lands 0600, i.e. the real umask is honoured. The
    sanitiser's 0o022 fallback would have produced 0644, so the eager
    capture - not the fallback - is what fixes the daemon.
  - `cargo nextest run --workspace --all-features`: 32385 passed, 0 failed.
  - fmt clean; `clippy --locked --workspace --all-targets --all-features
    --no-deps -- -D warnings` clean.

Un-ignores `new_destination_takes_the_masked_source_mode_on_every_write_path`,
which was landed ignored as the acceptance gate for exactly this fix.
@github-actions github-actions Bot added the bug Something isn't working label Aug 11, 2026
@oferchen
oferchen merged commit 0ed2894 into master Aug 11, 2026
67 checks passed
@oferchen
oferchen deleted the fix/orig-umask-before-sandbox branch August 11, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant