Skip to content

fix(receiver): clear a symlink standing at the --partial-dir name - #7657

Closed
oferchen wants to merge 2 commits into
masterfrom
fix/daemon-partial-dir-confinement
Closed

fix(receiver): clear a symlink standing at the --partial-dir name#7657
oferchen wants to merge 2 commits into
masterfrom
fix/daemon-partial-dir-confinement

Conversation

@oferchen

@oferchen oferchen commented Sep 4, 2026

Copy link
Copy Markdown
Owner

A peer-supplied --partial-dir naming a symlink out of the served tree was
accepted as "the directory is already there", after which the --delay-updates
staging rename followed the link and wrote outside the daemon module. The
delayed-updates sweep then renamed the file back out, leaving the victim's
directory empty.

Measured

Daemon push, -a --delay-updates, mod/blink a symlink to a directory outside
the module, with real rsync 3.5.0 as the control on the same host:

--partial-dir victim upstream 3.5.0 oc before oc after
/blink abs present exit 2, refusing unconfined partial basis for f0, dest OLD, victim INTACT exit 23, dest NEW, victim DESTROYED exit 0, dest NEW, victim INTACT
/blink abs absent exit 0, Unable to create partial-dir ... discarding completed file., dest OLD exit 23, wrote through the link exit 0, nothing outside
blink rel absent exit 0, link cleared + replaced, dest NEW exit 11, dest OLD, link left exit 0, matches upstream

The third row is a second, non-security divergence fixed by the same change:
with a relative --partial-dir upstream cleared the obstruction and completed
where oc failed the file.

Root cause

clear_partial_dir_obstruction had an explicit symlink pass-through arm,
justified by "the confined rename makes the refusal". The run above refutes
that premise - the staging rename follows the link.

Leaving the link could not have worked in any case: mkdirat reports EEXIST
for a symlink, and both fast_io::operator_mkdir and create_dir_all_sandboxed
map EEXIST to success. The obstruction was never merely tolerated; it was read
as a usable directory at every caller on this path. That also rules out the
tempting DRY fix of routing the staging create onto the shared
engine::create_partial_dir - it swallows EEXIST too, so the routing would
have been inert.

The fix

util1.c:1516-1527 handle_partial_dir(.., PDIR_CREATE) is the rule: under
operator_path_resolve, do_lstat_at finds a non-directory at the name -
!S_ISDIR covers a symlink and a regular file alike - do_unlink_at clears it,
and a failure to clear returns 0, at which point receiver.c:1302-1306 discards
the received temp rather than staging around the obstruction.

Clearing is what makes it safe: the ownership walk resolves the parent chain and
hands unlinkat a single leaf component, so the link is removed as the link it
is and never followed. fast_io::operator_unlink is added as the sibling of
operator_mkdir that this needs, mirroring upstream's pairing of do_unlink_at
with do_mkdir_at inside one operator_path_resolve region.

Verification

Mutation-proven in both directions: restoring only the symlink arm reproduces
the original escape end to end (victim destroyed, exit 23) and reddens
clearing_the_partial_dir_removes_a_symlink_without_touching_its_target. That
pin asserts both that the link is gone and that its target survives - "the link
is gone" alone would also hold for an implementation that followed the link and
deleted the target, which is the opposite of this fix.
clearing_the_partial_dir_removes_a_regular_file_and_reuses_a_directory is the
non-vacuity companion and stays green throughout.

On the pinned 1.88.0 toolchain: cargo fmt --all -- --check clean,
cargo clippy --workspace --all-targets --all-features --no-deps -- -D warnings
clean, engine 4719/4719. Full macOS upstream-3.5.0 leg run locally: 236 passed /
4 failed / 105 skipped, matching the committed manifest exactly - the four fail
rows are the four already classified there, and no passing cell regressed.

Deliberately not changed

The upstream-testsuite cell operator-path-partial-dir-daemon still fails, and
its manifest row is not re-baselined. Its security oracle now passes. What
remains is the cell's first oracle, which wants a non-zero exit - and upstream's
non-zero exit comes from somewhere else entirely: options.c:2402-2430's
if (sanitize_paths) block covers argv, tmpdir and backup_dir and not
partial_dir, so on a daemon /blink stays absolute, names the filesystem root,
and cannot be created.

oc re-roots it under the served module instead (the hardening from the earlier
daemon --partial-dir work). Matching upstream there means letting a peer name
an absolute staging path outside the served module - a question about oc's
confinement policy, not something to flip silently inside a security fix. The
second commit writes that reasoning into the manifest header, because the row's
previous one-line classification ("real oc defect") invites exactly the wrong
fix.

Residual, filed not fixed

engine::create_partial_dir's own Ok(_) => {} arm still falls through to the
EEXIST-swallowing operator_mkdir - the same defect class on the
finalize_partial / rename_to_partial_dir path. It is unmeasured as reachable,
so it is recorded rather than speculatively changed here.

A peer-supplied `--partial-dir` naming a symlink out of the served tree was
accepted as "the directory is already there", after which the staging rename
followed the link and wrote outside the daemon module.

MEASURED against a daemon push of `-a --delay-updates --partial-dir=/blink`
where `mod/blink` was a symlink to a directory outside the module, with real
rsync 3.5.0 as the control on the same host:

| | exit | dest mod/f0 | outside victim |
|---|---|---|---|
| upstream 3.5.0 | 2, `refusing unconfined partial basis for f0` | OLD | intact |
| oc before | 23 | NEW | DESTROYED |
| oc after | 0 | NEW | intact |

The escaping site is the `--delay-updates` staging create, not the basis open
and not `rename_to_partial_dir`. `clear_partial_dir_obstruction` deliberately
left a symlink standing, on the stated grounds that "the confined rename makes
the refusal". That premise is refuted by the run above: the staging rename
follows the link into the outside directory, and the delayed-updates sweep then
renames the file back out, which is why the victim's directory ends up empty.

Leaving the link could not work in any case, because `mkdirat` reports `EEXIST`
for a symlink and every caller on this path maps `EEXIST` to success -
`fast_io::operator_mkdir` and `create_dir_all_sandboxed` both do. So the
obstruction was never merely tolerated; it was read as a usable directory.

upstream util1.c:1516-1527 `handle_partial_dir(..., PDIR_CREATE)` is the rule:
under `operator_path_resolve`, `do_lstat_at` finds a non-directory at the name -
`!S_ISDIR` covers a symlink and a regular file alike - `do_unlink_at` clears it,
and a failure to clear returns 0, at which point receiver.c:1302-1306 discards
the received temp rather than staging around the obstruction. Clearing is what
makes it safe: the walk resolves the parent chain and hands `unlinkat` a single
leaf component, so the link is removed as the link it is and never followed.

`fast_io::operator_unlink` is added as the sibling of `operator_mkdir` that this
needs - the same ownership walk, the same shape, mirroring upstream's pairing of
`do_unlink_at` with `do_mkdir_at` inside one `operator_path_resolve` region.

Also fixes a second, non-security divergence measured on the way: with a
RELATIVE `--partial-dir=blink` upstream clears the obstruction and completes
(exit 0, destination updated) while oc exited 11 with the destination untouched.
oc now matches upstream on that shape too.

Mutation-proven: restoring only the symlink arm reproduces the original escape
end to end (victim destroyed, exit 23) and reddens
`clearing_the_partial_dir_removes_a_symlink_without_touching_its_target` with
"the symlink standing at the partial-dir name must be removed". The pin asserts
BOTH that the link is gone AND that its target survives - "the link is gone"
alone would also hold for an implementation that followed the link and deleted
the target, which is the opposite of this fix.

DELIBERATELY NOT CHANGED, and the reason the upstream-testsuite cell
`operator-path-partial-dir-daemon` still fails: upstream never sanitizes
`partial_dir`. options.c:2402-2430's `if (sanitize_paths)` block covers `argv`,
`tmpdir` and `backup_dir` and NOT `partial_dir`, so on a daemon `/blink` stays
absolute, names the filesystem root, and cannot be created - which is where
upstream's non-zero exit comes from. oc re-roots it under the module instead
(the hardening added for the daemon partial-dir), so oc completes the transfer
where upstream refuses. That is a policy question about whether a peer may name
an absolute staging path outside the served module, not something to flip
silently in a security fix; the cell's row is not re-baselined.

Verified on the pinned 1.88.0 toolchain: `cargo fmt --all -- --check` clean,
`cargo clippy --workspace --all-targets --all-features --no-deps -- -D warnings`
clean, engine 4719/4719.
…patchable bug

The row's one-line classification read "real oc defect", which invites the
wrong fix. Its security oracle now passes; the residual is the first oracle's
non-zero exit, and upstream's non-zero exit comes from options.c:2402-2430
never sanitizing partial_dir - an absolute daemon --partial-dir stays absolute
and cannot be created. oc re-roots it under the served module, so greening the
row means letting a peer name a staging path outside the module.
@github-actions github-actions Bot added the bug Something isn't working label Sep 4, 2026
@oferchen
oferchen marked this pull request as draft September 4, 2026 04:34
@oferchen

oferchen commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Converting to draft: this change regresses four Linux legs and greens nothing. My own design was wrong, and the CI measured it.

What CI reported

All four upstream-testsuite legs (pipe + TCP, nonroot + root) fail on one row:

operator-path-partial-dir-daemon: expected pass, got fail
overall result is 1

with the cell's own message:

the daemon did not reject the forced --partial-dir staging path:
rsync exited 0 and .../mod/f0 is now 'NEW\n'.
If the operand were ignored, the upload would replace the destination with 'NEW\n'.

Root cause — the design, not a detail

operator-path-partial-dir-daemon_test.py makes three assertions. At protocol
30+ it requires the daemon to REFUSE: nonzero exit AND the destination
untouched. The unconditional security oracle — the outside-module victim must
survive — is separate.

This branch's commit is fix(receiver): clear a symlink standing at the --partial-dir name. Clearing the obstacle and proceeding is exactly the
"operand was ignored" outcome the cell names and rejects. The victim assertion
still passes, so the escape is closed; but the transfer now completes and
replaces the destination, where it must have aborted.

Why this greens nothing

Per-platform baselines on master:

leg row
Linux pipe nonroot pass
Linux pipe root pass
macOS nonroot fail

Linux already refuses correctly — that is what the pass rows encode. The
macOS fail is a genuine oc defect, and this branch left that row fail
(deliberately, no re-baseline). So the change converts a correct refusal into a
silent ignore on the platform that was right, and does not fix the platform
that was wrong.

What the fix has to be instead

Refuse, don't clear. A peer-supplied --partial-dir that resolves outside the
served module must abort the transfer with a nonzero exit and leave the
destination alone — which is both what upstream does and what the Linux
baseline already encoded. The macOS divergence needs its own root-cause pass;
it is not the same defect, and "clear the obstacle" is not the remedy for
either.

Method note

The macOS-only verification I ran (236/4, "zero regression") was measured on
the one platform whose baseline for this cell was already fail, so it could
not see the regression. A cell whose expected result differs per platform has
to be measured on the platform whose row would change.

@oferchen

oferchen commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Correcting two claims in my earlier comment, both refuted by measurement. The conclusion that this branch must not land is unchanged; the reasons behind it were partly wrong.

The fixture, run head-to-head on both platforms

Same commit on both hosts (76e525dea / b12994fe3, the same tree), same fixture as
operator-path-partial-dir-daemon: module mod with blink -> <outside>/secret planted inside it,
then rsync -a --delay-updates --partial-dir=/blink src/ <url>mod/.

Linux macOS
exit 11 23
mod/f0 (destination) OLD-DESTINATION-FILE-CONTENT (untouched) NEW (replaced)
secret/f0 (outside the module) intact file no longer exists

Linux refuses. macOS follows the peer's escaping --partial-dir and destroys a file outside the
served module.

Correction 1: macOS is the same defect, not a different one

My earlier comment said "the macOS divergence needs its own root-cause pass; it is not the same
defect". That is wrong. macOS produces the identical operand-ignored outcome the cell rejects, and
the cell's unconditional security assertion - the outside victim must survive - fails on macOS
today
. The manifest row fail understates it: this is not a cosmetic divergence, it is a live
module escape with observed data destruction.

Correction 2: Landlock is not what makes Linux refuse

The obvious hypothesis was that Linux refuses via Landlock and macOS has no equivalent - the daemon
log says exactly that:

Linux: module 'mod': landlock fully enforced over 1 root(s)
macOS: module 'mod': landlock unavailable on this kernel; SEC-1 *at* helpers remain the sole defense

Refuted by A/B. With OC_RSYNC_NO_LANDLOCK=1 the Linux leg still refuses, with the same errno:

module 'mod': landlock=skipped reason=OC_RSYNC_NO_LANDLOCK set by operator (this layer is NOT installed)
...
error=Too many levels of symbolic links (os error 40)
RC=11, DEST unchanged, VICTIM intact

So the refusal is userspace - an ELOOP out of the fast_io ownership walk - and macOS does not
raise it. This also refutes the standing hypothesis on the sibling investigation that Landlock is
the masker for this class of platform split.

What is NOT yet established

I have localised the Linux refusal to an ELOOP from the ownership walk, but I have not measured
which of the two raise sites produces it (the untrusted-owner test, or the confinement predicate),
nor why macOS reaches neither. The confinement and walk code is cfg(unix), not cfg(target_os),
so it compiles identically on both - which means the mechanism is not visible from the source split
and needs a syscall-level trace to attribute. I am deliberately not naming a cause I have not
measured.

Why this branch still must not land

Unchanged from before, and now better supported: clearing the symlink and proceeding is the
"operand ignored" outcome. It would convert Linux's correct refusal into a silent ignore, and on
macOS it protects the victim only by removing the obstacle rather than by refusing the operand. The
fix is refusal on both platforms - nonzero exit, destination untouched - which is what upstream does
and what the Linux baseline already encodes.

@oferchen

oferchen commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Narrowing follow-up. The platform split is not in the symlink policy — it is entirely in the handling of an absolute --partial-dir. Same tree on both hosts, same fixture, only the operand spelling varies:

--partial-dir Linux macOS
relative blink exit 11, dest untouched, victim intact exit 11, dest untouched, victim intact
absolute /blink exit 11, dest untouched, victim intact exit 23, dest replaced, victim destroyed

Three of the four cells agree. macOS refuses the relative escaping symlink exactly as Linux does, which rules out the ownership walk's symlink-trust test and the cfg(unix)-vs-cfg(target_os) question I raised in the previous comment: the walk's per-component symlink policy is demonstrably working on macOS.

So the defect is on the route an absolute operand takes before or instead of that walk. Whoever picks this up should start there rather than in owner_walk's symlink arms - and note that open_start_dir(absolute = true) (opening / as the walk's start, with traversal_dir_flags() - the one genuine cfg(target_os) split, O_PATH on Linux vs O_RDONLY elsewhere) is reached only on the absolute path, which makes it the first thing to check rather than the last.

The harness is pdrepro.py (in the session scratchpad): it runs the cell's own fixture but prints the client exit code, stderr, the destination and the victim, all of which the cell itself sends to DEVNULL. Set scratchdir / srcdir / TOOLDIR / RSYNC, and PD= to vary the operand.

@oferchen

oferchen commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Root-caused. It is not a macOS defect, and I have to correct my own previous comment again: Landlock is the masker after all — my refutation two comments up was measured on a fixture shape that cannot show it.

The defect

--partial-dir confinement fails when the served module lies under a symlinked ancestor. The staging path is resolved to the canonical spelling while the module root is carried in the unresolved spelling, and the guard does not fire on the mismatch.

macOS hits it in the testsuite only because the harness scratch dir is under /tmp, which is a symlink to /private/tmp. Linux /tmp is a real directory, so the two spellings coincide there and the guard fires.

The measurements

Same tree on both hosts. Fixture is the cell's own: module mod with blink -> <outside>/secret planted inside it, rsync -a --delay-updates --partial-dir=… src/ <url>mod/.

host module ancestor operand Landlock exit mod/f0 outside victim
macOS /tmp (symlink) /blink n/a 23 replaced destroyed
macOS /tmp (symlink) blink n/a 11 untouched intact
macOS /private/tmp (real) /blink n/a 11 untouched intact
Linux /tmp (real) /blink on 11 untouched intact
Linux /tmp (real) /blink off 11 untouched intact
Linux symlinked /blink on 23 untouched intact
Linux symlinked /blink off 23 replaced destroyed

The last row is the one that matters: the userspace confinement fails on Linux too, and only Landlock stops the escape there. Take Landlock away — a kernel too old for it, OC_RSYNC_NO_LANDLOCK, or any non-Linux platform — and the same peer-supplied --partial-dir destroys a file outside the served module.

Correction to my previous comment

I wrote that the OC_RSYNC_NO_LANDLOCK A/B "refutes" Landlock as the masker. That A/B ran with the module under a real /tmp on Linux, where the userspace walk refuses on its own — so it was structurally incapable of showing Landlock doing anything. Re-run on the symlinked-ancestor shape, Landlock is exactly what was holding the line. The lesson generalises: an A/B on the wrong fixture shape produces a confident refutation of a true hypothesis.

Where it is not

Two candidates measured and excluded, so nobody re-treads them:

  • Not the ownership walk's symlink policy. macOS refuses the relative escaping symlink exactly as Linux does.
  • Not create_partial_dir. Instrumented both create_partial_dir and partial_dir_fname and ran the fixture: partial_dir_fname fires twice, create_partial_dir never fires at all on this path. Task 907's confinement sits in a function this staging path does not call. The two operand spellings produce the same final path except for the /tmp vs /private/tmp prefix — which is the whole defect.

What the fix has to do

Carry one spelling of the module root and the staging path, and compare those. Whether that is canonicalising the root to match, or refusing to canonicalise the operand, is the design call — but the guard cannot compare two different spellings of the same directory and be sound. And it must not rely on Landlock: the table above shows Landlock is currently load-bearing for a userspace guard that is supposed to stand on its own.

@oferchen

oferchen commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #7659, which takes the opposite shape: refuse the escaping operand rather than clear the obstacle and proceed.

The root cause is now named and measured. Both --delay-updates renames — the staging rename into <--partial-dir>/<basename> and the delayed sweep back onto the destination — walked the ownership walk in its Ancillary spelling, and AbsPathTracker::start returns Disabled for anything that is not PathKind::Confined. The session confinement root was therefore never consulted on this path at all, which is why the guard "did not fire": it was never asked.

Two corrections to my own earlier comments on this PR:

  • The staging path is not resolved-vs-unresolved spelling mismatch. That framing was wrong. The walk simply ran unconfined.
  • create_partial_dir and partial_dir_rename are both excluded by instrumentation — neither is on this path. The sinks are disk_commit/process/commit.rs (staging) and receiver/transfer.rs sweep_rename.

#7659 routes both onto operator_rename_confined and measures each half in isolation: sweep-only still destroys the victim, staging-only still leaves the destination exposed, both together give exit 11 with destination untouched and victim intact — matching the Linux baseline. macOS leg 235/5 -> 237/3.

Closing this branch unmerged.

@oferchen oferchen closed this Sep 4, 2026
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