Skip to content

fix(metadata): apply the set-group-ID mode chmod(2) would apply - #7655

Merged
oferchen merged 5 commits into
masterfrom
fix/chmod-setgid-fchmodat-semantics
Sep 4, 2026
Merged

fix(metadata): apply the set-group-ID mode chmod(2) would apply#7655
oferchen merged 5 commits into
masterfrom
fix/chmod-setgid-fchmodat-semantics

Conversation

@oferchen

@oferchen oferchen commented Sep 4, 2026

Copy link
Copy Markdown
Owner

oc-rsync -rp --chmod=a+s src/ dst/ aborted the whole transfer with exit 23 and
failed to preserve permissions '.../dst/': Operation not permitted for an
ordinary user on macOS. The real rsync 3.5.0 binary completes the same command
with exit 0 on the same host.

Root cause

Upstream reaches every mode change through do_chmod() -> chmod(2)
(syscall.c:761). oc anchors the same change on a walked parent dirfd for
symlink-race safety, so it issues fchmodat(2) instead. On macOS the two
syscalls do not agree about S_ISGID for an unprivileged caller.

Measured with a standalone C probe (euid 501, caller owns the target, same
inode and same mode word for both calls):

requested chmod(2) fchmodat(2)
0755 - ok
0755 | S_ISUID ok -> 04755 ok -> 04755
0755 | S_ISGID ok -> 00755 EPERM
0755 | both ok -> 04755 EPERM

The kernel silently masks a set-group-ID bit it will not grant when the
request arrives through chmod, but refuses the identical request through
fchmodat. Linux masks on both, which is exactly why the cell passes there and
only macOS fails.

The 0755 row is the control: fchmodat works normally, so the refusal
isolates to one bit rather than to the probe being broken.

Oracle

Same fixture, same argv, both binaries, this host:

exit dest root files
upstream 3.5.0 0 drwsr-xr-x -rwSr--r--
oc, before 23 drwxr-xr-x -rw-r--r--
oc, after 0 drwsr-xr-x -rwSr--r--

After the fix oc is byte-identical to upstream on all four entries.

The fix

secure_chmod_at routes through a helper that retries the refusal once with
S_ISGID cleared - exactly the mode the platform's own chmod would have
applied. This keeps the dirfd anchor (the confinement property) while restoring
upstream's acceptance semantics.

openat + fchmod was considered and rejected: opening the leaf would block on
a FIFO and fail on an unreadable file, hazards chmod does not have.

The retry cannot mask a genuine permission error - a caller that does not own
the target is refused for the setgid-free mode too, and that error propagates.
It is also naturally inert on Linux, where fchmodat does not refuse what
chmod masks, so no platform cfg is needed.

Testsuite

macOS upstream-3.5.0 nonroot leg, freshly built release binary:

before after
passed 234 235
failed 6 5

macos-setgid-ordinary-mode-regression moves fail -> pass; its expect row is
flipped in the same commit because the defect is fixed, not re-baselined.

⚠ chmod-setid is deliberately left failing

That cell asserts --chmod=a+s sets both setuid and setgid, and the result
here is mode 4644 - setuid only. The platform will not grant setgid to this
caller at all, and the measured oracle above shows the real rsync 3.5.0 binary
landing on the same setuid-only mode (-rwSr--r--) on this host. The assertion
is unsatisfiable in this environment rather than an oc defect, so its fail row
is unchanged.

Verification

  • cargo fmt --all -- --check clean.
  • cargo clippy -p fast_io --all-targets --no-deps -D warnings exits 0 with the
    two lints this toolchain already fails on in untouched files allowed
    (collapsible_if, derivable_impls); zero hits in the changed file, checked
    by name after confirming the crate actually compiled - an unlinted crate
    reports no hits either.
  • Full upstream 3.5.0 suite re-run after the post-fmt rebuild: 235/5/105,
    same failing set.

A daemon pull of `rsync://host/module/cd/`, where `cd` is a symlink out of
the module, enumerated the outside directory into the file list. Measured on
the upstream 3.5.0 cell `sender-flist-symlink-leak`: the marker file outside
the module appears in the listing, while the cell's own in-module positive
control also passes, so the listing machinery is live and the leak is real.

The confined enumeration was already routed. `walk.rs` calls
`fast_io::pinned_root::read_dir`, which resolves its anchor through
`confinement::pinned_root_relative` - the process-global session root. Nothing
installs that root for a daemon, so `pinned_root_relative` returned `None`,
which is documented to mean "resolve the path the ordinary way", and every
directory read silently degraded to an unconfined absolute-path read.

Meanwhile `GeneratorContext::confine_root()` was already correct for a daemon
(`is_daemon_connection => daemon_module_root`, mirroring upstream's
`am_daemon ? module_dir : confine_root`, syscall.c:136) and was consulted only
by the CONTENT open. That split is exactly the observed behaviour: the content
transfer refuses the escape while the enumeration follows it.

The scan now takes the root as a PARAMETER rather than reading ambient state.
`read_dir_under(root, path)` performs the same confined walk `read_dir` does
for the pinned case, and `GeneratorContext::scan_source_dir` is the single
owner of the anchoring decision for both scan sites, so they cannot disagree.

Installing the global per connection was rejected deliberately: it is a
`static RwLock` and oc serves each daemon connection on a worker thread of one
process, so two concurrent connections on different modules would overwrite
each other's boundary. Upstream's equivalent global is safe only because
upstream forks a child per connection. Passing the root in is what makes the
anchor correct under threads.

Not-anchorable is not an escape. `strip_prefix` is lexical, so a relative
operand - which a restricted shell produces routinely - or a differently
spelled absolute path falls back to the ordinary read, matching what
`pinned_root_relative` already decides for the ambient pin. Refusing there
instead was measured to break `rrsync-pull-arg-shapes` and
`rrsync-merge-file-confine` with `opendir "sub" failed: Cross-device link`,
directories that were never outside anything.

Measured on macOS against a freshly built release binary, whole suite:

  before   8 fail / 232 pass
  after    6 fail / 234 pass

`sender-flist-symlink-leak` and `daemon-scan-dir-escape` both move fail -> pass;
the remaining six are unchanged and all recorded `fail` in the manifest. Both
macOS manifest rows are flipped in this commit because the defect is FIXED, not
to silence a red - leaving them would report an XPASS. The two Linux manifests
already record both cells as `pass` and are untouched.

upstream: rsync-3.5.0/flist.c:2028-2059 `secure_opendir()` - the confined open
that produces `scan_dirfd` for a daemon's scan.
upstream: rsync-3.5.0/syscall.c:136 `confinement_root()` - `am_daemon ?
module_dir : confine_root`, the value the new parameter carries.
…a list

clippy::doc_lazy_continuation rejected the scan_source_dir doc: a wrapped
prose line began with `- was consulted only by ...`, which markdown reads
as a list bullet, making the three following lines lazy continuations of a
list item.

The hyphen-as-aside style is fine mid-line; the defect is purely where the
wrap put it. Reworded to parentheses so no line begins with a hyphen unless
it is a genuine list item - every other `/// - ` in this file is one.

⚠ This was invisible to a local workspace clippy run: crates/fast_io fails
clippy on this host under rust 1.94.0 (7 errors in copy_file_range /
sendfile / iocp_stub / confinement, none of them in this change), and a
crate that does not compile under clippy means every DEPENDENT crate is
never linted at all. "Zero hits in my files" from such a run is vacuous.
Verified here by linting transfer with only those inherited lints allowed.
Three sites claimed the daemon's operand resolvers CONFINE, on the strength
of collapsing `.` and `..`:

  "cannot enumerate outside the module root"
  "the result cannot escape the module root, so there is nothing left to refuse"
  "the result is confined to the module root by construction"

The collapse is a STRING operation. It contains exactly the shapes it folds
- a traversing spelling and a host-absolute one - and says nothing about
where the path RESOLVES, because a symlink is resolved by the kernel at
syscall time and no string operation can see it. Upstream does not rely on
the collapse alone either: it enters the module with change_dir(module_chdir,
CD_NORMAL) and scans through the confined secure_opendir().

That over-claim is why an out-of-module directory reached by a module-root
symlink was enumerated into the file list: a reader had been told this layer
already confined, so the downstream scan was left anchored on ambient state.
Each site now says what the collapse does prove, and names where resolved
containment is actually enforced - the confined source open and the anchored
directory scan.

Four citations in the same blocks were verified against the pinned 3.5.0
tree and retargeted; two were verified correct and left alone.

  util1.c:804    -> :881         glob_expand_module()
  clientserver.c:992 -> :1059    change_dir(module_chdir, CD_NORMAL)
  flist.c:2338-2349 -> :2610-2621  send_file_list() dir/fn split
  main.c:1203-1204 -> io.c:1497  the glob_expand_module call site

  util1.c:1183        unchanged  sanitize_path's `..` handling
  flist.c:2589-2594   unchanged  DOTDIR_NAME
Upstream reaches every mode change through do_chmod() -> chmod(2)
(syscall.c:761). oc anchors the same change on a walked parent dirfd for
symlink-race safety, so it issues fchmodat(2) instead - and on macOS the
two syscalls disagree about S_ISGID for an unprivileged caller.

Measured on macOS 26.5, euid 501, caller owns the target, same inode and
same mode word for both calls:

    requested          chmod(2)        fchmodat(2)
    0755               -               ok
    0755 | S_ISUID     ok -> 04755     ok -> 04755
    0755 | S_ISGID     ok -> 00755     EPERM
    0755 | both        ok -> 04755     EPERM

The kernel silently MASKS a set-group-ID bit it will not grant when the
request arrives through chmod, but REFUSES the identical request through
fchmodat. Linux masks on both, so this is not observable there.

Left unhandled that turned `--chmod=a+s` into a fatal EPERM (exit 23) for
an ordinary user where upstream completes with exit 0. Measured against
the real rsync 3.5.0 binary on the same fixture: upstream exits 0 and
leaves drwsr-xr-x / -rwSr--r--; oc exited 23 and applied nothing.

The refusal is now retried once with S_ISGID cleared - exactly the mode
the platform's own chmod would have applied - so the anchor is kept and
the resulting mode is byte-identical to upstream (verified: 4/4 entries
match after the fix). The retry cannot mask a genuine permission error: a
caller that does not own the target is refused for the setgid-free mode
too, and that error propagates.

macos-setgid-ordinary-mode-regression moves fail -> pass on the macOS
upstream-3.5.0 leg (234/6 -> 235/5); its expect row is flipped in the
same commit because the defect is fixed, not re-baselined.

chmod-setid still fails on macOS and its `fail` row is deliberately
UNCHANGED: that cell asserts both setuid and setgid are set, and the
platform will not grant setgid to this caller at all - the real rsync
3.5.0 binary lands on the same setuid-only mode (-rwSr--r--) on this
host, so the assertion is unsatisfiable here rather than an oc defect.
@github-actions github-actions Bot added the bug Something isn't working label Sep 4, 2026
`u32::from(libc::S_ISGID)` is a real widening on macOS, where `S_ISGID`
is `u16`, but a no-op on Linux, where it is already `u32` - so Linux
clippy rejects it as `useless_conversion`. An `as` cast fails the other
way round, tripping `unnecessary_cast` on Linux.

Drop the conversion entirely and name the POSIX-fixed literal, matching
the existing idiom in crates/cli/src/frontend/progress/format/list.rs.
A unit test pins the literal against `libc::S_ISGID`, widening both
sides to `u64` so the comparison itself stays lint-clean on either
platform.

Value-identical to the previous code, so the measured macOS testsuite
result (235 pass / 5 fail) is unchanged.
@oferchen
oferchen merged commit 237e347 into master Sep 4, 2026
76 checks passed
@oferchen
oferchen deleted the fix/chmod-setgid-fchmodat-semantics branch September 4, 2026 02:15
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