fix(options): --append must imply --inplace - #7243
Merged
Merged
Conversation
upstream options.c:2410 sets `inplace = 1` for any `append_mode`, and from that point nothing reads `append_mode` again to decide where the receiver writes or what survives a failure. Everything reads the one flag: the write target (receiver.c:968), the closing ftruncate (receiver.c:496), the pre-write backup copy (generator.c:1862,1898), the basis switch to that backup (receiver.c:872), the retained-vs-discarded branch (receiver.c:1029), the keptstr wording (receiver.c:1074), the sender's updating_basis_file (sender.c:337) and the protocol < 29 basis-dir refusal (compat.c:688). oc never set it. `ClientConfigBuilder::validate` reconstructed the rule locally as `inplace || append`, so the conflict table fired correctly while `inplace` itself stayed false for every other consumer. That silently broke `--append --backup` on the local-copy path. With `inplace` false the generator takes the non-inplace backup path, which hard-links the destination into the backup area (rsync.c:740) before the transfer; the append then grows that shared inode, so the "backup" ends up byte-identical to the new file and the pre-image is destroyed. Measured against rsync 3.4.4: backup content was AAAABBBBCCCCDDDD where upstream writes AAAABBBB, and the backup shared the destination's inode. Same with --backup-dir. An explicit --inplace --backup was always correct, which is what isolates the defect to the missing implication. Set the flag instead of reconstructing it. `apply_implied_options()` runs at the head of `validate` and `build`, mirroring upstream's parse-then-check ordering, and the local `is_inplace` disjunction is deleted so `self.inplace` is the single source of truth. The server-side halves parse their own argv, so `ServerConfig::apply_append_implies_inplace()` applies the same rule once on entry to the server body - the path shared by the `--server` parser, the daemon's client_args parser and the client's in-process half. `transfer_ops::resolve_use_inplace` drops its `append` parameter and reads `inplace` exactly as receiver.c:968 does. Server argv is unchanged and matches upstream: `--append` (twice for --append-verify) with no `--inplace` beside it, and `--append-verify` never goes on the wire (options.c:2951-2956).
oferchen
force-pushed
the
fix/append-implies-inplace
branch
from
August 6, 2026 22:45
6fb57a9 to
e9c4207
Compare
oferchen
marked this pull request as ready for review
August 6, 2026 22:53
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.
Upstream rule
options.c:2400-2411:After that assignment upstream never consults
append_modeagain to decidewhere the receiver writes or what survives a failure. Every consumer reads the
implied flag:
receiver.c:968if (inplace || one_inplace)receiver.c:327,496inplace_sizingpreallocated_len = size_r, and the closingftruncate(fd, offset)generator.c:1862,1898if (inplace && make_backups > 0 && fnamecmp_type == FNAMECMP_FNAME)receiver.c:872if (inplace && make_backups > 0)fnamecmp_type = FNAMECMP_BACKUPreceiver.c:1029(recv_ok && ...) || inplacereceiver.c:1074!(keep_partial && partialptr) && !inplacekeptstrsays "retained" rather than "discarded"sender.c:337updating_basis_filematch.c:211's backward-Copy suppressioncompat.c:688basis_dir_cnt && inplaceoptions.c:2424-2432--partial-dir/--delay-updates, namingappend_mode ? "append" : "inplace"batch.c:72flag_ptr[12] = &inplaceWhat oc did
inplacewas never set.ClientConfigBuilder::validatereconstructed the rulelocally (
let is_inplace = self.inplace || self.append), so the conflict tablefired correctly while
inplaceitself stayed false for every other consumer.A second reconstruction sat in
transfer_ops::resolve_use_inplace(
inplace || append), which is why the network receiver's write target wasright by accident.
Observable consequence (measured, rsync 3.4.4 vs oc, aarch64 Linux)
Destination
AAAABBBB(8 bytes), sourceAAAABBBBCCCCDDDD(16 bytes).--append --backup, backup contentAAAABBBBAAAABBBBCCCCDDDDAAAABBBB--append --backup, backup shares dest inode--append --backup --backup-dir, copy contentAAAABBBBAAAABBBBCCCCDDDDAAAABBBB--append --backuprun twice, backup contentAAAABBBBAAAABBBBCCCCDDDDAAAABBBB--inplace --backup(control), backup contentAAAABBBBAAAABBBBAAAABBBB--append, destination contentAAAABBBBCCCCDDDD--appendonto a fresh destinationAAAABBBBCCCCDDDD, no temp left--append --partial-dir/--delay-updates/--whole-file--append cannot be used with --XWithout the flag the generator takes the non-inplace backup path, which
hard-links the destination into the backup area before the transfer
(
rsync.c:740). The append then grows that shared inode, so the "backup" isbyte-identical to the new file and the pre-image is gone. The explicit
--inplace --backupcontrol was always correct, which isolates the defect tothe missing implication rather than to the backup code.
Scope of that defect: the local-copy path only. On the network path
resolve_use_inplacealready ORedappend, sodisk_commit'smake_inplace_backupfired and the remote--append --backupcell measuredcorrect both before and after.
Server argv - unchanged, and matching upstream
options.c:2951-2956sends--append(twice for--append-verify) and never--inplacebeside it. Measured through an-eshim that logs the server argv:--inplacein argv--appendcount--append-verifyin argv--append(upstream / oc before / oc after)--append-verify(upstream / oc before / oc after)--inplace(upstream / oc before / oc after)Both emitters (
invocation/builder.rs,daemon_transfer/.../arguments.rs)already used upstream's
if append { ... } else if inplace { ... }shape, sosetting the flag does not add
--inplaceto any peer argv.Change
Set the flag; delete the reconstruction.
ClientConfigBuilder::apply_implied_options()runs at the head of bothvalidateandbuild, mirroring upstream's parse-then-check ordering, sothe conflict table can test
self.inplacedirectly. Theis_inplacedisjunction is gone;
self.inplaceis the single source of truth. Themessage still reads
if self.append { "append" } else { "inplace" }-upstream's own naming ternary, not a second copy of the flag.
ServerConfig::apply_append_implies_inplace()on entry torun_server_with_handshake_adopting- the one path the--serverargvparser, the daemon's
client_argsparser and the client's in-process halfall reach, and it runs before
setup_protocol()'s protocol < 29 checks.transfer_ops::resolve_use_inplacedrops itsappendparameter and readsinplaceexactly asreceiver.c:968does.--backupis not in upstream's conflict table, and no rejection is added.Consumer audit
Every oc site that branches on
inplace, and what it now does under--append:transfer_ops::resolve_use_inplace- unchanged result; now single-sourced.pipeline/receiver.rs::verification_kept_str- unchanged result; it readsthe per-file
use_inplace, which already carried append.disk_commit::make_inplace_backup- unchanged result, same reason. This isoc's
generator.c:1898analogue and it is reached BY the flag.transfer_ops/response.rs:402- newly reached. The destination istruncated to the received length, matching
receiver.c:496. Previouslyneither branch ran under
--append.generator/transfer/transfer_loop.rs:929 updating_basis_file- newlytrue, matching
sender.c:337. This is the server-side sender on a pull,the site neither local reconstruction could reach.
setup/restrictions.rs:132---appendwith a basis dir at protocol < 29 isnow refused, matching
compat.c:688.config/builder.rs:564,583- transfer-layer--inplaceconflict checks. Notuser-reachable; the client rejects the same pairs first with upstream's
wording.
run/batch.rs:115- the batch stream-flagsinplacebit now carries theimplied value, matching
batch.c:72(flag_ptr[12] = &inplace).select_write_strategypicksInplacefor a freshdestination instead of a temp file (
receiver.c:968);execute/mod.rs:188takes the copy-aside backup path (
generator.c:1862,1898) - this is thefix above;
execute/mod.rs:517setspreallocated_len = size_r(
receiver.c:334). The FICLONE / clonefile /CopyFileExfast paths nowdecline
--append; they have no upstream analogue and the result isbyte-identical either way.
Tests
tests/append_implies_inplace.rs---append --backupmust keep thepre-image AND must not share the destination inode (the two together are
what separate "append happens to write in place" from "append IS inplace");
the same for
--backup-dir; an explicit--inplace --backupcontrol;--appendwrites through the destination inode; the three upstream conflictdiagnostics keep their exact wording and exit code.
crates/core/.../builder/tests.rs- the promotion fires for--appendand--append-verify, and is one-directional.crates/transfer/src/config/mod.rs- same for the server-side entry.Verification
Run on the aarch64 Linux host, since GitHub Actions is not scheduling:
cargo fmt --all -- --check- cleancargo clippy --locked --workspace --all-targets --all-features --no-deps -- -D warnings- cleancargo nextest run --workspace --all-features --no-fail-fast- 32283 tests,32277 passed, 6 failed, all 6 reproduced on unpatched
origin/master:4
xtask::commands::package::tests::cross_compiler_*and 2transfer::uts_chmod_optioncases.rsync 3.4.4binary,and against an unpatched
origin/masterbuild of oc for the before column.Out of scope, found while auditing
receiver.c:872has no oc analogue:FnameCmpType::Backupexists incrates/protocolbut is never produced, so under--inplace --backupoc'sdelta basis stays the destination rather than switching to the backup copy.
Independent of this PR (it reproduces with an explicit
--inplace), and itlives in the fenced receiver/generator crates.
receiver.c:759,769(make_backups = -make_backupson redo) is unreachableunder
--inplace/--appendin upstream itself: it is gated onkeep_partial,which
options.c:2439clears inside the sameif (inplace)block. Nothing tomirror.
A local copy never runs
setup::restrictions, so--protocol=28 --inplace --link-dest=...exits 0 where rsync 3.4.4 exits 13. Not append-specific andunchanged by this PR.
--write-deviceshas the same missing implication (options.c:2413-2419alsosets
inplace = 1); left alone.