fix(metadata): capture orig_umask before the daemon sandbox is installed - #7259
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-seccompenabled. An existing destination was unaffected.Root cause
Every link measured on Linux:
daemon-seccompis not a default feature (crates/daemon/Cargo.toml,default = [])--all-featuresenables it — why this reproduced under the workspace test build and never undercargo build --binworker_seccomp_allowlist()has noSYS_umaskgrep -c SYS_umask= 0SeccompAction::Errno(libc::EPERM)libc::umask(0)returns-1cached_umask()read lazily inside the sandboxed worker and cached-1 as u32cached_umask=37777777777=u32::MAXdflt_perms = 0o777 & !u32::MAXdest_mode()new-file branchflist_mode & (~CHMOD_BITS | dflt_perms)0o600 & (!0o7777 | 0)= 0 → chmod 000The existing-destination branch is
(flist_mode & ~CHMOD_BITS) | (stat_mode & CHMOD_BITS)and never readsdflt_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 inmain():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 zeroesdflt_perms. That is defence in depth only — see the load-bearing check below.Verification
left: 0, right: 384.umask 077and an 0644 source the destination lands 0600, i.e. the real umask is honoured. The sanitiser's0o022fallback 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.cargo fmt --all -- --checkclean;cargo clippy --locked --workspace --all-targets --all-features --no-deps -- -D warningsclean.sanitize_umaskgets its own deterministic unit tests: every one of the 512 real umasks passes through unchanged, andu32::MAXis rejected with an explicit assertion that the result cannot yielddflt_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=allemits nothing for the permission path and called it a coverage gap. That was wrong. Upstream emits no debug line for the chmod/dest_modedecision either — its only relevant emits arersync.c:537-545(set uid of/set gid of,DEBUG_GTE(OWN,1)) andacls.c:1133(DEBUG_GTE(ACL,1)), and oc already mirrors both (crates/protocol/src/idlist/trace.rs:114,124andtrace_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.