Compare commits

...
569 Commits
Author SHA1 Message Date
Andrew Tridgell 2358081d3d fleettest: mac2-hfs runs the backup-dir ownership race too
operator-path-backup-chown probes for a cross-device directory, because
make_backup() renames into a same-filesystem backup dir and never reaches
the set_file_attrs() path the test is about.  On macOS there is normally
no such directory, so the test is a macOS-wide expected skip -- but this
target puts the scratch trees on a separate HFS+ volume, which supplies
exactly the condition it was looking for.

Same reason backup-crossdev-copy and chmod-temp-dir are already omitted
here.  The test PASSES on the target (10.9s); only the expected-skip
bookkeeping was wrong.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 1030929cba vfs: adapt the merged-in base changes to the VFS layer
The base gained 13 commits.  Three needed hand-porting because the change
lands on code the VFS split moved or renamed:

- 31130112 (--confine-root) is almost entirely in syscall.c, which does
  not exist here.  abspath_excluded_by_module() becomes
  abspath_outside_confinement() in vfs/dirstack.c, taking its root from
  vfs.module_dir when we are a daemon and from confine_root otherwise,
  plus the fd-pin helpers; ona_open() in vfs/owner_walk.c gains the
  getcwd() seed and the pin_transit exemption.  The VFS passes is_operator
  as an argument where the base reads operator_path_resolve, so the
  refusal takes it from there rather than the deleted global.

- d09edb85 (--link-dest hard-link fallback) keeps the VFS call
  vfs_link_at(cmpbuf, fname, !am_daemon ? VFS_OPERATOR_PATH : 0, 0) and
  adopts the base's cannot_hardlink/match_level fallback around it.

- t_stub.c gains the confine_root/confine_rootlen stubs beside the VFS
  curr_dir note.

Tree is byte-identical to the merge oracle (tag merge-reference-11).
2026-08-13 10:20:13 +10:00
Andrew Tridgell 893e88ade1 testsuite: cover the backup-dir ownership set under a parent swap
The previous commit gave set_file_attrs()' path-based chmod/chown the
operator ownership walk, but nothing exercised the chown side: the
instrumented branch was reached 92 times by the existing suite, all of
them chmod.  That is the same gap that let the upstream fix ship inert.

The uncovered path is a SYMLINK backup.  op_pin cannot pin a symlink --
there is no O_NOFOLLOW open of one -- so make_backup()'s set_file_attrs()
falls through to the full-path lchown.  Without the walk, a backup parent
flipped to an attacker-owned symlink redirects that lchown onto a victim
outside the backup tree and retags it as the attacker's.

Two things make this awkward to test, and both are why it was missed:

  - On one filesystem make_backup() hard-links or renames the item into
    the backup dir and never calls set_file_attrs() at all.  The fixture
    therefore puts the backup dir on tmpfs so link and rename fail EXDEV
    and the recreate path runs.  backup-crossdev-copy probes the same way.

  - A statically planted symlink proves nothing: rsync's own backup-dir
    validation deletes a non-directory component before using it.  The
    plant has to be a live flip, as in operator-path-backup-symlink.

The positive control checks both that the symlink reached the backup tree
and that the backup copy carries the attacker's uid -- i.e. that an lchown
actually ran.  Without that second assertion the race would pass
vacuously on any build where the chown never happens.

RED on the parent of the previous commit (victim outside/f94 retagged
away from root); GREEN here.  Runs on Linux CI as root; registered as an
expected skip on Cygwin (root-only) and macOS (root-only, and no
cross-device tmpfs -- backup-crossdev-copy is listed there for the same
reason).  Full expected-skip oracle passes exactly.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 730e619767 vfs: give set_file_attrs' path-based chmod/chown the operator policy
Base commit 0bfcd3b0 taught do_chmod_at()/do_lchown_at() to resolve an
operator-supplied path through the ownership walk.  The previous commit
ported that into vfs_chmod()/vfs_lchown(), but on this branch the policy
is a per-call argument rather than the ambient operator_path_resolve
global, and set_file_attrs() still passed 0 -- so the ported branch was
unreachable and the fix was inert.

op_pin already covers a reg/dir/fifo leaf with a pinned fd, which is
stronger than the walk.  What it does not cover reaches the path-based
fallbacks: a symlink or device leaf never enters op_pin, and a non-root
operator can fail the pin open with an ordinary EACCES and fall through
with op_refuse clear.  Both then resolved the full operator path with a
bare lchown()/chmod().

Derive the flag from ATTRS_OPERATOR_PATH and pass it to the two
VFS_AT_FDCWD fallbacks only; the held-dirfd arms stay at 0, since a
pinned parent already confines them.  vfs_chmod()'s operator branch
skips S_ISLNK on its own, so a symlink-as-object keeps the
lchmod()/setattrlist() path, matching the base.

Also drop ten .gitignore entries the rebase duplicated: this branch's
own test-helper block and the base's new one list the same binaries.

Suite 259/0/83; the operator-path, backup and temp-dir families pass as
root over --use-tcp.  Instrumenting the branch shows it is now reached
92 times across those tests (the chmod side); the lchown side has no
test exercising it, which is how the gap survived upstream.
2026-08-13 10:20:13 +10:00
Andrew Tridgell fca1d10ff5 vfs: adapt the merged-in base changes to the VFS layer
The base gained 99 commits since the last rebase.  Nine files needed a
hand-port because the change lands on code the VFS split moved or
renamed, and syscall.c no longer exists here:

- backup.c: make_path() now runs on a private copy of backup_dir_buf
  (c933f622), so vfs_make_path() takes dirbuf and drops the restore.
- clientserver.c: keep both the module-root snapshot and the new
  daemon_config_filter_file window.
- exclude.c: the peer-driven merge-file confinement is the
  vfs_open_owner_walk() is_operator argument, not a global.
- fileio.c: the coalesced --sparse writer's new helpers use
  vfs_lseek()/vfs_punch_hole().
- generator.c/vfs/mknod.c: gen_entry_mknod() falls back through
  vfs_mknod(); the atfd path keys its mknodat() off HAVE_MKNODAT.
- receiver.c: secure_recv_open() passes VFS_OPERATOR_PATH instead of
  toggling operator_path_resolve; open_readonly_inplace() uses the VFS
  stat/chmod/open wrappers.
- sender.c: absolute --relative cleanup anchors at "/" via
  vfs_resolve_open(), the copy-links walk uses
  vfs_resolve_open_at_beneath(), and the source removal goes through
  vfs_unlink().
- vfs/chmod.c, vfs/chown.c: VFS_OPERATOR_PATH now takes the ownership
  walk, and the no-follow chmod grows the non-Linux fd path.
- vfs/secure_open.c: the fd-anchored resolver splits into a shared
  internal with an allow-dotdot entry point, and secure_walk_at() routes
  a literal "."/".." through ds_descend() before the leaf fast paths.

Tree is byte-identical to the merge oracle (tag merge-reference-10).
2026-08-13 10:20:13 +10:00
Andrew Tridgell 97d7cfc5cb vfs: adapt the merged-in base changes to the VFS layer
Linear-rebase counterpart of the conflict resolutions made when the
sec-fixes base was re-integrated (see the merge for reference).  Three of
the base's new commits touch code this branch relocated or reworked:

- options.c (3fe1ed51 "rsync: confine the daemon files-from open to the
  module root"): the base wraps the files-from open in the
  operator_path_resolve global, which no longer exists here.  Pass the
  operator context explicitly instead: vfs_open_owner_walk(..., 1).

- receiver.c (cfd40f55 "receiver: confine peer-selected partial basis
  paths"): the new relative-partial-basis branch uses the VFS resolver
  name, vfs_resolve_open().

- vfs/chmod.c (7b16872e "syscall: silence scan-build dead-store in
  do_fchmodat_nofollow fallback"): syscall.c is deleted here, so the
  mode masking move and the unused-arg casts land in the relocated
  do_fchmodat_nofollow.

Tree is byte-identical to the validated merge result.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 359baac8eb vfs: fail loud in the held-fd lstat no-AT_SYMLINK_NOFOLLOW arm; make the compile-check atomic
Two fixes from codex review of the no-AT_FDCWD port:

- vfs/stat.c: the held-dirfd vfs_lstat branch fell back to
  fstatat(dirfd, path, st, 0) when AT_SYMLINK_NOFOLLOW is unavailable,
  which FOLLOWS the leaf and breaks lstat's no-follow contract.  On a
  system with SUPPORT_LINKS but no AT_SYMLINK_NOFOLLOW, return ENOSYS
  instead (mirroring the held-fd vfs_lchown arm) so a symlink-sensitive
  caller fails loud rather than silently following; the !SUPPORT_LINKS
  arm keeps fstatat(...,0) since there is nothing to follow.  The CI
  compile-check config also undefines AT_FDCWD so no held fd is produced
  there; this hardens the standalone "no AT_SYMLINK_NOFOLLOW" shape.

- Makefile.in: the vfs-no-at-fdcwd.o compile loop wrote every object to
  $@, so a mid-loop failure left a fresh-timestamped $@ and a retry could
  skip the check.  Compile to $@.tmp and mv to $@ only after the whole
  loop succeeds (rm the stale target up front); clean the .tmp too.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 39c6c31bfc vfs: adapt the merged-in base changes to the VFS layer
Linear-rebase counterpart of the conflict resolutions made when the
sec-fixes base was re-integrated (see the merge for reference).  The base
gained 5cb4b829 ("syscall: build without AT_SYMLINK_NOFOLLOW") and its CI
compile-check, which touch code this branch relocated into vfs/:

- Move syscall.c's RSYNC_TEST_NO_AT_FDCWD undef block into vfs/vfs.h,
  before the VFS_AT_FDCWD sentinel binds (so the sentinel takes its
  no-AT_FDCWD value rather than dangling on the undefined AT_FDCWD).
- Port the AT_SYMLINK_NOFOLLOW-absent fallbacks into the relocated code:
  vfs/chown.c (vfs__lchown_secure + held-fd vfs_lchown gate on
  AT_SYMLINK_NOFOLLOW), vfs/stat.c (do_xstat_at's unused-arg casts, the
  vfs_lstat AT_SYMLINK_NOFOLLOW-absent arm, held-fd gate), vfs/mkdir.c
  (guard rand_bytes on AT_FDCWD, its only caller).
- Retarget the CHECK_COMPILE_OBJS compile-check from syscall.c to a
  portable shell loop over the vfs sources built with
  -DRSYNC_TEST_NO_AT_FDCWD (Makefile.in).

Tree is byte-identical to the validated merge result.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 518c09fccf gitignore: ignore the remaining test-helper binaries
t_hashtable_overflow, t_iwildmatch, t_clean_fname, and t_safe_arg were
built by the suite but missing from .gitignore, so a stray git add -A
sweeps them into a commit (as happened during this rebase round).  List
them alongside the other t_* harnesses.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 01e0de397b testsuite: keep daemon test ports out of the 13000+ bloatware range
daemon-exclude-namebased bound its daemon on 13010, and the
setup_chroot_inner helper hashed into 12940-13139 -- both reach into
13000+, where ASUS Armoury Crate on the Cygwin CI host parks localhost
listeners (13010, 13030-13032), making the port probe fail the test.
The helper also used str hash(), which is per-process randomized
(PYTHONHASHSEED), so its port wandered run to run.

Move the fixed port to 12931 and the helper to a deterministic
crc32-based slot in the otherwise-unused 12800-12859 band.
2026-08-13 10:20:13 +10:00
Andrew Tridgell ea03fe0e9a vfs: fix the no-AT_FDCWD fallback in vfs__symlink_secure
The #else arm for platforms without AT_FDCWD called the four-argument
vfs_symlink() with two arguments -- a compile error on such systems.
Call vfs__symlink_plain(), matching the base's do_symlink() fallback.
Found by codex review; predates this rebase round.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 4c142f671b vfs: adapt the merged-in base changes to the VFS layer
Linear-rebase counterpart of the conflict resolutions made when the
sec-fixes base was integrated (see the merge for reference):

- Port ddda7ba5's operator-path confinement of do_symlink_at and
  do_rmdir_at into the relocated VFS code.  vfs__symlink_secure gains
  the VFS_OPERATOR_PATH ownership-walk branch (parent confined via
  vfs_owner_walk_parent, shared leaf-creation preserved so fake-super
  emulation still applies); vfs__unlink_secure extends its existing
  operator branch to the rmdir/AT_REMOVEDIR case.  Callers pass the
  policy explicitly where the base set operator_path_resolve: the
  keep_backup symlink create (backup.c), the backup-tree rmdir in
  delete_item (delete.c, DEL_FOR_BACKUP), and handle_partial_dir's
  rmdir (util1.c).
- Port 1f8f89c2's robust_rename EXDEV-fallback confinement into
  vfs/robust.c: an absolute --temp-dir/--partial-dir operand routes the
  copy_file dest-write and the source-unlink through the ownership walk
  (VFS_OPERATOR_PATH), so a raced parent symlink can't redirect either
  out of the module.
- Drop the stale "no ownership-walk branch" notes in vfs/vfs.h and
  vfs/symlink.c now that symlink and rmdir carry the branch.

Tree is byte-identical to the validated merge result.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 331188aaf6 acls: use vfs_relpath_active in the Solaris ACL branch
The HAVE_SOLARIS_ACLS facl(2) paths called secure_relpath_active() -- the
base-branch name -- which does not exist on the VFS branch (here the gate is
vfs_relpath_active()).  Linux/BSD never compile that branch, so it stayed latent;
a real Solaris build fails with an implicit-declaration error.  Rename both call
sites (set_rsync_acl default-ACL delete and the access/default set fallback).
2026-08-13 10:20:13 +10:00
Andrew Tridgell 1a8c81d9d7 github: run the ASan suite under --enable-strict-confinement
Enable the strict confinement assertion in the ASan/UBSan CI build so the suite
enforces "no confined-regime raw path metadata op" on every run.
2026-08-13 10:20:13 +10:00
Andrew Tridgell fc23ad74c9 rsync,generator: assert the xattr/ACL pin invariant under STRICT_CONFINEMENT
Guard the two receiver decision points that drive the xattr/ACL setters down a
raw path-based branch -- set_file_attrs() and gen_entry_copy_xattrs().  In a
strict build, a confined pinnable non-operator leaf that reached the setters
without a confined fd (held_fd/xfd < 0 and not refused) aborts: that is exactly
the copy-xattrs fallback class.  The guards mirror the pin/re-pin conditions, so
they cannot false-abort a legitimate transfer.

Verified: under --enable-strict-confinement the suite is 210/0 with zero aborts;
neutering the re-pin makes copy-xattrs-symlink-race abort at the guard.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 531f941c7d vfs: add STRICT_CONFINEMENT build-time confinement assertion
Add a CI/dev hardening mode (--enable-strict-confinement) that turns any
confined-regime raw path-based metadata op into a hard abort, so a reintroduced
copy-xattrs-class fallback fails the test suite instead of silently escaping
through a flipped parent symlink.

vfs_must_be_confined() is the predicate: the modern *at/O_NOFOLLOW primitives are
present, the path is non-operator, relative and multi-component (a parent the
attacker could flip), and vfs_relpath_active().  vfs_strict_confine_fail() logs
and aborts.  Both are absent/no-op without STRICT_CONFINEMENT, so there is no
production behaviour change.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 5210c5fd53 vfs: review fixes
cleanups from review comments by Sam James. Thanks!
2026-08-13 10:20:13 +10:00
Andrew Tridgell eab19ec7f9 rsync: never path-resolve a confined receiver's xattr/ACL write (copy-xattrs race)
set_file_attrs() pins the entry via the cached held dir fd and drives the xattr
/ACL ops off that fd (fsetxattr).  But when the pin missed -- vfs_cached_dirfd()
returns -1 (its dir isn't the held one, or the path is deeper than the dirfd
cache), or the leaf openat() loses a race -- held_fd stayed -1 and
set_stat_xattr()/set_xattr()/get_acl_fdat()/set_acl_fdat() fell through to the
path-based branch (sys_lsetxattr(fname,...)).  Unlike the chmod/chown/times path
wrappers (which secure-resolve), that raw lsetxattr re-resolves the parent, so a
concurrent flip of a dest parent component to a symlink->outside lands the xattr
OUTSIDE the destination tree (the intermittent copy-xattrs-symlink-race escape
that surfaces under -j load, which widens the open->setxattr window).

Re-pin through the secure resolver when the cached pin misses on a confined,
non-operator receiver path, so the xattr/ACL ops always use a confined fd -- NOT
a raw path lsetxattr; if the re-pin also fails (a genuinely raced parent/leaf
symlink) skip the path-based ops (xattr_refuse) rather than redirecting them.
The re-pin passes O_DIRECTORY for a directory leaf (the secure resolver refuses a
bare dir open with EISDIR), so dir xattrs/ACLs are still preserved on a cache
miss.  Apply the same re-pin/refuse to gen_entry_copy_xattrs() (the dir xattr
copy), whose dfd<0 path likewise fell to copy_xattrs() with dest_fd==-1.
chmod/chown/times are unchanged (already confined via their *at wrappers);
operator paths keep op_pin/op_refuse.

Confirmed: under heavy parallel load the dangerous lsetxattr fallback was reached
52+ times before and 0 times after; dir xattrs preserved; suite 210/0/66,
root metadata PoC tests pass, builds +/-xattr.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 955115c5a5 testsuite: per-operand policy regression for vfs_rename_at (PR #30)
Extend the t_rename_secure harness with PS-refuse/PS-follow: rename to the SAME
operator-owned escaping symlink path (oplink -> ../trap) with the SAME operator
old-side flag, differing ONLY in the new-side flag.  The ownership walk (operator)
follows the operator's own uid0/euid symlink; the secure receiver resolve
(transfer, flag 0) refuses it because it leaves the cwd anchor.  PS-refuse must be
rejected and PS-follow must succeed -- proving the new side's confinement is
independent of the old side's policy.

Deterministic, non-root, no race.  Verified RED on the old whole-call behaviour
(emulated by OR-ing both operands' flags, the new side then follows oplink and
escapes to ../trap) and GREEN with the per-operand split.  The driver adds the
oplink fixture and the per-side source files.

Suggested-by: Zen Dodd <mail@steadytao.com>
2026-08-13 10:20:13 +10:00
Andrew Tridgell 62122367a4 vfs: split two-path ops to per-operand policy flags (rename/link)
vfs_rename_at() and vfs_link_at() took a single vfs_flags applied to BOTH
operands, so VFS_OPERATOR_PATH (ownership walk: follow uid0/euid symlinks, refuse
foreign) was applied to a transfer operand even when only one side was operator-
supplied -- relaxing the secure receiver resolve that a transfer/file-list path
should get.  The default branch confined each side by absolute-vs-relative, but
that missed a *relative* operator path (--backup-dir=bdir, --partial-dir=.rsync,
relative --link-dest).  Reported in the rsync-private PR #30 review.

Take old_flags and new_flags separately and resolve each operand under its own
policy via a shared vfs_twopath_side() helper (operator walk if the side is
VFS_OPERATOR_PATH or absolute; secure receiver resolve if relative-with-slash;
AT_FDCWD for a bare name).  An operator basis/backup path on one side can no
longer relax the other side's confinement.

Callers now pass the correct per-side policy:
- backup link/rename: source (live dest file) = transfer (0), backup target = operator.
- receiver partial-dir rename: partialptr = operator, final dest = transfer.
- hard_link_one / generator link-dest: basis source = operator (non-daemon), dest = transfer.
- finish_transfer / gen_entry_rename / robust fallback: both transfer (0,0; default per-side).
- robust_unlink ETXTBSY sibling rename: both share the caller's policy.
Also drops the operator flag on backup's "just in case" robust_unlink of the
transfer-side source.

Behaviour-preserving for the operator side (the backup/partial-dir parents still
owner-walk); it only tightens the transfer side back to the secure resolve.
Builds +/-xattr; suite 210/0/66; root operator-path PoC tests all pass.

Suggested-by: Zen Dodd <mail@steadytao.com>
2026-08-13 10:20:13 +10:00
Andrew Tridgell 139c305814 delete: confine the backup-tree unlink via the operator ownership walk
A backup-tree delete (delete_item with DEL_FOR_BACKUP -- removing an existing
leaf under an absolute --backup-dir before the new backup is placed) fell through
del_unlink() to robust_unlink(fbuf, 0), which resolves the leaf's parent by path
with no ownership walk.  A local attacker who flips a backup-path parent to a
symlink in that window could redirect the unlink outside the backup tree.

The base confines this: make_backup() wraps make_backup_inner() in
operator_path_resolve, so the same unlink reaches do_unlink_at()'s owner-walk
branch.  The VFS refactor replaced the global with explicit vfs_flags but left
del_unlink() passing 0, dropping the confinement.  Thread VFS_OPERATOR_PATH into
del_unlink()'s path-based fallback when DEL_FOR_BACKUP is set (the exact cases
that ran under the base's operator wrap); a held-dirfd delete is already confined
and ignores it.  The rmdir path keeps no owner-walk branch, matching base.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 4099664250 vfs: round-3 operator-path reconciliation (snap to merge oracle)
The -X theirs rebase replayed the 47 VFS commits onto the new base, letting each
VFS commit win its refactor on conflict; that drops the round-3 base changes that
overlap the operator-path code (they were authored against the deleted syscall.c
and the pre-vfs_flags model).  This commit snaps those files byte-for-byte from
the validated merge oracle (merge-reference-3 = 6afdd389), so the branch tip is
tree-identical to the merge that built clean +/-xattr and passed the suite +
root operator-path PoC tests.

Reconciled: the syscall.c->vfs/ ports (secure_open/owner_walk/dirstack/rename/
link/chmod/chown/times/copy_file + vfs.h/vfs_internal.h), the set_file_attrs
op_pin via ATTRS_OPERATOR_PATH (rsync.c/rsync.h + backup.c callers), the
secure_basis_open/basis_link_stat operator branches (receiver.c/generator.c), the
copy_file/gen_entry_copy_xattrs held-fd source reads, the change_dir/sender
opt-out sites (util1.c/sender.c/clientserver.c), and Makefile.in.
2026-08-13 10:20:13 +10:00
Andrew Tridgell fbfa40fe4d vfs: port 37dbb263's operator-path mknod FIFO/socket fallback
Linear-rebase counterpart of the round-2 merge resolution: 37dbb263 fixed
do_mknod_at()'s operator branch in syscall.c (deleted on this branch) to
fall back to mkfifoat() for a FIFO and EOPNOTSUPP for a nested socket when
mknodat() can't make a special file on the BSDs/macOS/Solaris.  Re-express
it in the VFS_OPERATOR_PATH branch of vfs__mknod_secure() in vfs/mknod.c,
mirroring the secure-relpath branch already in that function.

Tree is byte-identical to the validated round-2 merge result.
2026-08-13 10:20:13 +10:00
Andrew Tridgell e0f2f52670 vfs: adapt the merged-in base changes to the VFS layer
Linear-rebase counterpart of the two conflict resolutions made when the
sec-fixes base was integrated (see the merge for reference):

- Port 8a7e9a16's fixed-array dirstack into the relocated VFS code:
  vfs/vfs_internal.h (DS_MAXDEPTH + inline int fds[]) and vfs/dirstack.c
  (drop malloc/realloc/free; ENOMEM past the cap).  8a7e9a16 changed the
  dirstack in syscall.c, which this branch deleted, so the fix is
  re-expressed in vfs/.
- Map the base's new F_XATTR ndx<0 guard (1d36a565) from the old
  do_chmod_at() to the unified vfs_chmod(VFS_AT_FDCWD, fname, ..., 0).

Tree is byte-identical to the validated merge result.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 18e30dd23e vfs: codex review fixes for the unified stat/chmod/lchown
Three issues from the post-unification review:

- The !SUPPORT_XATTRS x_stat/x_lstat macros hardcoded VFS_ALLOW_SYMLINK,
  silently downgrading an operator-path stat (backup-dir) from the ownership
  walk + module confinement to plain follow-stat in no-xattr builds (a
  pre-existing gap the unified vfs_stat now lets us close).  Forward the
  caller's vfs_flags instead, so no-xattr builds get the same policy as
  xattr builds.

- Held-fd ".." was accepted by the stat/chmod/lchown dispatchers, which
  would operate on the PARENT of the pinned dirfd -- the wrong default for a
  security boundary even though no current call site passes it.  Reject ".."
  while still allowing "." (a stat/chmod/chown of the dir itself, which
  link_stat_at and set_file_attrs legitimately do for directory entries).

- Two stale comment/test strings still said vfs_chmod_at (now vfs_chmod).

Verified with a local --disable-xattr build; full suite 190/50.
2026-08-13 10:20:13 +10:00
Andrew Tridgell af68dac222 vfs: update the !SUPPORT_XATTRS x_stat/x_lstat macros for the unified stat
vfs_stat/vfs_lstat are now vfs_<op>(int dirfd, path, st, int flags); the
no-xattr fallback macros still expanded to the old 2-arg vfs_stat(fn,fst),
breaking the build without xattr support.  Map them to the unified plain
form: vfs_stat(VFS_AT_FDCWD, fn, fst, VFS_ALLOW_SYMLINK) (and lstat), which
preserves the prior plain-stat behaviour.  Caught by the fleettest on
openbsd; verified with a local --disable-xattr build.
2026-08-13 10:20:13 +10:00
Andrew Tridgell b61342e4c9 vfs: docs pass for the unified dirfd+flags API
Rewrite the vfs.h header contract to describe the current model: the two-layer
vfs/ structure (primitives + compounds), the single vfs_<op>(int dirfd, ...,
int flags) form, the meaning of VFS_AT_FDCWD / VFS_ALLOW_SYMLINK /
VFS_OPERATOR_PATH / VFS_REMOVEDIR, that the operator policy is an explicit
per-call flag (not ambient state), and which ops keep explicit forms
(rename/link two-path, open variants, fd-based fstat/fileio).

Update the stale threat-model cross-references that pointed at the old
vfs_chmod_at() (now the static vfs__chmod_secure()) and the two
vfs_mknod_at()/vfs_symlink_at() mentions left by the unification.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 1214c4a2f9 vfs: unify stat/lstat, chmod, lchown into dirfd+flags form
Collapse the remaining three-form ops into one call each, matching
mkdir/mknod/symlink/unlink:
  vfs_stat(int dirfd, path, st, flags)
  vfs_lstat(int dirfd, path, st, flags)
  vfs_chmod(int dirfd, path, mode, flags)
  vfs_lchown(int dirfd, path, owner, group, flags)
dirfd == VFS_AT_FDCWD resolves the path (VFS_ALLOW_SYMLINK = plain libc op,
default 0 = secure receiver resolve, VFS_OPERATOR_PATH = ownership walk for
stat); a real held dirfd operates on a single component under it.  The old
plain/_at/_atfd bodies become static helpers behind the dispatchers;
vfs_fstat stays (fd-based).  chmod/lchown keep their no-owner-walk behaviour
(VFS_OPERATOR_PATH resolves the same as the default secure walk).

Pure API-narrowing -- the operator policy was already explicit; no global is
involved.  This is behaviour-preserving (plain sites -> VFS_ALLOW_SYMLINK,
_at -> the flag they already carried, _atfd -> held-fd 0).

Held-fd validation differs by op: mkdir/mknod/symlink/unlink reject "."/".."
(creating/removing them is nonsensical), but stat/chmod/lchown ALLOW "."
(a read or metadata op on the dir itself is legitimate -- link_stat_at and
set_file_attrs do it for directory entries); only empty and multi-component
("/") names are rejected there.  Caught by the suite (chmod/metadata/
ownership-depth + the implied-"." dir mkdir path).

Full suite 190/50.
2026-08-13 10:20:13 +10:00
Andrew Tridgell f436561d08 vfs: fix the !SUPPORT_XATTRS x_stat/x_lstat macros for the vfs_flags arg
The stat/lstat threading added a vfs_flags argument to x_stat()/x_lstat(),
but on a build without xattr support those are 3-arg macros in rsync.h
(mapping to plain vfs_stat/vfs_lstat), so the 4-arg call sites failed to
compile ("too many arguments to macro").  Give the macros the extra
(ignored) parameter.  Caught by the fleettest on openbsd; verified with a
local --disable-xattr build.
2026-08-13 10:20:13 +10:00
Andrew Tridgell b226005967 vfs: delete the operator_path_resolve global
With every operator-context call site now passing VFS_OPERATOR_PATH
explicitly, nothing reads vfs.operator_path_resolve any more.  Remove the
four now-dead set/clear blocks (make_backup, handle_partial_dir, and the two
generator in-place-backup paths) and delete the field from struct vfs.

The operator-supplied path resolution policy -- which selects the ownership
walk and the daemon module-root confinement for --backup-dir/--temp-dir/
--partial-dir/--link-dest operations -- is no longer ambient dynamic-scope
state poked into the VFS from mainline.  It travels as an explicit per-call
VFS_OPERATOR_PATH flag through the primitives and compounds, resolving the
layering violation that motivated this work.

Full suite 190/50.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 871d1974f1 vfs: thread the operator flag through stat/lstat + x_stat/x_lstat
vfs_stat_at / vfs_lstat_at (and their shared do_xstat_at helper) gain a
vfs_flags argument so the ownership-walk branch reads VFS_OPERATOR_PATH
instead of vfs.operator_path_resolve -- this was the last primitive still
reading the global.  The metadata wrappers x_stat()/x_lstat() (which stay in
xattrs.c, above the VFS) gain a vfs_flags argument forwarded to the
primitives.

Call sites classified: every backup-dir stat (validate_backup_dir,
copy_valid_path's x_stat, make_backup_inner's x_lstat + bak lstat) and the
partial-dir lstat (handle_partial_dir) pass VFS_OPERATOR_PATH; the transfer
enumeration (flist link_stat + friends), the in-place dir stat (generator),
and the set_stat_xattr lstat pass 0.  generator's non-daemon --link-dest
basis lookup passes 0 too (is_operator only gates the daemon module
confinement, a no-op there).

With this, NOTHING reads vfs.operator_path_resolve any more -- it is now
write-only (set by four now-dead blocks, removed next).  Full suite 190/50;
backup/partial/link-dest/xattr/daemon subset green.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 4a0f20f657 vfs: flag vfs_link_at + thread hard_link_one; retire the hard-link block
Give vfs_link_at an explicit vfs_flags argument (kept two-parent form) so its
ownership-walk branch reads VFS_OPERATOR_PATH instead of the global.

hard_link_one() gains a vfs_flags argument forwarded to vfs_link_at, so the
generator's alt-dest hard-link no longer sets vfs.operator_path_resolve
around the call: it passes (!am_daemon ? VFS_OPERATOR_PATH : 0), matching the
prior op = !am_daemon gating (a non-daemon --link-dest uses the ownership
walk; a daemon keeps the stronger module-anchored vfs_relpath_active path).
That operator_path_resolve block is removed.

Other call sites: the backup link (make_backup link_or_rename) passes
VFS_OPERATOR_PATH; the try_dests alt-dest link and the hlink finish-up
hard_link_one pass 0 (transfer context, as the global was there).

Set-sites: 5 -> 4 (backup make_backup, generator 2085/2130, util1
handle_partial_dir -- all wrapping the not-yet-flagged metadata layer
set_file_attrs/x_stat/x_lstat).  Full suite 190/50; hardlink/link-dest/
backup/daemon subset green.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 5c97ad3403 vfs: flag vfs_open_owner_walk + secure_basis_open; retire two more blocks
Give vfs_open_owner_walk an explicit is_operator argument so its module-root
confinement (abspath_excluded_by_module) reads the policy from the caller
instead of the vfs.operator_path_resolve global -- the last owner-walk
function still reading it.  secure_basis_open gains an is_operator parameter
threaded to it; its gate (owner-walk vs strict resolve) now branches on that
param too.

Faithful conversion (no behaviour change): every direct caller passes the
value the global held at that site -- config/log/motd/early-input/files-from/
batch/connection/exclude/authenticate/params, change_dir's daemon dest-chdir,
and vfs_secure_mkstemp's --temp-dir all pass 0; secure_basis_open passes
VFS_OPERATOR_PATH only for the operator cases (a --partial-dir basis,
fnamecmp_type == FNAMECMP_PARTIAL_DIR; and one_inplace partial-dir staging).
This preserves the existing --temp-dir behaviour (unconfined) by deliberate
choice; hardening that is a separate decision.

The receiver partial-dir-basis and one_inplace operator_path_resolve blocks
are removed (receiver.c is now free of the global).  Set-sites: 7 -> 5
(backup make_backup, generator 1071/2085/2130, util1 handle_partial_dir).

The operator-path-partial-dir-daemon test caught a real regression mid-change
(secure_basis_open's gate still read the global after its set/clear block was
removed, dropping the confinement); fixed by gating on is_operator.

Full suite 190/50.
2026-08-13 10:20:13 +10:00
Andrew Tridgell c89283a500 vfs: fix stale comment in unlink_and_reopen (robust_unlink now flagged)
The comment predated flagging robust_unlink; vfs_flags now reaches both the
robust_unlink and the create.  (codex review nit.)
2026-08-13 10:20:13 +10:00
Andrew Tridgell d037014ff4 vfs: flag vfs_rename_at + retire three operator-path blocks
Give vfs_rename_at an explicit vfs_flags argument (it keeps its two-parent
form, per the kept-explicit scope for rename) so its ownership-walk branch
reads VFS_OPERATOR_PATH instead of the vfs.operator_path_resolve global.

Call sites: the backup link/rename (make_backup) and the in-place/partial
rename (receiver) pass VFS_OPERATOR_PATH; the transfer renames (generator
entry rename, finish_transfer) and robust_rename pass 0; robust_unlink's
ETXTBSY rename-retry now forwards robust_unlink's own vfs_flags -- so
robust_unlink is fully explicit (both its unlink and its rename retry).

With unlink and rename both flagged, three operator_path_resolve set/clear
blocks that wrapped only a single now-flagged op are removed: the receiver
partial-dir rename and the two partial-dir unlinks (generator + receiver).
The remaining seven blocks still wrap not-yet-flagged ops (link, stat/lstat,
chmod/lchown via set_file_attrs, secure_basis_open) and stay for now.

Full suite 190/50; rename/partial/backup/inplace/daemon subset green.
2026-08-13 10:20:13 +10:00
Andrew Tridgell e12eb04e12 vfs: unify the unlink/rmdir family + flag robust_unlink
Collapse vfs_unlink / vfs_unlink_at / vfs_unlink_atfd / vfs_rmdir /
vfs_rmdir_at into one vfs_unlink(int dirfd, const char *path, int flags).
VFS_REMOVEDIR selects rmdir/AT_REMOVEDIR; dirfd == VFS_AT_FDCWD resolves the
path, a real held dirfd removes a single validated component.  The secure
body reads the operator context from VFS_OPERATOR_PATH instead of the
global; the rmdir path keeps its pre-existing no-owner-walk behaviour (it
never read the global -- matched the old vfs_rmdir_at).

robust_unlink() gains a vfs_flags argument threaded to vfs_unlink, so the
operator policy reaches it explicitly: the backup-tree callers (make_backup
link_or_rename, generator in-place backup) and copy_file's dest unlink (when
copying a backup) pass VFS_OPERATOR_PATH; delete and robust_rename's
transfer-context calls pass 0.  Its ETXTBSY rename-retry still uses
vfs_rename_at (the rename primitive isn't flagged yet), which reads the
global -- still set by the surrounding backup blocks -- so behaviour is
unchanged.

Call sites classified: --remove-source-files fallback (sender) follows
symlinks (VFS_ALLOW_SYMLINK); partial-dir unlink/rmdir (handle_partial_dir)
and in-place-backup/partial unlinks operator; the rest secure or held-fd.
Full suite 190/50; delete/backup/partial/remove-source/daemon subset green.
2026-08-13 10:20:13 +10:00
Andrew Tridgell ae819474d7 vfs: flag vfs_open_at + thread vfs_flags through copy_file's dest open
Wiring phase: give vfs_open_at an explicit vfs_flags argument so the
operator-path resolution policy reaches it from the caller instead of the
vfs.operator_path_resolve global, and thread that argument through
copy_file()/unlink_and_reopen() to the dest create.

Call sites classified: the in-place backup open (generator) and the backup
copy (backup.c, generator in-place) pass VFS_OPERATOR_PATH; the copy-dest
basis copy (copy_altdest_file) passes 0 -- it must NOT use the ownership
walk (re-opening the copy_xattrs parent-symlink race, see generator.c
comment), which the explicit argument now expresses directly; robust_rename
runs in transfer context and passes 0.

copy_file's dest UNLINK (robust_unlink) still reads the operator global,
still set by the surrounding backup blocks, so behaviour is unchanged at
every site (flag and global agree).  Full suite 190/50; backup/copy-dest/
link-dest/partial/daemon subset green.
2026-08-13 10:20:13 +10:00
Andrew Tridgell cbe4ae7744 gitignore: ignore the vfs test binaries
These compiled test harnesses (t_*_secure, t_acl, simdtest) were
accidentally committed by a git add -A; ignore them like the other
built test programs (t_unsafe, wildtest, ...) so they stay out of the tree.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 1695148937 vfs: move copy_file + robust_unlink/rename into the VFS compound layer
Relocate the file-copy / robust-unlink / robust-rename cluster from util1.c
into vfs/copy_file.c and vfs/robust.c.  These are filesystem mechanics built
on the vfs_* open/read/write/unlink/rename primitives, so they belong in the
VFS compound layer; this is a verbatim move (no behaviour change).

The cluster is mutually coupled -- robust_rename falls back to copy_file for
the cross-filesystem (EXDEV) case, and copy_file's unlink_and_reopen uses
robust_unlink -- so all four move together.  They call out to the metadata/
protocol layer for what isn't pure filesystem (copy_xattrs for the held-fd
xattr copy, handle_partial_dir for the EXDEV partial-dir path); those stay in
their current modules, keeping the dependency one-way (vfs -> nothing
above it pulled in).  safe_read (a static helper only copy_file used) moves
with it.

Operator context still flows via the vfs.operator_path_resolve field for now
(legitimately VFS-internal once these callers live in vfs/); a later commit
threads it as an explicit vfs_flags argument and removes the field.  The
t_*_secure harnesses don't reference the cluster, so no xattr/protocol
symbols are pulled into them.  Full suite 190/50; copy-dest/backup/partial/
link-dest subset green.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 056a9fe6bc vfs: move make_path into the VFS compound layer as vfs_make_path
First step of the VFS three-layer architecture: filesystem-mechanic helpers
that compose the vfs_* primitives belong inside vfs/, so the operator-path
resolution policy travels as an explicit argument instead of leaking across
the vfs<->mainline boundary as ambient state.

make_path() (recursive mkdir over vfs_mkdir/vfs_stat) moves verbatim from
util1.c to vfs/make_path.c and becomes vfs_make_path(fname, mkp_flags,
vfs_flags): mkp_flags keeps the path-handling bits (MKP_DROP_NAME/
MKP_SKIP_SLASH), vfs_flags carries the resolution policy (VFS_OPERATOR_PATH
for the operator-supplied --backup-dir tree, else 0).  The transitional
MKP_OPERATOR flag added in the mkdir step is dropped in favour of the
explicit vfs_flags argument.

Callers: get_backup_name (backup dir) passes VFS_OPERATOR_PATH; the transfer
callers (main/receiver/generator) pass 0.  Full suite 190/50.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 6e305172e5 vfs: unify the symlink family into vfs_symlink(lnk, dirfd, path, flags)
Collapse vfs_symlink / vfs_symlink_at / vfs_symlink_atfd into one call.
dirfd == VFS_AT_FDCWD resolves `path` (secure receiver resolve by default,
plain follow under VFS_ALLOW_SYMLINK); a real held dirfd makes `path` a
single validated component.  The old bodies become static helpers behind
the public dispatcher.

Unlike mkdir/mknod, the symlink secure path has NO ownership-walk branch --
it never read vfs.operator_path_resolve.  That pre-existing asymmetry is
preserved: VFS_OPERATOR_PATH is accepted but resolves the same as the
default secure walk (only the parent dir is confined; the link target is
stored verbatim and never resolved at creation).  The backup-symlink site
passes flags=0, which exactly reproduces the old vfs_symlink_at behavior.

Full suite 190/50.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 31c3b8f3d0 vfs: unify the mknod family into vfs_mknod(dirfd, path, mode, dev, flags)
Collapse vfs_mknod / vfs_mknod_at / vfs_mknod_atfd into one call, mirroring
vfs_mkdir.  dirfd == VFS_AT_FDCWD resolves the path (secure receiver resolve
by default, ownership walk under VFS_OPERATOR_PATH, plain follow under
VFS_ALLOW_SYMLINK); a real held dirfd makes path a single validated
component.  The old plain/secure/atfd bodies become static helpers
(vfs__mknod_plain/_secure/_atfd) behind the public dispatcher; the secure
body reads the operator context from VFS_OPERATOR_PATH instead of the
vfs.operator_path_resolve global.

Call sites: backup file node (make_backup_inner, operator) ->
VFS_OPERATOR_PATH; transfer device/fifo (generator) -> secure / held-fd.
The t_symlink_secure harness's plain PoC call becomes VFS_ALLOW_SYMLINK
(the vulnerable follow it demonstrates) and its secure calls flags=0.

Full suite 190/50.
2026-08-13 10:20:13 +10:00
Andrew Tridgell e34177b124 vfs: unify the mkdir family into vfs_mkdir(dirfd, path, mode, flags)
Collapse vfs_mkdir / vfs_mkdir_at / vfs_mkdir_atfd into one call.  dirfd ==
VFS_AT_FDCWD resolves the path argument; a real held dirfd makes path a
single component created directly under it (the held-fd form validates
that path is a lone harmless component -- rejects empty, any '/', "." and
".." -- so it cannot reintroduce path resolution under the pinned dir).

The plain-vs-secure choice is now an explicit per-call flag instead of a
function-name choice plus the vfs.operator_path_resolve global:
  VFS_ALLOW_SYMLINK  the call site asserts it is safe to follow symlinks
                     (the old plain vfs_mkdir); checked first, wins over
                     VFS_OPERATOR_PATH (no caller passes both)
  VFS_OPERATOR_PATH  operator-supplied path: ownership walk + module
                     confinement (the old global)
  default 0          secure receiver resolve (the old vfs_mkdir_at)

VFS_AT_FDCWD is a VFS-owned sentinel (maps to AT_FDCWD where available,
else a value that routes the held-fd form to ENOSYS) so mainline code
never has to mention AT_FDCWD directly -- avoids a compile break on
platforms lacking it.

mkdir stops reading vfs.operator_path_resolve.  vfs_owner_walk_parent now
takes the operator context as an is_operator parameter; its non-mkdir
callers (the other _at wrappers, generator hard-link) still pass the
global verbatim and convert in later steps.  make_path() gains MKP_OPERATOR
(translated to VFS_OPERATOR_PATH) so the backup-dir creation stays an
operator path by path-semantics, not by relying on the global being set.

Call sites classified: dest dir (main.c) follows symlinks (user's local
dest); transfer dirs (generator, make_path transfer callers) secure;
backup-dir (backup.c, get_backup_name->make_path) and partial-dir
(handle_partial_dir) operator.  The operator_path_resolve=1 blocks stay
for now since they still wrap non-mkdir ops; the global value and the
explicit flag agree at every converted mkdir site.

Full suite 190/50; backup/partial/daemon/exclude/filter/symlink-race
subset green.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 230d25b32b vfs: thread the operator context into abspath_excluded_by_module
First step of the API redesign.  Define the per-call VFS_* flags
(VFS_ALLOW_SYMLINK / VFS_OPERATOR_PATH / VFS_REMOVEDIR) and stop
abspath_excluded_by_module() from reading the vfs.operator_path_resolve
global directly -- it now takes an explicit is_operator argument.

The strict resolver stays confined beneath its anchor (always within the
module), so its two call sites pass is_operator=0 (the check never fires
there anyway).  The ownership walk is reached in BOTH operator context
(--backup-dir/--temp-dir/--partial-dir) and non-operator context (daemon
log-file/motd/config opens, which may legitimately live outside the
module), so vfs_open_owner_walk()/vfs_owner_walk_parent() capture the
global and pass it down through ona_open().  No behavior change -- the
value still comes from the global; later commits replace that source
with the explicit flag and delete the global.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 068c086287 vfs: snapshot the daemon module root into struct vfs
Fold the served module root (module_dir / module_dirlen / module_dirfd)
into the vfs.module_* snapshot so the confinement checks read the VFS's
own state rather than implicit clientserver.c externs.

clientserver.c calls vfs_set_module_root() in two stages, matching when
the values become final: once right after the module path is settled
(before the daemon opens any operator path -- filter/include files, the
log file), with the root dirfd still -1, and again once that dirfd is
pinned by identity.  The dirfd is borrowed (open_anchor_dirfd dup()s it);
the VFS never closes it.  vfs_init() now clears the whole snapshot so a
forked connection can never inherit a stale module root.

The vfs/ readers (dirstack, secure_open, owner_walk) switch from the
externs to vfs.module_*; clientserver/flist/main keep their own globals.
Behavior is identical: between the two calls vfs.module_dirfd is -1, so
open_anchor_dirfd re-resolves the path exactly as before the pin.
2026-08-13 10:20:13 +10:00
Andrew Tridgell b4417876b4 vfs: document the layer contract in vfs/vfs.h
Refresh the header comment now that the wrappers are vfs_* and syscall.c
is gone, and document the three operation forms callers choose between:
the plain path wrapper, the parent-resolved _at form (race-safe receiver
path), and the _atfd form (single component under a pinned dirfd).
Comment-only; no code change.  Validated at protocol 30 and 29.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 94fa51be8c vfs: remove the now-empty syscall.c
Every filesystem wrapper has moved into vfs/, so syscall.c held nothing
but its includes and an orphaned comment.  Delete it and drop syscall.o
from the rsync link and from each test-harness object list (they reach
the vfs_* symbols through libvfs.a now).  The S_BLKSIZE fallback define,
used by vfs/fileio.c, moves there.  No behavior change.
2026-08-13 10:20:13 +10:00
Andrew Tridgell 7c15f78bc3 vfs: restore platform includes in vfs/chmod.c
The chmod family carries macOS setattrlist() and the Linux SYS_fchmodat2
raw-syscall fast path, but the move into vfs/chmod.c dropped the two
platform headers that syscall.c had included at file scope: <sys/attr.h>
(macOS) and <sys/syscall.h> (Linux).  A Linux build hid this -- with
SYS_fchmodat2 undefined the code silently fell back to fchmodat() -- but
macOS failed to compile setattrlist().  Re-add both, matching the
original syscall.c includes.

Found by fleettest (mac2 BUILD-FAIL).
2026-08-13 10:20:12 +10:00
Andrew Tridgell 2341b7b9a0 vfs: move the file-data ops into vfs/fileio.c
Relocate do_ftruncate/do_lseek/do_fallocate/do_punch_hole out of
syscall.c into vfs/fileio.c as the vfs_* names, declared in vfs/vfs.h.
The SUPPORT_PREALLOCATION / HAVE_FALLOCATE / HAVE_SYS_FALLOCATE /
FALLOC_FL_PUNCH_HOLE guards travel verbatim.  This was the last
operation family: syscall.c no longer defines any filesystem wrapper.
No behavior change.  (Portability-sensitive; wants a fleettest.)
2026-08-13 10:20:12 +10:00
Andrew Tridgell 7f2ebc55f4 vfs: move the times family into vfs/times.c
Relocate the timestamp wrappers (do_utimensat/_at/_atfd, do_lutimes,
do_utimes, do_utime) and the crtime paths (do_setattrlist_times/_crtime,
get_create_time, do_SetFileTime) out of syscall.c into vfs/times.c as the
vfs_* names.  The struct create_time / #pragma pack / Cygwin windows.h
and sys/attr.h includes travel with them, as do the SUPPORT_CRTIMES /
HAVE_SETATTRLIST / HAVE_GETATTRLIST / HAVE_UTIMENSAT / HAVE_LUTIMES /
HAVE_UTIMES / HAVE_UTIME guards.  No behavior change.  (Portability-
sensitive; wants a fleettest.)
2026-08-13 10:20:12 +10:00
Andrew Tridgell 951489db55 vfs: move the mknod family into vfs/mknod.c
Relocate do_mknod/do_mknod_at/do_mknod_atfd out of syscall.c into
vfs/mknod.c as the vfs_* names, declared in vfs/vfs.h.  The
HAVE_MKNOD/HAVE_MKNODAT/HAVE_MKFIFO guards and the AF_UNIX socket-bind
fallback (with its <sys/un.h> include) travel verbatim.  No behavior
change.  (Portability-sensitive family; wants a fleettest.)
2026-08-13 10:20:12 +10:00
Andrew Tridgell 546e04b005 vfs: move the lchown family into vfs/chown.c
Relocate do_lchown/do_lchown_at/do_lchown_atfd out of syscall.c into
vfs/chown.c as the vfs_* names, declared in vfs/vfs.h.  The HAVE_LCHOWN
fallback guard travels verbatim.  No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell 26236b156e vfs: move the mkdir/mkstemp family into vfs/mkdir.c
Relocate do_mkdir/do_mkdir_at/do_mkdir_atfd and do_mkstemp/
do_mkstemp_atfd/secure_mkstemp out of syscall.c into vfs/mkdir.c as the
vfs_* names (secure_mkstemp -> vfs_secure_mkstemp).  The
trim_trailing_slashes path helper (used by trimslash and the mkdir
wrappers) moves with them, keeping its name and now declared in vfs.h.
The static rand_bytes helper, used only by the mkstemp create loop,
moves along too.  No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell c6e86483b6 vfs: move the link family into vfs/link.c
Relocate do_link/do_link_at/do_link_atfd out of syscall.c into
vfs/link.c as the vfs_* names, declared in vfs/vfs.h.  The
HAVE_LINK/HAVE_LINKAT guards travel verbatim.  No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell e76b927378 vfs: move the symlink/readlink family into vfs/symlink.c
Relocate do_symlink/do_symlink_at/do_symlink_atfd and do_readlink/
do_readlink_atfd out of syscall.c into vfs/symlink.c as the vfs_* names.
The fake-super (NO_SYMLINK_*XATTRS) placeholder handling travels
verbatim.  vfs_readlink stays a function only in fake-super builds and a
macro -> readlink() otherwise (the rsync.h macro is renamed to match);
its vfs.h declaration is guarded accordingly.  No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell 07dd81b17b vfs: move the chmod family into vfs/chmod.c
Relocate do_chmod/do_chmod_at/do_chmod_atfd (and the leaf-safe
do_fchmodat_nofollow helper) out of syscall.c into vfs/chmod.c as the
vfs_* names, declared in vfs/vfs.h.  The HAVE_CHMOD / HAVE_LCHMOD /
HAVE_SETATTRLIST / SYS_fchmodat2 platform guards travel verbatim.
Function bodies unchanged; no behavior change.

(Portability-sensitive family; wants a fleettest with the rest of the
#ifdef-heavy moves.)
2026-08-13 10:20:12 +10:00
Andrew Tridgell e84b935d3b vfs: move the open family into vfs/open.c
Relocate do_open/do_open_at/do_open_atfd/do_open_nofollow/
do_open_checklinks out of syscall.c into vfs/open.c as the vfs_* names,
declared in vfs/vfs.h.  Function bodies unchanged; no behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell 7f03474b2b vfs: move the unlink/rmdir family into vfs/unlink.c
Relocate do_unlink/do_unlink_at/do_unlink_atfd and do_rmdir/do_rmdir_at
out of syscall.c into vfs/unlink.c as the vfs_* names, declared in
vfs/vfs.h.  Function bodies unchanged; no behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell 02ea67ab6c vfs: move the rename family into vfs/rename.c
Relocate do_rename / do_rename_at / do_rename_atfd out of syscall.c into
vfs/rename.c as vfs_rename / vfs_rename_at / vfs_rename_atfd, declared in
vfs/vfs.h.  Function bodies unchanged.

Also centralize the option-global externs (dry_run, am_root, am_sender,
inplace, preserve_*, open_noatime, copy_*, insecure_links, module_id, …)
in vfs/vfs_internal.h, replacing syscall.c's local extern block, so each
relocated family picks them up from one place.  No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell ad5c09ad9e vfs: move the stat family into vfs/stat.c
Relocate the stat/lstat/fstat wrappers out of syscall.c into vfs/stat.c
with the vfs_* names:

  do_stat       -> vfs_stat        do_stat_at   -> vfs_stat_at
  do_lstat      -> vfs_lstat       do_lstat_at  -> vfs_lstat_at
  do_fstat      -> vfs_fstat       do_stat_atfd -> vfs_stat_atfd
                                   do_lstat_atfd-> vfs_lstat_atfd

do_xstat_at stays a file-local helper.  The x_stat/x_lstat/x_fstat
fallback macros in rsync.h now expand to the vfs_* names.  First Phase-3
family move, so the shared RETURN_ERROR_IF* dry-run/read-only guard
macros (and the read_only/list_only externs they expand to) move from
syscall.c into vfs/vfs_internal.h where every vfs/ source can use them.
Function bodies unchanged; no behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell 48250edd8d vfs: fold operator_path_resolve into struct vfs
Move the operator-path resolver-mode flag into vfs.operator_path_resolve.
Its definition leaves vfs/owner_walk.c, the extern declarations (in
backup/generator/receiver/util1 and vfs_internal.h) are dropped, and the
uses across the do_*_at wrappers and the vfs/ internals are updated to
the struct field directly.  This completes moving the scattered VFS state
(dirfd cache, curr_dir, operator_path_resolve) into struct vfs.
No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell 540245fa61 vfs: fold curr_dir/curr_dir_len into struct vfs
Move the logical-cwd globals into vfs.curr_dir / vfs.curr_dir_len.  The
definition leaves syscall.c, the per-file `extern char curr_dir[]` /
`extern unsigned int curr_dir_len` declarations are dropped (the struct
is reached via the vfs.h `extern struct vfs vfs`), and the uses across
exclude/flist/log/main/util1 and vfs/secure_open are updated directly --
no compatibility alias macros.  change_dir() writes vfs.curr_dir.  The
separate curr_dir_depth global is untouched.  No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell a76ef875aa vfs: fold the dirfd cache statics into struct vfs
Replace the file-local dpc_* statics in vfs/dircache.c (anchor, base,
fd[], name[][], depth) with the vfs.dpc fields already declared in
struct vfs and initialized by the designated initializer in vfs/vfs.c.
Pure encapsulation: the cache logic is unchanged, DPC_MAXDEPTH becomes
the shared VFS_DPC_MAXDEPTH, and vfs_dircache_reset() now resets vfs.dpc
(consistent with vfs_init()).  No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell db6c5ac6f1 vfs: tidy comments and drop unused externs after the core move
Post-relocation cleanup (no behavior change):
 - fix a doubled-prefix typo "vfs_vfs_owner_walk_parent" in the
   vfs/owner_walk.c header comment
 - vfs/vfs.c comment now points at vfs/dircache.c (not syscall.c) for the
   live dpc_* cache
 - drop unused extern decls left from assembling the moved files
   (am_sender/insecure_links in owner_walk.c, curr_dir/curr_dir_len in
   dircache.c)
2026-08-13 10:20:12 +10:00
Andrew Tridgell a5f52ee62e vfs: move the held-dirfd cache into vfs/dircache.c
Relocate the persistent ancestor-dirfd cache out of syscall.c into
vfs/dircache.c with the vfs_* public names:

  open_dir_secure    -> vfs_opendir
  get_dir_fd         -> vfs_get_dirfd
  held_dir_path_fd   -> vfs_path_dirfd
  held_dfd_for       -> vfs_cached_dirfd
  reset_dir_fd_cache -> vfs_dircache_reset

The dpc_* cache statics and dpc_dir_fd stay file-local (they fold into
the vfs struct in a later commit).  Function bodies are unchanged; the
callers in generator/receiver/sender/delete/util1/rsync are updated and
the five entry points declared in vfs/vfs.h.  This completes moving the
security core (resolver, owner-walk, dirfd cache) out of syscall.c.
No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell b5d30f926b vfs: move the operator-path ownership walk into vfs/owner_walk.c
Relocate the operator-supplied-path resolver out of syscall.c into
vfs/owner_walk.c with the vfs_* public names:

  open_no_attacker_symlinks -> vfs_open_owner_walk
  owner_walk_parent         -> vfs_owner_walk_parent

The static helpers ona_open and abspath_step move with them, as does the
operator_path_resolve flag definition (commit-8 will fold it into the vfs
struct).  Function bodies are unchanged; the call sites across the daemon
and option-parsing files are updated and the two entry points declared in
vfs/vfs.h.

With the owner-walk gone, syscall.c no longer references am_daemon, so
the now-dead `extern int am_daemon` declarations (the do_*_at wrappers
delegate to vfs_relpath_active) are dropped.  No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell e1b4784a08 vfs: move the secure path resolver into vfs/secure_open.c
Relocate the race-safe resolver and its policy gates out of syscall.c
into vfs/secure_open.c, and give them the vfs_* public names that the
mainline code will use going forward:

  secure_relative_open    -> vfs_resolve_open
  secure_relative_open_at -> vfs_resolve_open_at
  secure_relpath_active   -> vfs_relpath_active
  symlink_optout_allowed  -> vfs_symlink_optout_allowed

secure_walk_at stays file-local.  The function bodies are unchanged; the
call sites across receiver/sender/generator/flist/util1/clientserver/
main/options and the test harnesses are updated to the new names, and
the four entry points are declared in vfs/vfs.h.

The resolver's only consumer of am_chrooted in syscall.c left with it,
so the now-dead `am_chrooted` is dropped from the do_*_at wrappers'
local externs (and the file-scope extern), which is otherwise unused.
No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell 366bbf68f0 vfs: move the dirstack path-walk primitives into vfs/dirstack.c
First step of relocating the security core.  The component-walk dirfd
stack (struct dirstack + the ds_* helpers) and the module-confinement
helpers (path_has_dotdot_component, abspath_excluded_by_module,
open_anchor_dirfd) move verbatim out of syscall.c into vfs/dirstack.c.
The secure resolver (still in syscall.c) and the held-dirfd cache reach
them through a new private header vfs/vfs_internal.h.

The functions are byte-identical to before; only their linkage changes
(the ones syscall.c still calls become non-static; ds_path_push/
ds_path_pop/ds_push stay file-local).  struct dirstack and
SECURE_OPEN_MAXSYMLINKS now live in vfs_internal.h so both sides see one
definition.  vfs_internal.h also centralizes the option/daemon externs
the VFS internals read.  No behavior change.
2026-08-13 10:20:12 +10:00
Andrew Tridgell 1e4a52e6b4 vfs: scaffold the virtual-filesystem layer
Introduce a vfs/ subtree that will house rsync's filesystem-handling code
(the do_* syscall wrappers, the race-safe path resolver, the held-dirfd
cache, the operator-path ownership walk and daemon module confinement),
separating those security-critical details from the protocol/transfer
logic.  This first commit only stands up the layer; no code is moved yet,
so behavior is identical.

 - vfs/vfs.h: public interface, included by rsync.h just after proto.h.
   Declares "struct vfs" -- the single global that will hold the state
   currently scattered across syscall.c statics (the dirfd cache, curr_dir,
   operator_path_resolve) and the clientserver.c module_* globals -- plus
   vfs_init().
 - vfs/vfs.c: defines the global instance with a designated initializer so
   the cache and module snapshot are safe by construction (a plain
   definition would zero base/module_dirfd, making fd 0 look valid); the
   t_*_secure harnesses never run main(), so this must not rely on
   vfs_init().  vfs_init() resets between transfers (inert for now).
 - Build: bundle the VFS into a static libvfs.a linked last on rsync and
   every test harness, so later commits can move code out of syscall.o
   without breaking a harness link (the linker pulls only what it needs).
   AC_CHECK_TOOL(AR)/AC_PROG_RANLIB added for portable archiving; a
   vfs/dummy config-file output creates vfs/ in VPATH builds.
 - main.c calls vfs_init() early (establishes the call site).
2026-08-13 10:20:12 +10:00
Andrew Tridgell 471e17dc0d Preparing for release of 3.5.0 [buildall] 2026-08-13 10:05:47 +10:00
Andrew Tridgell 7a36355a8c NEWS update for 3.5.0
fixed some typos and add in old 3.4.4 release info
2026-08-13 09:50:33 +10:00
Andrew Tridgell 40c93b173e update web pages and news for 3.5.0
ready for security release
2026-08-13 09:16:21 +10:00
Andrew Tridgell 6dfa73fc90 version.h: bump to 3.5.0 for the release
Drops the "dev" suffix on RSYNC_VERSION ahead of the
2026-08-13 00:00 UTC public release.
2026-08-07 15:25:11 +10:00
Andrew Tridgell d0ededae4e NEWS: finalise the 3.5.0 release entry
Date the release and bring the security section up to the full set: it
described 20 CVEs, and the release fixes 33.

The thirteen later items are added in three groups -- the peer-triggerable
memory-corruption findings from the daemon-protocol fuzzing pass, the daemon
availability and access-control issues, and the two client-side ones.

Several changes were previously described here as carrying no CVE and now do,
so those claims are removed rather than left to contradict the advisories:
rsync-ssl's unverified TLS is CVE-2026-70454, the non-positive MSG_IO_TIMEOUT
is part of CVE-2026-70462, and the early-protocol argument-count bound is part
of CVE-2026-70464.  What is left under "no CVE assigned" is only the proxy
header bounds and the xattr expansion cap.
2026-08-07 15:25:11 +10:00
Andrew Tridgell 46ad34f28d runtests: honour the built tree's backport skip list
A stable-backport branch runs a newer suite than its own code.  fleettest
already reads testsuite/skiplist/backport.txt from the tree being built and
excludes those tests; runtests.py did not, so running the suite directly --
which is what the backport branches' CI job does -- tried to run tests that
base cannot support.

Read the same file from tooldir and drop its names from both the run and the
expected-skip set: an excluded test never runs, so leaving it in the expected
set would make the oracle demand a skip that cannot happen.  A stale name is
an error rather than a silent no-op.
2026-08-06 14:28:03 +10:00
Andrew Tridgell 3d10e84266 docs: distinguish accepted breakage from absent features in backport.txt
Also note that backport CI, once it exists, has to consume these lists the
same way fleettest does -- read backport.txt from the branch being built and
pass it as RSYNC_EXCLUDE -- or it will fail on every entry and get switched
off.
2026-08-05 15:12:44 +10:00
Andrew Tridgell 2d1a91eb0f fleettest: drop backport-excluded tests from the expected-skip set too
A test named in a backport's backport.txt never runs, so it cannot skip either.
If the suite's expected-skip list also names it -- the two --compress-threads
tests are declared as expected skips because they need --use-tcp -- the oracle
waits for a skip that can no longer happen and every pipe cell reports a skip
mismatch.

Emit a '-name' removal for those, as the per-target expect_skip_omit already
does.  Only for names the spec actually contains: runtests rejects a '-name'
that removes a name nothing added, and most of a backport's exclusions (a test
for a feature it lacks) are not expected skips at all.  Deciding that needs the
@FILE references expanded locally, which is what _expand_spec_names does.

v3.4.1 with this: 5/5 cells OK on ubuntu-2404, from 3 OK / 36 not OK across the
fleet before the mechanism existed.
2026-08-05 13:38:47 +10:00
Andrew Tridgell 5cd4c682c8 fleettest: let a backport branch declare tests it cannot run
Running the 3.5.0 suite against an older branch (--repo BACKPORT
--testsuite-repo .) reports a wall of failures that are not regressions: tests
for fixes the branch does not carry, and tests whose unit-test helpers its
Makefile cannot build.  Both backport branches came back 3 OK / 36 not OK with
every distinct failure explained that way, which makes the run useless as an
oracle -- a real regression would not stand out.

A backport now declares those in its own testsuite/skiplist/backport.txt, read
from the tree being BUILT rather than the one providing the suite: only the
built tree knows what it lacks.  The names go to runtests.py as RSYNC_EXCLUDE
rather than as an expected-skip declaration, because some of them fail rather
than skip and an expected-skip list cannot describe a failure.

The overlay that puts a newer testsuite/ onto an older tree is a merge with no
delete, so a file that exists only on the backport survives it.  skiplist-spec
exempts the name from its every-list-must-be-referenced rule, since nothing
references this one by design.
2026-08-05 13:09:20 +10:00
Andrew Tridgell e8c79d2d62 testsuite: cover the empty-dir_flist parent_ndx wild-pointer read
Under inc_recurse the first flist (ndx_start == 1) has no parent entry of its
own, so recv_file_list() trusts the peer's "." entry to be the transfer root and
leaves parent_ndx at the flist_new() default of 0 -- dir_flist->files[0].  Only
S_ISDIR entries are appended to dir_flist, so a peer that sends "." with a
NON-directory mode keeps dir_flist->used at 0 while the basename strcmp still
passes: parent_ndx stays 0 and the consumers index a never-written slot.

Drives a real daemon with the pure-Python sender: an inc_recurse push whose only
flist is a regular file "." plus a regular file "a" (no directory anywhere, and
"." sorts lowest; "a" keeps file_total != 1 so the receiver doesn't divert into
recv_additional_file_list).  The file list alone is what does it -- the
generator crashes in generate_files() before any transfer phase -- reproduced on
released 3.2.7, 3.4.0 and 3.4.1.

The oracle needs both halves: a positive control that the daemon logged
"receiving file list", and the condition-specific refusal.  Accepting any
"rsync error:" line is not enough -- with "." sent as a valid directory and a
bogus file index, that form passes on "File-list index 1000000 not in 0 - 2"
without the crafted transfer root ever reaching the parser.

A fixed daemon has already refused the list and exited by the time the ndx-0
token is sent, so that send and the drain can hit a closed socket; Linux and
FreeBSD swallow it, Solaris, the other BSDs, macOS x86 and Cygwin raise
EPIPE/ECONNRESET.  Treat it as an expected outcome, not a result.

The header records what this does not prove: it gates the attack shape rather
than the parent_ndx clause (only the parse-time transfer-root check fires on a
current build), it does not exercise the receiver-side consumer, and the
dereferenced slot is not guaranteed NULL since dir_flist->files[] comes from
realloc(), not calloc().
2026-08-03 21:03:26 +10:00
Andrew Tridgell 9e40ef0eaf NEWS.md: record the safe_arg() uninitialized-byte leak
The fix shipped in the test10 snapshot but was never written up: safe_arg()'s
filename-mode buffer sizing disagreed with the writer, leaving an uninitialized
heap byte in the argument handed to the remote shell when --protect-args is off.
2026-08-03 21:03:26 +10:00
Andrew Tridgell f50d53d056 testsuite: judge the handshake deadline on the daemon's own clock
The daemon sets its deadline with time(NULL) (set_daemon_handshake_timeout,
io.c), and this test measured the elapsed time with CLOCK_MONOTONIC.  Those
agree on a quiet machine and diverge on a stalled one: a virtualised guest
resyncs its wall clock after the host deschedules it, while monotonic keeps
its own count.  The daemon then closes exactly when it meant to and the test
reports it closed early.

That is what a NetBSD CI run showed -- "closed after 39.55s, before the
expected timeout window (58.75s)" -- and it is the same shape as the macOS
failure that turned out to be the machine sleeping mid-test.

Measure the bound on the clock the daemon decides with.  Monotonic still
drives the poll budget, where the job is only "do not hang forever".

On its own that would trade a false failure for a false pass, which is worse:
a refusal or a crash arriving just as the guest's wall clock caught up would
read as a clean timeout, and no diagnostic would fire because the test would
be green.  So when the two clocks disagree -- wall says on time, monotonic
says early -- neither settles it, and the daemon has to have recorded its own
deadline firing.  The offset of its log is taken before each observation, so a
timeout it logged earlier cannot vouch for this one.

Failures now carry both clocks and that window of the daemon's log, bounded
and with control bytes escaped.  The clock note states the discrepancy without
concluding from it: a stalled host produces it, but so does an NTP step, and
either can accompany a real failure.
2026-08-03 14:49:48 +10:00
Andrew Tridgell 11e43daf90 socket: don't let open_socket_out() hang in connect()
The same kernel-side missed wakeup on the other side of the connection: a
blocking connect() can sleep forever on a connection that is already
established, with the 4-tuple ESTABLISHED at both ends and the listener's
greeting queued unread. Without --contimeout nothing breaks it.

Wait for the connect with poll() in slices rather than blocking in the
kernel, re-checking the socket on each pass, and take the result from
SO_ERROR. A finished slice is not a failure -- looping is what re-examines
the socket and recovers a missed wakeup.

--contimeout is unchanged: the alarm still fires and the caller still
reports RERR_CONTIMEOUT. The per-address errno is now stashed before
close()/alarm() can overwrite it.

Measured the same way, against a real loopback daemon: 20 hangs in 48,000
connects before, 0 in 48,000 after, with equal wall clock. This is the half
of the OpenBSD flakiness that the socketpair_tcp() fix does not cover: the
--use-tcp pass talks to a real rsyncd over a port, so it hangs here rather
than in accept().
2026-08-03 13:02:33 +10:00
Andrew Tridgell ea5a3a277f socket: don't let socketpair_tcp() hang in accept()
On OpenBSD a blocking accept() can sleep forever on a connection the kernel
has already completed: the 4-tuple is ESTABLISHED at both ends, the
connection is queued on the listener, and the accept()ing process is still
asleep in netacc. Nothing bounds that wait, so rsync hangs for good.

Poll the listener instead, with a non-blocking accept(), so a missed wakeup
costs another pass rather than the process. The accepted fd is put back
into blocking mode explicitly because BSD accept() gives it the listener's
non-blocking flag. A time(NULL) deadline bounds the whole wait the way
io.c bounds its own, rather than counting passes -- a signal on every pass
must not extend it and a poll() that returns at once must not consume it.
A listener that reports ready without yielding a connection (the peer can
reset first) pauses rather than spinning.

Measured on an OpenBSD 7.8 VM, driving the real binary through
RSYNC_CONNECT_PROG with 8 concurrent workers, alternating stock/patched
rounds: 111 hangs in 120,000 invocations before, 0 in 120,000 after, with
no change in throughput.

Every daemon test reaches socketpair_tcp() through RSYNC_CONNECT_PROG in
the default transport, so the hang landed on whichever daemon test happened
to be connecting. See dev-notes/openbsd-socketpair-accept-wedge.txt.
2026-08-03 13:02:33 +10:00
Andrew Tridgell d4d183c58c NEWS: note the AVX2 rolling-checksum over-read 2026-08-03 08:48:33 +10:00
Andrew Tridgell 9a382baddb fleettest: build one target with the SIMD/asm optimizations
Nothing in CI or the fleet has ever set --enable-roll-simd, --enable-roll-asm
or --enable-md5-asm, which is why the over-read above sat behind a "fixed"
label for two months, and why the fix applied for it went to the wrong
assembly file.

mac-x86-asm is the same host and OS as mac-x86 with all three on.  Mach-O is
the interesting part -- both problems reported against these flags were
macOS-x86-64 -- and it is the only machine in the fleet that can build the
x86-64 assembly at all.

It needs MacPorts clang 19 through CC/CXX, because Apple clang 10 (the ceiling
on macOS 10.13) rejects configure's target("default") multiversioning probe.
mac-x86 keeps the stock Apple compiler, which is what caught #161, so the two
cover different ground rather than one replacing the other.

simd-checksum is a macOS-wide expected skip, since simdtest is only built when
SIMD is enabled; this target subtracts it, because running it is the point.

Also corrects mac-x86's comment, which claimed the probe "cannot compile here
with any clang".  It is a compiler-version limit: clang 19 on that same box
compiles, links and runs it.
2026-08-03 08:48:33 +10:00
Andrew Tridgell 63fcfa399e testsuite: run the SIMD checksums against an unreadable page
simdtest allocated 64 spare bytes so it could test an unaligned buffer, which
is exactly the slack that hid a 64-byte over-read in the AVX2 assembly for as
long as it existed.  Add a pass that places the buffer flush against a
PROT_NONE page, so a read past the end faults in the test rather than in
somebody's transfer.

Every length from 128 to 4096, so each remainder mod 64 and both alignments
are covered, and all four implementations are checked -- the assembly was the
one at fault here, but the intrinsic paths preload too.

It fails closed.  A guard this test cannot set up means it is not testing what
the caller thinks, so a failed sysconf/mmap/mprotect is a failure rather than a
pass that looks identical to a real one.  And because the dispatcher falls back
on a CPU without AVX2 -- where the guard loop proves nothing about the code
under test -- it says which of the two happened rather than letting a fallback
run read as coverage.

Without the fix this segfaults; the suite's simd-checksum test reports the
non-zero exit.
2026-08-03 08:48:33 +10:00
Andrew Tridgell 2979d8eddc simd: stop the AVX2 rolling checksum reading past its buffer
The loop is software-pipelined: each iteration folds in the 64 bytes it
preloaded last time and preloads the next 64.  Nothing stopped the final
iteration doing that preload, so it always read the 64 bytes after the region
it was asked to checksum.

Not an edge case.  The assembly processes len&~63 and leaves the remainder to
the caller, so the remainder is by construction under 64 bytes and the preload
passed buf+len on every call, by 64 minus the remainder.

It normally landed in slack inside the map_ptr() window and nothing noticed.
Where the buffer ended near an unmapped page it was a SIGSEGV in the middle of
a transfer -- reported on macOS x86-64 by Roland Kletzing, whose `partial` run
died with "connection unexpectedly closed" because the generator had crashed.
A guard page reproduces it on Linux too, so it was latent there, not absent.

Run the pipelined loop one block short and finish the last block in .last,
which does the same arithmetic without the preload.  No per-iteration cost, and
checksums are bit-identical -- simdtest compares every implementation against
the C reference.

The earlier fix for that report, "lib: use .balign in md5 x86-64 asm", was to
the md5 assembly.  It addressed the linker alignment warning that appeared
alongside, not this.
2026-08-03 08:48:33 +10:00
Andrew Tridgell bb6329bc8c NEWS: note the --link-dest hard-link fallback 2026-08-03 07:10:00 +10:00
Andrew Tridgell d12bdb1579 testsuite: cover the --link-dest hard-link refusal, and ask the filesystem
An LD_PRELOAD hook refuses linkat() for a symlink source only, so the arm is
reachable on a filesystem that hard-links symlinks perfectly well.  Three
controls keep it from proving less than it looks:

 - the regular file in the same transfer must still be hard-linked, or "it fell
   back" would also be satisfied by --link-dest having been abandoned;
 - the itemised run must emit exactly one "cL... sym -> some-target" line.  A
   plain -a run cannot see a duplicated itemisation, which is how that defect
   reached an HFS+ target before this was added;
 - EPERM and ENOSYS must fall back too, since errno does not separate "cannot"
   from "may not".

itemize picked its expected change-type letter from the build capability, which
is the wrong question -- the link happens on whichever filesystem holds the test
data.  Ask that one too, and drop the XFAIL the old mismatch needed.

The hook is Linux-only, so the test joins the macOS and Cygwin skip lists,
which are required to be sorted.
2026-08-03 07:10:00 +10:00
Andrew Tridgell d09edb85e6 generator: fall back to a copy when the destination cannot hard-link
CAN_HARDLINK_SYMLINK and CAN_HARDLINK_SPECIAL are decided by configure running
linkat() on whatever filesystem the build tree happened to sit on.  The
destination is free to disagree, and one host can hold both answers: macOS
builds on APFS, which can hard-link a symlink, and backs up to HFS+, which
returns ENOTSUP.

A build that said yes had no fallback left.  try_dests_non() reported the
refusal as a transfer error and returned a matched basis, so the caller created
the entry anyway -- correctly -- and the run still exited 23.  Every
neighbouring case copes: a regular file whose link() fails goes to try_a_copy,
and a build compiled without either macro resorts to --copy-dest behaviour.
This was the same situation, discovered a little later, and the only one
treated as fatal.

Take the existing fallback on any refusal.  Singling out the "cannot" errnos is
not possible: link(2) documents EPERM both for a filesystem with no hard-link
support and for an ordinary permission refusal, and FUSE reports ENOSYS for the
same thing.  It is also what the regular-file path next door has always done
(try_dests_reg -> hard_link_one -> try_a_copy), and consistency between the two
was the point.  Where the errno does matter the surrounding transfer says so
anyway: ENOSPC, EDQUOT and EROFS fail the creation independently, EMLINK and
EXDEV mean the link was never possible.  EIO alone goes unremarked; reporting
it would put a line into --link-dest's itemised output, so it is left out on
purpose.

Returning -3 rather than -2 keeps the caller out of the "already up to date,
skip it" arm, which under --link-dest would drop the entry entirely.  Both
callers give -3 the treatment the compile-time fallback already gets -- clearing
itemizing and code -- because try_dests_non() has itemised the match itself and
would otherwise report the entry twice.

The fallback is silent, matching a build that cannot link these at compile
time; documented under --link-dest instead.
2026-08-03 07:10:00 +10:00
Andrew Tridgell 36be3b4e70 NEWS: note the merge-file confinement half of the filter fix 2026-08-03 05:35:03 +10:00
Andrew Tridgell 1bea181096 testsuite: an rrsync restricted dir must bound merge files
Uses the exclude-only merge form, which leaves no diagnostic to assert on: the
escape shows up as a file silently missing from the transfer, so the test reads
the oracle the same way an attacker would.  Pull mode, so no --delete is
involved.  Each case requires the transfer to have succeeded as well, since
refusing outright would hand the peer a denial of service.

A second escape reaches the source through a symlink, which is what makes
rsync's tracked cwd and the real one disagree -- the shape that catches a
lexical seed.  That one drives --confine-root directly: rrsync rejects the
argument spellings that would carry it, so routing it through the wrapper would
pass either way and prove nothing.

Both controls repeat their escape with an in-tree merge target and require it
to be read AND obeyed, since "the transfer failed" and "every merge file is
refused" would otherwise satisfy the escape assertions on their own.
2026-08-03 05:35:03 +10:00
Andrew Tridgell 314abcc437 rrsync: confine the server's path resolution to the restricted dir
Filter rules arrive over the protocol, long after the wrapper has exec'd rsync,
so no argv-level check can see them.  A client can name a merge file outside
the restricted dir in a dir-merge rule and have the server read it in as filter
rules; on a pull that needs neither --delete nor any verbosity.  Pass
--confine-root so the server bounds the open itself, which is the only end that
can.

Both directions: a dir-merge is read by whichever side its rule applies to, so
unlike --drop-D this is not receiver-only.  Skipped for a "/" restricted dir,
where there is nothing to confine.
2026-08-03 05:35:03 +10:00
Andrew Tridgell 3113011218 rsync: add --confine-root, bounding operator path resolution
The ownership walk that resolves operator-supplied paths asks who planted a
symlink, not where the path came out, so a symlink owned by uid 0 or the euid
is followed wherever it points.  A daemon already narrows that with the served
module root; nothing else has a root to narrow it with.

That leaves a wrapper serving a restricted directory over a remote shell with
no way to bound the resolution.  rrsync can vet the argv it is handed, but
filter rules travel over the protocol instead: a dir-merge rule can name a
merge file outside the restricted dir and the server reads it in as rules.
Redacting the resulting diagnostics does not close it, because an exclude-only
merge produces none -- every line becomes a pattern, so nothing fails to parse,
and the client reads the file's contents off which of its own names went
missing from the file list.

--confine-root gives that wrapper the root the daemon has.  The existing module
check becomes a root check that takes its root from module_dir when we are a
daemon and from the option otherwise, so daemon behaviour is unchanged; a
daemon ignores the option outright, since it arrives in a peer-supplied argv
and could only widen the module.

The tracker is seeded from getcwd() rather than curr_dir, which is only the
lexical name change_dir() was given: descend into a source argument through a
trusted symlink and the two sit at different depths, so a ".." that really
escapes looks like it landed inside.  When the cwd cannot be read there is
nothing to measure against and the open is refused -- an empty tracker does not
deny by itself, because a leading ".." pops nothing from it and an empty path
reads as an ancestor of the root.

An fd pin (/proc/self/fd/N, which rrsync uses so no later symlink can redirect
a validated option path) is spelled outside the root by construction, so the
walk transits the pin namespace and the pin is judged by what it points at.
Only a bare ".../fd/<digits>" is resolved that way, and one that will not
readlink to an absolute path is refused; rrsync's ".../fd/N/<leaf>" spelling
resolves through the magic link and has its remaining components checked
normally.

--insecure-links is refused alongside it: that opt-out returns the legacy open
before the walk that enforces the root runs, so the pair would have quietly
meant no confinement at all.
2026-08-03 05:35:03 +10:00
Andrew Tridgell 0f6f35e522 exclude: give the ":e" self-exclude rule its merge rule's provenance
The exclude-self rule that a ":e" merge synthesizes is built by hand with
new0(), so it inherited no flags.  While the merge file was still being
parsed the global parse state masked that, but once parsing finished the
stored rule looked argument-origin, and report_filter_result() printed its
pattern -- a merge file's own text -- verbatim:

    [sender] hiding file PAT-x9 because of pattern PAT-[x]9 [per-dir ...]

Plain -vv reaches this on a stock client; no --debug is involved.  That is
the fifth site of this shape, and the first to get there by constructing a
rule rather than by printing one, so the redaction helper could not catch it.

Also fix the location a per-directory merge reports.  Its fname points into
dirbuf, which is cut back to the directory before the name was saved, so the
error said "<rule from .../src/ line 1>" instead of naming .rsync-filter --
no leak, but it breaks the "redact what, keep where" bargain the rest of this
work depends on.  Save the name before the truncation.

The rrsync test's claim to close "the rest of the FILTER trace family" was
too strong and is corrected: options.c maps verbosity onto the debug flags,
so -vvv still raises a restricted server to FILTER2 and its trace metadata
comes back.  Rule text stays redacted at every verbosity, which is the
property that matters; -vvv is added to the unaffected-transfer cases.
2026-08-02 20:32:33 +10:00
Andrew Tridgell 631f9bd464 rrsync: refuse a peer-selected --debug
The --debug=FILTER traces print rule text and merge-file names that came
out of a file's contents -- and a word-split per-dir merge (":w- FILE")
turns every word of a file into a merge-file name, so the trace echoes
what the syntax errors no longer do, with nothing failing to parse.

Redacting every trace would mean carrying provenance on each rule, which
a deferred ":" merge does not currently keep.  For a restricted account
the cheaper answer is to deny the peer the switch: server_options() only
ever forwards --info, so no stock client sends --debug to a server and
the only way it arrives is a deliberate -M--debug=.  An operator
debugging their own server is unaffected.

Disabled rather than deleted from the table, because that table is
generated by the cull-options script and a regeneration would put the
line back; the test would then catch it.
2026-08-02 20:32:33 +10:00
Andrew Tridgell bb806288b7 exclude: don't hand a merge file's contents back to the peer
A filter rule that fails to parse was printed back verbatim.  When the
rule came from a file rather than an argument, that text is file
CONTENT, and the peer picks which file gets merged: a per-directory
merge rule travels over the protocol, so no argument of ours ever names
it and nothing a wrapper can see mentions it either.  Any line that is
not valid filter syntax therefore came straight back to the peer -- a
read-any-line oracle over an rrsync restricted account or a daemon
module, neither of which confines the merge open.

The syntax errors turned out to be the smaller half.  The MATCH trace
names the pattern that acted, and report_filter_result() logs at level 1
for a sender or generator, so plain -vv -- no --debug, nothing a stock
client cannot send -- returns a server-side merge file's rules:

  [generator] protecting file X because of pattern <the file's text>

So provenance is carried on the rule itself (FILTRULE_FROM_FILE), not
just in the parser: a deferred ":" merge is processed long after the
file that named it was read, and its own name is file content too.
TEXT_FROM_FILE() consults the parse-time context and the rule, so both
the immediate and the deferred paths redact.

Rather than test the provenance at each message -- which is how the last
few of these were found, one at a time, after the ones before them were
fixed -- every string that is or is built from a rule's own text goes
through rule_text().  It returns the text for an argument-supplied rule
and a description of where it came from otherwise, so a message added
later cannot reintroduce the leak by forgetting to check, and there is
one place to audit.  rule_detail() does the same for the extra detail a
message adds ABOUT the text: a character of it, an offset into it, the
[not found] bit.

Thirteen sites now route through them: the syntax errors; the modifier
character (one byte of the file, a slower oracle but still one); the
failed-open and merge-depth messages, whose pathname is file content
whenever a rule named it -- and errno with them, since it answers "does
this path exist"; both over-long messages, the deferred one of which
needed no verbosity at all; both merge-name overflows; the long-named
directory error; the [not found] openability bit; the match trace; the
add_rule, parse_filter_file and daemon-hidden traces; and the per-dir
mergelist label, which had the name baked in.

rule_detail() covers more than it first looks: the trailing-whitespace
CAUTION is computed from the rule's last byte, and "hidden by daemon
filter" distinguishes a daemon-filter rejection from an ordinary open,
so both would answer questions about text the peer cannot see.

The regression proves the chokepoint rather than the sites: making
rule_text() return its input unconditionally fails the test.  It also
pins what must NOT change for the user's own rules -- the whitespace
warning still fires, and an over-long argument rule is still reported at
full length (the helper buffers at BIGPATHBUFLEN, as rprintf does, so
redaction does not quietly truncate what the user typed).

Bounded and left alone: the numeric rflags in the FILTER2 trace and the
in/exclude wording still describe a file-derived rule without quoting
it, and the daemon's own FLOG line records the name it filtered -- that
one goes to the operator's log, not the peer.

Rules given AS arguments are still echoed in full -- that text is the
user's own, and hiding it would only make ordinary typos harder to fix.
Where a rule did come from a file, the diagnostic names the file and
line instead, which is more useful anyway.

Two things the location itself needed: fname can point into
parse_merge_name()'s static buffer, which a merge rule inside the same
file overwrites while we are still reading it, so a rule after a nested
merge was blamed on the nested file -- keep our own copy.  And a CRLF
pair was counted as two line endings while word-split mode counted
tokens rather than lines, so the number pointed at nothing; consume the
LF of a CRLF (preserving the byte for the next rule if pushback ever
fails), and report word-split sources without a line number.

Not covered, deliberately: a rule's provenance is not serialized by
send_filter_list(), so it does not survive to the far side.  That is
right -- only the client sends that list, and the server already knows
the patterns the peer gave it.
2026-08-02 20:32:33 +10:00
Andrew Tridgell d1756203b9 testsuite: stop the test rsyncd stranding a connection child
A daemon test could leave an orphaned rsyncd squatting its port even when
every test PASSED, so nothing in the results pointed at it. On Cygwin the
orphan then wedged the whole fleet: it kept the ssh session from closing,
so fleettest's run_on() blocked until its 2400s timeout and unrelated
tests failed with 300s timeouts as collateral. One such wedge cost a fleet
run 21 minutes.

Cause: rsyncd forks a child per connection, but _stop_rsyncd only killed
the parent -- the one pid the Popen handle knows. A child still winding up
or down when the test ended survived, inherited the listening socket, and
was reparented to init. Cygwin turned that from untidy into unrecoverable:
its signals are cooperative, delivered by a helper thread inside the
target, so a process sitting in a Windows call ignores even SIGKILL. kill,
killpg and pkill all failed against it, which also defeated the orphan
reapers and fleettest --cleanup.

Snapshot the daemon's children before killing it (once the parent is gone
they are reparented and no longer identifiable as ours) and kill them too,
re-checking with _pid_is_rsync before each signal so a pid recycled in the
meantime is never signalled. Where signals cannot win, fall back to
terminating the winpid via taskkill; fleettest --cleanup gets the same
fallback, so it can no longer report SURVIVED and leave the port squatted.

_reap_group() reports success only once the daemon is confirmed gone
rather than when a signal was merely accepted -- on Cygwin a signal is
routinely accepted by a process that then ignores it -- and confirms with
a bounded poll, because SIGKILL is asynchronous and calling a
still-terminating process "alive" would make _probe_bindable() skip its
retry and fail a test for a port that was about to free itself.

_cleanup_rsyncd() keeps the port's pid record only while it still names a
live rsync. Keying that on the port being busy instead looks safer but is
worse: a port sits in TIME_WAIT after a passing test, so a record naming
an already-dead pid would be retained forever, and nothing clears such a
record -- yet no reaper can use it either, since they all reject it at the
_pid_is_rsync guard, leaving only the hazard that its pid is recycled onto
an unrelated rsync.

The daemon stays in the TEST's process group on purpose: runtests.py
killpg's that group on a per-test timeout, and that is what keeps a
timed-out test from stranding its daemon. An earlier version of this fix
gave the daemon its own group so one killpg would catch the children --
which silently broke that sweep, and a full Cygwin pass then stranded two
parent daemons when variety hit its timeout.

Two residual limitations are documented in the code rather than left to be
rediscovered: _kill_pid's check-then-signal is inherently a TOCTOU
(narrowed to microseconds, not closed; closing it needs pidfd or retained
Windows handles across seven platforms), and _stop_rsyncd cannot collect
children when the parent has already exited on its own, because the
parent-child link it relies on is gone by then.

Measured on a Cygwin VM, 4 proxy/daemon tests x 8 runs at -j4: before 5/8
runs left an orphan (one left two), after 0/10. All tests passed in every
run, before and after -- which is the point: the leak was invisible to the
suite. A test killed by the runner's timeout still leaves no daemon
behind.
2026-08-02 16:05:24 +10:00
Andrew Tridgell d95217fbbe fleettest: run only the daemon tests in the tcp pass
The tcp pass re-ran the whole suite over the same build the pipe pass had
just swept, but --use-tcp is observable through exactly one code path:
RSYNC_TEST_USE_TCP is read once (rsyncfns USE_TCP) and acted on once (in
start_test_daemon). A test that never reaches there cannot tell the two
passes apart, so 186 of the 340 tests were producing the same result
twice.

runtests.py --daemon-tests-only keeps the tests that can reach the daemon
transport, matched against the closure of every rsyncfns helper leading to
USE_TCP/start_rsyncd/claim_ports plus the modules that open a daemon
connection themselves. The token list is deliberately over-broad and an
unreadable test is kept, so the filter can only ever run too much; audited
against the tests it drops, none of which reach the transport (their
"daemon" hits are the unix username, a macOS ACL principal, mount --bind,
and docstrings declaring the test local-only). The dropped count is always
printed rather than left implicit.

The narrowing is only sound as the second half of a pipe+tcp pair, so it
is gated on the pipe pass having run: under --transport tcp that pass is
the only one there is, and narrowing it would drop the other 186 tests
from the run altogether. --full-tcp forces the full sweep either way.

Measured on the full suite: serial work 558s -> 367s.
2026-08-02 16:05:24 +10:00
Andrew Tridgell 653c4e8db9 testsuite: make --race-timeout actually control the race budget
The race tests are the suite's slowest by a wide margin -- a race test is
a negative oracle, so it passes by spending its entire budget. Most of
them wrote `max(RACE_TIMEOUT, 10.0)`, which ignored --race-timeout below
10s: the documented knob did nothing for 10 of the 16 tests.

Replace the floor idiom with race_budget(default), where the per-test
default applies only when the operator did not pass --race-timeout, and
runtests.py exports race_timeout only when the flag was actually given.
Defaults are unchanged (measured identical at 15.3s/10.3s/5.2s).

Validate the value rather than take it on trust. A race test loops
`while monotonic() < deadline`, so a zero, negative or NaN budget runs the
body zero times and the test reports PASS without ever racing, and an
infinite one runs until the unrelated per-test timeout; the old
max(..., 10.0) floor had made all of that unreachable, so removing the
floor had to come with rejecting the input. An unparsable value in the
environment counts as unset for the same reason -- falling back to the 5s
baseline while still counting as "set" would silently halve a 10s or 15s
oracle that nobody asked to shorten.

NB the *_test.py glob spans four committed symlinks (chown-fake,
devices-fake, exclude-lsh, xattrs-hlink); sed -i would replace each with a
copy of its target, so they are rewritten with --follow-symlinks semantics
and left as symlinks.
2026-08-02 16:05:24 +10:00
Andrew Tridgell 501165218c fleettest: add --keep-on-fail, and pass --timing to the targets
A fleet run costs a full configure+build on every machine, and the report
only names the tests that failed -- so seeing WHY one failed meant paying
for a second whole run, against a race test that may not fail the same
way twice.

--keep-on-fail saves the full build/test output of every target that came
back with anything unexpected, and keeps that target's remote run dir
(with the scratch trees the failing tests left). Clean targets are swept
as before.

--timing now also asks each target's runtests.py for its own per-test
table, so a slow cell can be attributed to actual tests rather than just
named as the hold-up.
2026-08-02 16:05:24 +10:00
Andrew Tridgell 6bad3be6fc runtests: report per-test wall-clock with --timing
The suite reported which tests ran, never how long any of them took, so
"the fleet is slow" could not be attributed to anything. Time each test
and, with --timing, print the slowest first.

The footer gives the two bounds that decide what to do about a slow run:
the serial sum (what one worker would take) and the floor set by the
longest single test, which no amount of -j can beat.
2026-08-02 16:05:24 +10:00
Andrew Tridgell 2a20d6ec0a testsuite: list daemon-handshake-timeout in the shared skip list
The test needs a real listening socket to stall, so it require_tcp()s and skips
on the default pipe transport -- like daemon-chroot-acl and the proxy tests
alongside it.  runtests.py compares the skip set against RSYNC_EXPECT_SKIPPED on
a FULL run, so without an entry every pipe-mode CI job reports an unexpected
skip and fails, while the tcp jobs pass.
2026-08-02 13:26:41 +10:00
Andrew Tridgell 4d7c243ca6 daemon: bound the pre-transfer handshake with a timeout
rsyncd.conf(5) says of "timeout": "Using this parameter you can ensure that
rsync won't wait on a dead client forever."  That did not hold before a module
was known.  set_io_timeout() ran at the very end of rsync_module(), so the
greeting, authentication and the whole argument list were read with no I/O
timeout at all -- a peer could stall at any of them and the child waited
indefinitely.  Measured: 20 connections sending "@RSYNCD: 31.0" with no newline
were all still alive well past timeout=5, and only went away when the client
hung up.

The consequence is worse than an idle process.  claim_connection() runs BEFORE
auth_server(), so naming a module is enough to take a slot: an attacker with no
credentials could occupy every "max connections" slot of an authenticated
module and hold them for as long as it kept the sockets open, with the
documented control unable to recover them.  It costs the attacker nothing --
five stalled children measured 0 CPU ticks over 5s -- so this is descriptor and
slot exhaustion, not load.

Bound the handshake at min(configured, 60s).  "timeout" is a Locals parameter,
so lp_timeout(-1) reads the global section -- the same -1 idiom start_daemon()
already uses for lp_reverse_lookup().

Both halves of that minimum matter.  "timeout" DEFAULTS TO 0, so honouring only
the configured value would leave the daemon most exposed to this -- one whose
administrator never set a timeout -- exactly as pinnable as before.  And capping
matters because an operator who sets "timeout = 86400" for slow links is asking
for patience during a TRANSFER, not for a stranger to hold a pre-auth slot for a
day.  The pre-module phase has no legitimate reason to take even a minute.

The bound is retired the moment the module is known, which is what lets the
configured value still govern the transfer.  That retirement is load-bearing:
the per-module test only ever LOWERS the timeout (`lp_timeout(module_id) <
io_timeout`), so leaving the handshake bound in place would silently clamp a
module that asked for more -- "timeout = 300" would get 60.  It is cleared
before that test runs, and only when io_timeout is still the value we armed,
since the client's own --timeout is parsed in between and must win on its own
terms.  Verified: with no global timeout and "timeout = 120" in the module, a
connection idles past 75s rather than being dropped at 60.

Applied only for a real socket daemon (am_daemon > 0): an rsh-run daemon has no
listener to exhaust.

Verified end to end with max connections = 2 and timeout = 5: with two stalled
unauthenticated connections holding both slots, a legitimate client is refused
during the timeout window and served once it elapses.  Before this change it was
refused both times.

Reported by Chamal De Silva.  Not a regression -- 3.2.7 behaves the same way.

An idle timeout alone is not enough, which the review of the first version of
this change made concrete: safe_read() consults it only when poll() TIMES OUT,
so a peer sending a byte more often than allowed_lull (timeout/2) is never
checked at all.  Measured: one byte every 20s held the handshake open for 182s
against a 60s bound, keeping its max-connections slot the whole time -- the
reported attack, merely with the attacker typing.

Non-positive configured values are treated as "use the built-in bound":
"timeout" is parsed with atoi(), so "timeout = -1" would otherwise reach
set_io_timeout() (which reads it as no timeout) and alarm() (which would take it
as a huge unsigned count), disabling the very bound it looks like it configures.

The client's own --timeout is no longer inferred by comparing values, which could
not distinguish it from an identical armed value: io_timeout is zeroed before
parse_arguments(), so anything non-zero afterwards came from the client.

So the bound is absolute and lives in the READ PATH, next to the idle timeout
it complements: safe_read() caps each poll() at whatever is left of it and
gives up when it expires, so it is re-checked on every iteration and a peer
that keeps typing cannot outrun it.

It is deliberately NOT alarm()/SIGALRM.  Three earlier attempts used one and
each regressed something: fork() clears pending alarms, so the "post-xfer exec"
parent -- which waits for the ENTIRE transfer -- kept the deadline and _exit()ed
mid-transfer, skipping the hook and releasing the max-connections fcntl lock
while the transfer child ran on; "pre-xfer exec" and the name converter are
operator scripts that may legitimately outlast any handshake bound; and the
cancellation sat inside an exec-environment compile guard, so a build without
setenv/putenv kept it armed through the transfer.  A deadline consulted only
where the daemon is already blocked reading a peer has none of those hazards.

It is also kept entirely separate from io_timeout, which is an idle timeout the
module or client may set.  Mixing them clamped a module asking for more than the
bound ("timeout = 300" became 60) and leaked the handshake value into the
transfer.  Verified: module 300 stays 300, and a client --timeout=7 still wins.

Armed for each peer-driven phase and cleared between them: at the start of the
handshake, tightened by the module's own timeout once the module is known and
its slot claimed, cleared across the hook/fork setup, re-armed before
"@RSYNCD: OK" so it spans BOTH read_args() calls including secluded args, and
cleared before the transfer.

That argument-read coverage is the part that matters most.  auth_server()
returns immediately when a module sets no "auth users", so on an ANONYMOUS
module nothing is authenticated: without a bound there, a peer could claim the
slot, take the OK, and trickle an unterminated argument line forever.  Measured:
still open after 150s before, closed at 60s after.
2026-08-02 13:26:41 +10:00
Andrew Tridgell cf15b1bb24 gitignore: cover the newer test helper binaries
.gitignore lists the older helpers (tls, getgroups, wildtest, trimslash,
t_unsafe, getfsdev) but not the ones the security work added, so a `git add -A`
in a built tree stages ~4 MB of ELF -- which is exactly how nine of them ended
up committed on this branch before being removed again.
2026-08-02 08:13:25 +10:00
Andrew Tridgell ba3e9d681b testsuite: don't report a bogus escape when the scratch path has a space
The three operator-path-traversal daemon tests failed with "escaped: a '..'
traversal reached the excluded subtree" when the build path contained a space.
That reads like a confinement failure and is not one.

rsyncd.conf's "exclude" is a SPACE-SEPARATED list of patterns, so
"exclude = /ws test/.../secret/" is two patterns, neither of which is the
directory meant to be protected.  Nothing was excluded, so the traversal
reached a subtree that was never actually off limits.

Confirmed by running the same case with a "filter" rule, which the parser
deliberately does not split at an internal space: it passes, so the traversal
protection itself holds.

Left on "exclude" rather than switched to "filter" -- these tests exist to
cover the exclude path -- and skipped with the reason when the scratch path
makes that config inexpressible.

Worth knowing outside the testsuite: an operator whose module paths contain a
space gets no warning that "exclude" silently matched nothing.
2026-08-02 08:13:25 +10:00
Andrew Tridgell 84832f0ff7 rsync-ssl, testsuite: quote paths interpolated into shell text
Third layer of the space-in-build-path work, and the first part that is not
test-only.

rsync-ssl expanded the helper program paths unquoted -- "exec
$RSYNC_SSL_OPENSSL s_client ...", likewise for gnutls and stunnel -- so an
openssl installed under a path containing a space is split and never runs.
That affects anyone with such a path, not just the testsuite.  Quoted; the
neighbouring $caopt/$certopt/... stay unquoted because they are option lists
that rely on word splitting.  Its own re-exec passes --rsh="$0 --HELPER",
which rsync then tokenises, so $0 is single-quoted for rsync's parser.

On the test side, the same shape in generated shell scripts: redirect targets
("printf ... > {capture}") and daemon hook commands, which rsync runs through a
shell, both interpolated a path with no quoting.

In a directory with a space: 235 pass, 18 fail, from 0 able to run.
Unchanged in a normal path: 257 passed, 0 failed.
2026-08-02 08:13:25 +10:00
Andrew Tridgell 0789709aeb testsuite: quote the rsync command everywhere a shell re-parses it
Second layer of the space-in-build-path work.  Quoting the Makefile got the
runner started; these are the places that then hand the binary's path to
something that splits on whitespace.

  - RSYNC_CONNECT_PROG is run by a shell.  This was the big one: an unquoted
    daemon command turned every daemon-mode test into
    "sh: 1: /path/to/ws: Permission denied".
  - RSYNC_RSH / --rsh is tokenised by rsync itself (do_cmd() in main.c, which
    honours ' and "), so support/lsh.sh needs quoting when srcdir has a space.
  - --rsync-path is a command line run by the REMOTE shell, so rsync passes it
    through unsplit and lsh.sh's eval re-parses it.
  - The generated rsync-shim scripts interpolate RSYNC into "#!/bin/sh\nexec
    ...", where it is shell syntax rather than an argv entry.

rsync_path_arg() and rsh_cmd() build those strings by splitting the command and
re-joining with shlex, so a plain path with a space comes back quoted while a
wrapper command ("valgrind ... /build/rsync") stays several words.

split_rsync_cmd() also has to cope with RSYNC once a test has appended options
to it -- chown-fake and friends do -- where the string is no longer a filename.
It now takes the longest leading run that names an existing file as the program
and splits only what follows.

In a directory with a space: 231 pass, 22 fail, from 0 able to run before the
first commit.  Unchanged in a normal path: 257 passed, 0 failed.
2026-08-02 08:13:25 +10:00
Andrew Tridgell b1d2c32b1d build/testsuite: survive a build path that contains a space
`make check` died immediately when the build directory had a space in it:

    ./runtests.py --rsync-bin=`pwd`/rsync -j 8
    rsync_bin /Volumes/Untitled is not a file

Reported by Roland Kletzing building in "/Volumes/Untitled 2"; it reproduces
anywhere, and is not macOS-specific.

Makefile.in interpolated an unquoted `pwd` into --rsync-bin at five sites, so
the shell word-split it.  Quote those, and --tooldir at the installcheck site,
which had the same bug and was not in the report.  Quote "$(srcdir)/runtests.py"
too: the script's own path word-splits just as readily.

That alone only gets as far as starting the runner.  rsync_argv() then did
shlex.split(RSYNC), which turns "/ws test/rsync" into two nonexistent programs.
RSYNC may legitimately be a wrapper command line ("valgrind ... /build/rsync"),
so it cannot simply stop splitting; split_rsync_cmd() checks whether the string
names an existing file first -- a path that exists is one word by definition --
and only falls back to shlex for a real command line.  Nine tests that called
shlex.split(RSYNC)/(RSYNC_PEER) directly go through it as well.

Deliberately a function called at use time rather than a pre-split constant:
chown-fake, devices-fake, chown, devices and partial_nowrite append
' --fake-super' or ' --super' to rsyncfns.RSYNC part-way through, and a cached
split hands back the pre-mutation command.  Caching it is what broke those two
tests while I was writing this.

The suite is still not space-clean -- in a directory with a space 157 pass and
97 fail, against 0 able to run before.  The rest is a separate problem: mostly
transfers whose --rsync-path is re-parsed by a remote shell, which needs
quoting at a different layer.  No change in a normal path: 257 passed, 0 failed.
2026-08-02 08:13:25 +10:00
Andrew Tridgell a0952930a1 build: require <poll.h>, not just poll()
io.c and socket.c include <poll.h> unconditionally, but configure only
required the function.  A system that exposes poll() through some other
header would pass configure and then fail to compile -- the AC_CHECK_HEADERS
result for poll.h was collected and never used.

Require the header too, with its own message.  Verified both ways: a normal
configure still succeeds, and forcing ac_cv_header_poll_h=no now stops with
"rsync requires <poll.h>" rather than failing later in the build.
2026-08-01 20:02:02 +10:00
Andrew Tridgell 8e0bd08a62 testsuite: CC is a command, not a filename
highfd-hang probes FD_SETSIZE by compiling a snippet, and passed $CC to
subprocess as a single argv[0].  CC='ccache gcc' then looks for a program
literally named "ccache gcc" and the test dies with FileNotFoundError
instead of probing -- and ccache is wired into PATH on the CI fleet, so
this was reachable rather than theoretical.

Split it with shlex, and treat an unusable CC as "cannot probe" (skip)
rather than an error: the fallback to cc/gcc already handles a missing CC,
and a broken one should behave the same way.
2026-08-01 20:02:02 +10:00
Andrew Tridgell d5cce08503 io: describe the timeout guards in terms of poll(), not select()
The MSG_IO_TIMEOUT cap and set_io_timeout()'s negative/overflow guards
were written when these loops used select(), and their comments explain
the danger as a tight select()-EINVAL spin on a negative tv_sec.

Under poll() the failure mode inverts: the timeout is a millisecond count
where a negative value means "wait forever", so a wrapped allowed_lull
hangs the process instead of spinning it.  The guards are still needed and
unchanged -- only their stated reason was wrong, and a rationale that no
longer matches the code is what gets a guard removed later.

poll_timeout_ms() clamps the value too, so the guards are now belt and
braces; noted so neither looks redundant on its own.
2026-08-01 20:02:02 +10:00
Stuart Inglis bf3a11cf24 io/socket: address review of the poll() conversion
Follow-up to the FD_SETSIZE fix, covering the points raised in review.

Negative/overflowing I/O timeouts.  set_io_timeout() could produce a negative
select_timeout (a peer-supplied MSG_IO_TIMEOUT value was applied unchecked),
and every wait now passes select_timeout * 1000 to poll(), where a negative
millisecond count means "wait forever" -- so a hostile or buggy peer could
stall the other side and bypass keepalives entirely.  select() used to reject
that with EINVAL, which kept the loop and check_timeout() running.  Clamp a
negative argument to 0, compute allowed_lull without overflowing near INT_MAX
(secs / 2 + secs % 2), ignore a non-positive MSG_IO_TIMEOUT value, and funnel
all three waits through poll_timeout_ms(), which keeps the count positive and
bounded.

The daemon accept loop had the same fd_set overflow.  start_accept_loop() still
stored listening sockets in an fd_set, so a daemon started with enough
descriptors already open got listener fds >= FD_SETSIZE and hit the same
undefined behaviour at startup -- verified: with the old code a transfer
through such a daemon yields nothing, with this change it succeeds.  Converted
it to poll() as well.

Readiness testing.  Treating any non-zero revents as ordinary readiness was
wrong: poll() reports POLLERR/POLLHUP/POLLNVAL unrequested, and an invalid fd
shows up as POLLNVAL on a successful poll() rather than -1/EBADF, which left
the EBADF branches dead and let an invalid ff_forward_fd reach
forward_filesfrom_data() (where EBADF reads as EOF).  Use role-specific masks
(POLL_RD_BITS / POLL_WR_BITS), handle POLLNVAL explicitly in all three loops,
and request POLLPRI so select()'s old exception set is not silently dropped.

A bidirectional fd is no longer entered twice.  A direct daemon connection uses
one fd for both directions; it now occupies a single pollfd row with OR-ed
events instead of two rows carrying different masks, which also avoids the
Cygwin < 3.3.6 duplicate-entry readiness bug.

poll() is now a declared requirement: configure.ac checks for poll.h and
poll(), failing with a clear message rather than leaving it implicit.

The test no longer hardcodes FD_SETSIZE (1024 on glibc but 65536 on 64-bit
Solaris, where it would have opened too few fds and passed vacuously); it asks
the C library for the real value via a small compiled probe and skips if that
is unavailable.  Its description now also covers the fortified-libc case, where
the pre-fix result is an abort rather than a hang.

(cherry picked from commit 7ef165dd45)
2026-08-01 20:02:02 +10:00
Stuart Inglis 44fdf0bc43 io: use poll() instead of select() to avoid an FD_SETSIZE hang (issue #231)
rsync's I/O loops (safe_read, safe_write, and the main perform_io
multiplexer) waited for readiness with select() and fd_set bitmaps. An
fd_set can only represent descriptors below FD_SETSIZE (1024 with glibc).

When rsync is started with many descriptors already open -- e.g. inherited
from a parent process that leaked fds, a high "ulimit -n", or a busy daemon
-- its own socket and pipe fds get allocated at or above 1024. FD_SET() and
FD_ISSET() then index past the end of the fixed-size fd_set, which is
undefined behavior: select() reports the fd as ready, but FD_ISSET() reads
the out-of-bounds bit as 0, so the read or write never happens and rsync
spins at 100% CPU forever with no progress. This is the long-standing
"rsync hangs at 100% CPU on large systems" report, and it matches the
MemorySanitizer use-of-uninitialized-value seen in perform_io.

Convert the three loops to poll(), which identifies descriptors by value in
a small array and has no FD_SETSIZE ceiling, so a high-numbered fd works
fine. rsync only ever waits on a handful of fds (at most three in
perform_io: in_fd, out_fd, and the files-from forward fd), so poll() is as
fast as -- or faster than -- select() here; the select()-vs-poll() cost gap
only appears when watching thousands of descriptors, which rsync never
does. The remaining select(0, ...) call is a pure timed sleep with no fds
and is unaffected.

The conversion is behavior-preserving: the same max_fd bookkeeping decides
when there is nothing to wait on, the per-fd readiness checks map to the
matching pollfd revents, and the timeout is the same (now expressed in
milliseconds).

testsuite/highfd-hang_test.py reproduces the hang deterministically by
opening enough inheritable dummy fds to push rsync's descriptors past
FD_SETSIZE before an ordinary transfer; it hangs (caught by a timeout) on
the select() code and passes instantly with poll().

(cherry picked from commit 4a751a2ceb)
2026-08-01 20:02:02 +10:00
Andrew Tridgell 1078876a31 testsuite: cover sparse holes in matched inplace blocks
(cherry picked from commit cacb9bddbd)
2026-08-01 20:02:02 +10:00
Stuart Inglis f7c67d4a1d testsuite: drop the strace-based --sparse write-count test
The test needed strace, so it skipped on every platform without it (macOS, the
BSDs, Solaris, and the AlmaLinux container).  runtests.py compares the skip set
against RSYNC_EXPECT_SKIPPED and treats any unexpected skip as a failure, so it
turned the macOS and AlmaLinux 8 jobs red and would have needed an entry in
each platform's expected-skip list -- an entry that would itself go stale the
moment strace became available.

It also earned its keep poorly: it guarded a syscall-count property rather than
correctness, and it was not what caught the --inplace --sparse hole regression
in this series (review and differential fuzzing did).  Correctness of the sparse
paths is already covered by the sparse and preallocate tests; the write-count
improvement is recorded, with measurements, in the commit that made it.

(cherry picked from commit b7639c8c71)
2026-08-01 20:02:02 +10:00
Stuart Inglis bb804f80f6 fileio: keep punching interior holes on the matched (--inplace) path
Review caught a release-blocking regression in the previous commit: with
--inplace --sparse, interior zero runs inside *matching* blocks were left
allocated.

The scan I added applied only to the write path.  The use_seek branch --
reached via skip_matched() when an in-place update finds identical data --
still trimmed just the leading and trailing zeros and lseek()'d over the whole
middle.  Before the change, write_file() fed that data through
SPARSE_WRITE_SIZE slices, so an all-zero slice in the middle of a large
matching block became a deferred hole like any other; afterwards those blocks
stayed fully allocated.  Reproduced with the reported case: an 8 MiB file whose
every 32 KiB block is 4 KiB data / 24 KiB zeros / 4 KiB data, copied onto an
identical destination with --inplace --sparse --no-whole-file
--block-size=32768, occupied 2048 KiB before this series, 8192 KiB after the
previous commit, and 2048 KiB again with this one.  Content was byte-identical
throughout; only the on-disk sparseness regressed.

Rather than duplicate the scan in the use_seek branch, drop that branch and run
both cases through the one loop, with the sole difference factored into
emit_sparse_span(): a span that is not becoming a hole is written normally, or
merely seeked past when the bytes on disk already match.  The hole itself is
flushed by the existing flush_sparse_hole(), which already picks do_punch_hole()
over do_lseek() while inside the preallocated extent -- and for an in-place
transfer the receiver sets preallocated_len to the basis size, so a matched
interior hole is genuinely deallocated rather than skipped over.

Verified by differential fuzzing against the pre-series binary: 37 file shapes
(including the reported one, runs either side of the SPARSE_WRITE_SIZE
threshold, all-zero and hole-free files, and randomised mixes) across both the
plain and --inplace modes, comparing contents and allocated blocks.  Contents
match and allocation is never worse than before the series.

(cherry picked from commit 231de22f7c)
2026-08-01 20:02:02 +10:00
Stuart Inglis 6a15079cab fileio: coalesce --sparse writes instead of 1-KiB dribbles (issue #773)
write_file()'s sparse path sliced each span into SPARSE_WRITE_SIZE (1024-byte)
pieces and write_sparse() issued one write() syscall per slice.  Copying a
large *non-sparse* file with --sparse therefore cost roughly one write() per
kilobyte -- about a million write() calls for a 1 GiB file -- which on real
storage ran far slower than the same copy without --sparse (the bug report
measured 1.36 MB/s vs 391 MB/s, ~280x).  The 1024-byte chunk is also smaller
than a filesystem block, so it cannot even create finer holes than a plain
copy could.

Rewrite write_sparse() to scan the whole span itself: it looks for interior
runs of zeros that are at least SPARSE_WRITE_SIZE long -- the same hole
granularity rsync has always used -- and emits each intervening non-zero
region (which may include shorter zero runs not worth a hole) with a single
write().  do_punch_hole() advances the file offset just like the lseek() path,
so flushing a deferred hole between segments keeps the position correct.

The hole granularity is unchanged, so sparseness is identical; only the
syscall pattern changes.  Measured on a 100 MiB random (hole-free) file:
write() syscalls drop from 100,730 to 6,125 (~16x), now tracking the data's
natural chunking rather than its size in kilobytes.  Verified byte-identical
and equally sparse output for hole-free, large-hole, small-interior-hole,
all-zero, --inplace, and --preallocate cases.

testsuite/sparse-write-count_test.py copies a 16 MiB hole-free file under
strace and asserts the write() count stays far below the old size/1024
behaviour (it skips where strace is unavailable).

(cherry picked from commit 5ecab683a8)
2026-08-01 20:02:02 +10:00
Andrew Tridgell 56f6b67453 generator: decide the unconfined mknod fallback at compile time
The ENOSYS test was the wrong discriminator.  It was meant to detect "this
build compiled no fd-relative create at all", but a live mknodat() or
mkfifoat() returns ENOSYS too -- an unimplemented FUSE mknod does, and
seccomp can synthesise it -- so a runtime failure could route a create
through the unconfined path on a platform that has the secure primitive.

On Linux the fall-through lands in do_mknod_at(), which re-confines with
secure_relative_open(), so no escape was reachable there.  The real gap is
a mixed-capability build (mkfifoat() but no mknodat()): a runtime ENOSYS
from a real mkfifoat() reached the unconfined fallback even though an
fd-relative FIFO primitive existed.

Whether a primitive exists is a property of the build, so decide it there:
no_atfd_mknod_primitive() is false wherever mknodat() covers the node type,
where mkfifoat() covers a FIFO, or where --fake-super creates through
openat() -- which is always present and was previously able to fall back on
its own unrelated failures.

Also correct the SECURITY.md residual, which overstated the loss.  Plain
mknod()/mkfifo() do not follow a planted leaf symlink; they fail EEXIST,
verified directly against a symlink to a victim file.  What a no-mknodat
platform actually loses is the pinned parent, so the residual is a
parent-component race rather than a followed basename, and ordinary
fake-super placeholder creation stays confined via openat(O_NOFOLLOW).

And narrow t_symlink_secure's skip: it skipped the whole helper without
mknodat(), including do_symlink_at() assertions that do not depend on it.
Only the do_mknod_at() checks are now skipped, and the helper still skips
outright when neither applies rather than passing vacuously.
2026-08-01 19:19:42 +10:00
Andrew Tridgell fd8bc41967 testsuite: itemize's no-hardlink-symlink expectations described no rsync
The hardlink_symlinks==false branch set five values that differ from the
true branch.  Only one of them is real.

Measured on macOS 10.13 -- the one platform that takes it, where
linkat(AT_FDCWD, sym, ..., 0) is EOPNOTSUPP so a symlink cannot be
hard-linked even though ordinary hard links work -- rsync prints the
attribute field as blanks rather than 'c.t.' + dots, says "foo/sym is
uptodate" rather than "foo/sym -> ../bar/baz/rsync", emits no trailing
--copy-dest line at all, and uses .L where the branch expected cL.

Only the change-type letter genuinely differs, and only where the symlink
itself is transferred: hL when hard-linked, cL when copied.  So the other
four knobs are gone rather than corrected -- keeping them as variables
that hold the same value on both paths would preserve the suggestion that
something varies.

It went unnoticed because every platform that had run this test takes the
other branch: Linux, FreeBSD and OpenBSD all report hardlink_symlinks
true, and Cygwin skips itemize.  macOS before 13 is the first target to
reach it.

Derived by collecting every mismatching block in one run rather than
fixing them one at a time, so the values are what rsync emits rather than
a guess that makes one block pass and leaves the next wrong.
2026-08-01 19:19:42 +10:00
Andrew Tridgell 2a44bf4e9f fleettest: record mac-x86's two skip-list differences from macOS
That host is macOS 10.13 and has no mknodat(), so it differs from the
shared macOS list in both directions:

  symlink-mknod-fakesuper-symlink-race  skips there and only there --
      do_mknod_at() is the unconfined fallback on such a build, so the
      test skips itself rather than asserting a property the build does
      not have.  mac2 is macOS 26 and still runs it.
  sender-remove-source-root-anchor      the macOS list expects it to
      skip; this host runs it.

Neither can go in testsuite/skiplist/macos.txt: both Macs share that
file and they disagree.  The per-target extra/omit fields exist for
exactly this.
2026-08-01 19:19:42 +10:00
Andrew Tridgell abe4a2717e generator: fall back where the platform has no fd-relative mknod
do_mknod_atfd() returns ENOSYS on a platform that compiled no
fd-relative create at all -- older macOS has mknod() and mkfifo() but
neither mknodat() nor mkfifoat() -- and gen_entry_mknod() returned that
straight to the caller, so a FIFO or device node could not be created:

    rsync: [generator] mknod ".../afifo" failed: Function not implemented (78)

That is not the stance SECURITY.md sets out.  Where an operation can be
secured on some platforms but not others, rsync takes the race-safe path
where it exists and falls back to the historical unconfined behaviour
where it does not, "rather than refusing the operation outright".  Its
one stated exception is the nested-socket bind(), which gen_entry_mknod()
already routes away from this path.

So fall through to do_mknod_at(), which on such a platform is do_mknod()
by design.  Only on ENOSYS: any other errno is a real failure and must
not be retried through the unconfined path.

None of this was reachable before: the platform did not link at all until
the previous commit, which is why a refusal sitting where the documented
rule says fall back went unnoticed.

The race helper skips itself where mknodat() is absent.  do_mknod_at() IS
do_mknod() there -- the held-dirfd walk and the O_NOFOLLOW leaf create are
compiled out, not failing -- so its checks were asserting a property the
build deliberately does not have, and reported the accepted residual as a
module escape.

SECURITY.md gains that residual under "Known residuals": it previously
covered only the socket case, and said nothing about the whole special-
file path degrading where mknodat() is missing.

Checked by rewriting config.h the way macOS 10.13 has it (mknod and
mkfifo yes, mknodat and mkfifoat no): --specials now creates the FIFO
where it previously failed with ENOSYS, the race test skips instead of
failing, and the suite is 254/0.  Unchanged on Linux at 255/0.
2026-08-01 19:19:42 +10:00
Andrew Tridgell c458873481 syscall: guard do_mknod_atfd()'s mknodat() with HAVE_MKNODAT
It used HAVE_MKNOD.  Older Darwin has mknod() but not mknodat(), so the
call was compiled and then failed to link:

    "_mknodat", referenced from:
          _do_mknod_atfd in syscall.o
    ld: symbol(s) not found for architecture x86_64

Reported on macOS 10.13.6 x86_64, where config.h carries
/* #undef HAVE_MKNODAT */ next to #define HAVE_MKNOD 1.  The sibling
do_mknod_at() already keys off HAVE_MKNODAT and its comment names this
exact platform.

Both occurrences change together.  The second guards

    return -1;	/* mknodat()'s errno (regular/device node) */

against an ENOSYS fallback, so leaving it on HAVE_MKNOD would report
"mknodat()'s errno" on a build where the call was never compiled -- a
stale errno from whatever ran last.

Checked by rewriting config.h the way that platform has it and compiling
syscall.c: the parent leaves one unresolved mknodat reference, this
leaves none.  A FIFO still goes to mkfifoat() where that exists, and a
socket still returns EOPNOTSUPP; only the regular/device-node path
becomes ENOSYS, which is what a platform without mknodat() can offer.
2026-08-01 19:19:42 +10:00
Filipe Casal 41f411d6e9 rrsync: pin receiver option directories the peer can pivot
A checked receiver-side directory option whose leaf does not exist can
be created as attacker-controlled transfer content and then consumed by
the same transfer: --backup-dir=a, with "a" arriving in-band as a
symlink pointing out of the restricted directory.  The same shape works
against --copy-dest, where it reads an outside file and delivers it to
the client.  Passing the leaf under the parent's /proc/self/fd pin is
not enough: the peer's symlink wins the race to the name, and rsync
creates or reads through it.

The answer is to hand rsync an inode rather than a name -- but what
inode depends on what rsync does with the option, so the policy is per
option rather than per type ("type 2" means "check when receiving", not
"is a directory"):

  --backup-dir, --partial-dir   rsync creates them on demand.  rrsync
                                creates them instead, walking down from
                                the restricted dir one component at a
                                time with O_NOFOLLOW (nested names too:
                                make_bak_dir() builds a hierarchy), and
                                pins the result.  The partial dir is
                                made 0700, as rsync makes it.
  --temp-dir                    rsync requires it to exist, so a missing
                                one stays an error.
  --link-dest, --compare-dest,  rsync only reads through these, and a
  --copy-dest                   missing one is the ordinary first-run
                                case that must keep working.  rrsync
                                pins an empty directory it then unlinks:
                                the transfer behaves as with a missing
                                one, and there is no name left for the
                                peer to take over.  Not quite identical:
                                rsync prints "--link-dest arg does not
                                exist" for a genuinely missing basis and
                                the placeholder suppresses that.

If the transfer later replaces a created name, the held inode is merely
detached -- the backup fails, it does not escape.

Without /proc/self/fd there is no way to name an inode, so on those
platforms every missing type-2 option path is refused instead -- the six
above, not the type-3 paths covered at the end.
That is the same fail-closed behaviour this change originally had
everywhere; the pinning is what buys back first use where it can.

An earlier version refused every missing type-2 leaf on every platform.
That closed the pivot but broke first-use --backup-dir and --partial-dir
-- including --partial-dir=.rsync-partial, the documented resumable-
upload idiom -- and would have broken first-run --link-dest, which is
how every rotating-snapshot script starts.

The regressions run their security assertions BEFORE their controls, so
an environment where a control fails for an unrelated reason cannot mask
the escape check by aborting first -- which is exactly what happened to
one reviewer.  The alt-dest one also has to beat a race: the generator
runs ahead of the receiver, so sorting the pivot symlink first does not
guarantee it is installed before the basis lookup, and a run where the
generator won would pass vacuously.  A few thousand files in between
give the receiver the head start, and the test asserts the symlink was
really installed rather than trusting the ordering.  Measured 5/5 RED on
the parent, 3/3 GREEN here.

The regressions cover each branch of the policy: the backup-dir pivot,
the --copy-dest read escape (its own test), first-use --backup-dir,
nested first-use --backup-dir, first-use --partial-dir including its
mode, a refused missing --temp-dir, an accepted first-run --link-dest
with no placeholder left behind, and an existing basis still working.
Each requires the specific mechanism rather than just "the outside file
was left alone", which any unrelated failure would satisfy, and each
takes the refusal branch where the pin primitive is unavailable.

Behaviour worth knowing about, since rrsync now creates these rather
than rsync: they are created while the ARGUMENTS are parsed, so they
appear even under --dry-run, where rsync's own make_path() deliberately
does not mkdir; and one is left behind if the transfer then fails.  A
pinned --partial-dir is also one directory rather than one per
destination directory, and rsync will not auto-remove it -- which is not
new, rrsync already rewrote an existing relative partial dir to its pin.

A nested --partial-dir now behaves differently from plain rsync, which
documents creating "just the last directory -- not the whole path".  With
--partial-dir=a/b and "a" missing, rsync creates nothing and rrsync
creates both.  Containment is unaffected -- each component is made
beneath the fd already held, O_NOFOLLOW -- but rrsync accepts a shape
rsync would not honour, and since rsync only removes the last component
of a relative partial dir, an empty parent is left behind.

Not fixed here, and NOT claimed to be: this covers the six type-2
directory options.  --files-from, --log-file and receiver positional
paths are type 3; where the leaf and its parent are both missing, or on
a platform without /proc/self/fd, those still reach rsync unpinned, as
SECURITY.md describes.  That is the rest of the issue, not this one.

The wrapper is handed a shim rather than RSYNC directly: RSYNC is a
multi-word command whenever the runner forces --protocol=N, and rrsync
execlp()s its RSYNC as a single executable name, so this test died
before reaching the policy under test.  A fleet run caught it on the
protocol columns of three targets.
2026-08-01 12:16:40 +10:00
Andrew Tridgell 95e7f04148 runtests: let a skip spec remove a name a composed list added
The expected-skip lists are now files referenced as @FILE and composed
(common + platform + protocol), and they are expanded here, on the
target, against the tree that shipped.  That is deliberate, but it means
nobody upstream of this point can SUBTRACT: the name lives inside a file
the composer does not read.

fleettest needs exactly that.  A target may run the same build against a
different filesystem, and then tests its platform list expects to skip
genuinely run -- a scratch dir on a second volume makes backup-crossdev
-copy and chmod-temp-dir work.  With additions only, such a target can
never be green.

Accept a '-name' entry, applied after every addition so order does not
matter.  A test name never begins with '-', so the token is unambiguous.

Every removal must remove something: a name nothing added is stale, and
a repeated removal is that same no-op written twice.  Quietly shrinking
the expected set is the failure this parser exists to prevent, so both
are refused rather than left to sit in a config unnoticed.
2026-08-01 12:12:21 +10:00
Andrew Tridgell 4b4bf80f8d fleettest: never run two targets on one machine at the same time
Targets were all submitted to the pool at once, and the per-run build
directory was named for the run alone -- <builddir>-<run_id>, identical
on every target.  Both assume one target per machine.

Two targets naming the same host break that, and the fleet now has such
a pair: mac2 and mac2-hfs are one Mac, differing only in where the
tests' scratch trees live.  They pushed into the same directory and
built over each other, and BOTH reported BUILD-FAIL -- a failure that
looks exactly like the code under test not compiling.  Either target run
by itself was fine, which is the worst way for this to present.

The build directory now carries the target name too, and a machine's
targets run one after another, with a log line saying so.  Serialising
matters beyond the shared directory: two suites on one host would fight
over the fixed ports the daemon tests claim, and over every other piece
of host-global state, so separate directories alone would not be enough.
Different machines still run concurrently, which is where the
parallelism actually was.

The target name is reduced to [A-Za-z0-9._-] before it goes into a path
that cleanup later feeds to rm -rf, so a name cannot contribute a path
separator, a shell metacharacter or a leading dash.  --cleanup still
globs <builddir>-*, which the longer name matches.
2026-08-01 12:12:21 +10:00
Andrew Tridgell dc72d0409a testsuite: xattr_set must refuse the same way on every platform
make_variety_tree() sets an xattr on every file, including the ones it
deliberately creates read-only, and tolerates a refusal:

    try:
        xattr_set('variety', os.path.basename(str(p)), p)
    except OSError:
        pass

That handler works only on Linux.  There xattr_set() calls os.setxattr()
and a refusal is an OSError; every other platform shells out to a CLI
with check=True and raises CalledProcessError, which is not an OSError
and sails straight past.  So a refusal the suite tolerates on Linux can
kill variety and variety-symlink-traversal on any CLI-backed platform.
macOS, Cygwin, FreeBSD and Solaris all carry the defect; macOS is where
Roland Kletzing hit it, on test8 through test10, on the perm7 file:

    xattr: [Errno 13] Permission denied: '.../d1/d2/.../d7/perm7'
    subprocess.CalledProcessError: ... returned non-zero exit status 1

What triggers it there is a non-root run meeting the mode-0400 files the
tree deliberately creates, which their own owner cannot attach an xattr
to.  That is why the fleet, which runs as root, never saw it.  Root is
not immune to every refusal, just to that one.

Route the four CLI branches through a helper that raises XattrError, an
OSError subclass, so one handler covers every platform.  It carries an
errno only when the tool named one, and only macOS's xattr(1) does:
setfattr and setextattr just say "Permission denied", and guessing an
errno back out of localised strerror text would be worse than admitting
we do not know.  The match is anchored to that tool's own prefix on the
first line -- the rest of the line is a filename, and a file can perfectly
well be called "[Errno 5]".

devices/devices-fake had already worked around this locally by catching
both types; that catch is now dead, so drop it.

Verified by forcing the CLI branch on Linux and running variety non-root:
it fails with Roland's traceback, on the same perm7, and passes with
this.  That establishes the exception path every CLI branch takes, not
macOS's xattr(1) in particular -- his report supplies that half.
2026-08-01 12:12:21 +10:00
Andrew Tridgell 47df88d5fb fleettest: run a target's tests on another filesystem, and add two Macs
runtests.py already honours $scratchbase, but a target could not use it:
the sudo branch runs `sudo -n env PATH="$PATH" ...`, which drops whatever
env_prefix exported.  Setting it there looked like it worked and silently
ran on the default filesystem instead -- the first HFS+ run came back
green for that reason.  Give it a target field carried inside the env
string, on both the root and non-root paths, shell-quoted so a volume
name containing a space does not turn into a stray argument.  The
non-root pass also clears the relocated scratch, which a prior sudo run
leaves root-owned outside builddir.

expect_skip_omit is the mirror of expect_skip_extra: entries the
workflow expects to skip which a target actually RUNS.  Relocating the
scratch supplies conditions the workflow's host lacks -- a separate
volume makes backup-crossdev-copy and chmod-temp-dir reachable -- and
without a way to subtract, such a target can never be green.

mac2-hfs runs the same host and build as mac2 with the scratch on HFS+.
It verifies the mount rather than assuming it: a stale directory, or a
name collision attaching at "RsyncHFS 1", would otherwise leave the
tests on APFS reporting green, which is how the first version lied.
Ownership must be on as well, and is now checked rather than attempted:
a user-attached image mounts "noowners", under which every uid/gid and
permission check is meaningless, and that alone accounted for 28 of the
31 failures the first honest run produced.

mac-x86 is the x86-64 Mac -- the only target that can build the x86-64
md5 assembly, since mac2 is arm64 where configure refuses
--enable-md5-asm outright.  It needs MacPorts for autotools, python3 and
the crypto/hash libs.  --enable-roll-simd is not set and cannot be: that
probe uses GCC-style function multiversioning, which clang does not
support on Mach-O, failing identically under Apple clang 10 and clang 19.
mac-x86 currently BUILD-FAILs on the unguarded mknodat() in
do_mknod_atfd() (#161), which it reproduced on its first run.
2026-08-01 12:12:21 +10:00
Andrew Tridgell ab373ad1d0 testsuite: itemize XFAILs where the filesystem cannot hard-link a symlink
itemize picks between two expectation sets using rsync's own
"hardlink_symlinks" build capability.  That says nothing about the
filesystem underneath: on macOS the build reports true while HFS+ returns
ENOTSUP for link()ing a symlink, and the run dies with

    failed to hard-link .../foo/sym with foo/sym: Operation not supported (45)

Selecting the other expectation set does not help and would assert
something untrue: the itemisation follows the BUILD capability, so rsync
still prints "foo/sym is uptodate" and ".L foo/sym -> ..." even though
the link failed.  Neither set describes that combination.

It is an rsync gap rather than a test one.  generator.c reports the
runtime linkat() failure as FERROR_XFER and the transfer exits 23, after
which rsync creates the symlink anyway -- while a regular file in the
same position falls back to a local copy, and so does a build compiled
WITHOUT symlink-hardlink support.  Falling back on ENOTSUP would make
this pass by itself.

So XFAIL rather than skip: the failure stays visible and flips back to a
pass once rsync falls back.  XFAILing the whole test is blunter than the
one --link-dest case deserves, but the symlink expectations are threaded
through every assertion here rather than confined to one.

The probe answers only the question it is asked: a link() refused for any
other reason -- EPERM, ENOSPC, EMLINK, a quota -- propagates instead of
being reported as a capability difference and quietly reshaping the
expectations.
2026-08-01 12:12:21 +10:00
Andrew Tridgell de38616b25 testsuite: detect an operator-path escape without sub-second mtimes
operator-path-temp-dir and operator-path-partial-dir decided whether a
symlink had been followed by sampling the target directory's
st_mtime_ns, sleeping 10ms, and looking for a change.  The temp file is
renamed away, so an mtime bump was the only trace left.

On a filesystem whose timestamps have 1-second granularity -- HFS+, and
it is not alone -- a change within the same second is invisible.  The
delta is zero, the test concludes the symlink was not followed, and
reports the operator's OWN euid-owned symlink as refused when it was
followed correctly.  Both fail that way on HFS+ while passing on APFS,
and operator-path-partial-dir is one of the failures Roland Kletzing
reported on macOS.

Pin the directory's mtime to a fixed past epoch instead, read back what
the filesystem actually stored, and ask afterwards whether it still
holds -- reading back because a filesystem may clamp or round the value,
and comparing against the requested epoch would then read an unfollowed
symlink as followed.  temp-dir-symlink-injection already works this way.

This is not proof against every clock: a directory whose mtime lands
exactly on the stored sentinel would still read as unfollowed.  That
needs the host clock set to 2001 or a deliberate restore, where the old
10ms delta failed on any coarse-granularity filesystem.

Verified in both directions by running as root, where the matrix also
exercises the cross-uid cells: a followed symlink moves the mtime off
the sentinel, a refused one leaves it.
2026-08-01 12:12:21 +10:00
fcasal c933f6227d backup: preserve backup-dir while creating it 2026-08-01 10:43:58 +10:00
Andrew Tridgell dcc3c9c51a socket: refuse a meaning-changing first character, and stop refusing aliases
The %H allow-list was wrong in both directions.

Several accepted characters change an argument's MEANING rather than its
text when they lead the value, which quoting cannot prevent because the
word stays intact -- that IS the problem:

  '-' and '+' introduce options to plenty of programs; with
      RSYNC_CONNECT_PROG="prog %H", hosts "-c" and "+x" arrive as
      options, and "sh +x" is as real as "sh -x";
  '~' is tilde-expanded by the nested shell, turning ~root into /root;
  '%' is expanded by a nested fish, where %self becomes its pid.

An empty host has the same shape from the other end: it survives a direct
exec as an empty argument but disappears when a nested shell re-splits the
command, shifting everything after it.  rsync://:873/m/ and ::m/ both
produce one.  None of these can begin a real hostname, so refuse them in
first position only -- mid-word each is literal, which matters because an
IPv6 zone id carries its '%' mid-word.

The other direction: '+' and '~' were refused outright.  They execute
nothing, and RSYNC_CONNECT_PROG exists for custom transports where %H is
often an alias the program resolves itself rather than a name the
resolver sees.  Refusing them mid-word breaks that use case for no gain.

A non-ASCII host stays refused.  That is a policy choice rather than a
free one: a custom connect program never calls getaddrinfo, so a Unicode
alias would otherwise work, and this does exclude it.  A punycode A-label
is unaffected.

What this cannot do is bound what the named program makes of the value.
"host:-rf" arrives intact, and a program that splits on ':' may
reinterpret the tail; that boundary belongs to whoever writes the
command.

The test asserted only that a marker file was absent -- equally true when
rsync failed to parse its arguments, when socketpair_tcp is blocked, or
when touch was missing.  Worse, the marker path was absolute, and a URL
authority ends at the first '/', so the injected `touch` never received
an operand and the check could not fail even with the guard gone.  It now
runs with cwd set to the scratch directory and injects a bare name, so
the marker is genuinely reachable; requires the specific refusal message;
checks the exit status; and checks that ordinary hosts still arrive at
the connect program with their text intact, which is the part an
absence-only test can never show.

Fault-injected separately: dropping the guard, dropping just the
first-character check, and narrowing the set back each fail the test on
their own, in both the default and --use-tcp transports.
2026-08-01 10:00:05 +10:00
Filipe Casal 78e10e7e32 socket: reject shell-active connect hosts 2026-08-01 10:00:05 +10:00
Andrew Tridgell f704aa4aed daemon: refuse '!', '~' and braces in a hook expansion too
The refused set was built from the characters that obviously execute
something, and missed three that a SECOND shell acts on:

  '!' negates in command position.  A hook written as an access check --
      `pre-xfer exec = sh -c '%RSYNC_USER_NAME% false'` -- becomes
      `! false`, reports success, and serves the transfer.  An
      authenticated user named "!" turns a denial into an approval, which
      is precisely the case the fail-closed comment above exists for.
  '~' is tilde-expanded, so ~root becomes /root.
  '{' and '}' brace-expand in bash and zsh.

None of them execute anything on their own, which is how a set built from
the obvious metacharacters came to miss them.  That is also the standing
weakness of the approach: this is a deny-list, and the two rounds of
review it took to find '!' are the argument for eventually inverting it.

The documentation is corrected with it -- it claimed every shell-active
character was refused, which this disproves -- and now lists the set.
Each listed character is pinned by the test, which needed its module
paths to EXIST first: a missing path fails the transfer on its own, so
checking the exit status alone passed whether or not the character was
refused.  Removing any single character from the set now fails the test.
2026-08-01 09:52:27 +10:00
Andrew Tridgell 8da62816c8 testsuite: make the nested-shell test fail for the right reason
It asserted that a marker was absent and that rsync exited non-zero.  Both
are equally true when authentication failed, when the daemon never
started, or when the globbed command was missing -- so it passed for any
of those, with or without the guard.  Changing only the password to a
wrong value left it green.

Require the daemon log to carry the specific refusal, so a transfer
stopped for some other reason no longer reads as the value having been
refused.

Add a positive control.  The test now runs the same nested-shell
expansion itself first and requires it to work; without that, a pass
could equally mean the attack was inert here and rsync was never tested
against a live one.

Stop globbing onto /usr/bin/touch.  The command the expansion selects is
now one the test writes into its own directory: the build never
guaranteed a system touch, and with the old oracle a missing one produced
a pass.  Exactly one file there matches "touc?", so the expansion is
unambiguous.

The docstring described the value becoming "find arguments", which is not
what the hook does -- it selects /usr/bin/touc? and glob-expands it to a
command.  Say what actually happens.

Checked in both directions: the wrong-password mutation that used to pass
now fails, narrowing the refused set back to the pre-existing one fails,
and the unmodified test passes under both the default transport and
--use-tcp.
2026-08-01 09:52:27 +10:00
Andrew Tridgell 5bf9940de4 rsyncd.conf: document that the hook metacharacter refusal covers your own values
Refusing a shell-active %VAR% in an exec hook is a real usability cost
and it is not confined to hostile input: the check runs on every
%RSYNC_*% value, so a module whose path holds a space cannot be
interpolated into a hook at all.  `path = /srv/My Backups` with a command
mentioning %RSYNC_MODULE_PATH% refuses every transfer of that module,
with no attacker involved.

That is deliberate rather than an oversight.  rsync escapes a
substitution for the quoting context it sits in, which is right for the
one shell that runs the command and wrong for a command that starts a
second one -- `sh -c '... %RSYNC_USER_NAME% ...'` hands the inner shell a
bare value, where "touc?" is glob-expanded against /usr/bin and chooses
the command rather than being data for it.  Escaping for an unknown
number of passes is not possible, so the value is refused instead.  Nor
can the check exempt operator-supplied values: `path` may itself be
templated from a peer one (`path = /home/%RSYNC_USER_NAME%`), and once
both are inside the same string rsync cannot tell them apart.

Say so in the manual, and give the way out: the same names are exported
to the command, so $RSYNC_MODULE_PATH inside a script is unrestricted.

The test pins both halves, since a documented behaviour with no oracle
drifts.  Both are fault-injected: narrowing the refused set back makes
the interpolated half pass when the manual says it must not, and dropping
the RSYNC_MODULE_PATH export fails the workaround half.
2026-08-01 09:52:27 +10:00
Filipe Casal 00160f76b8 daemon: reject shell-active hook expansions 2026-08-01 09:52:27 +10:00
Andrew Tridgell 1eab6f43e8 testsuite: cover the skip-list encoding guard
An invalidly encoded list must exit 2 like any other unreadable one, but no
case created invalid bytes, so dropping UnicodeDecodeError from the except
would have left the test green (failing closed with a traceback instead).
2026-07-31 21:02:55 +10:00
Andrew Tridgell c1df37d954 testsuite: prove each skip-list guard independently
Follow-up review found three guards that the test observed only indirectly:
removing the one-name-per-line check, or relaxing the regular-file check back
to exists(), or dropping a separator check still produced a hard error via the
"no such test" path, so the test could not tell them apart.  Each now runs
against a stand-in suite directory containing fixtures that make the malformed
name look real ("acls sparse_test.py", a directory named adir_test.py), so the
guard under test is the only thing that can reject it.  Verified by mutation:
each guard removed in turn makes the test fail on its own case.

Also: reject a comma in a name (it is the separator of the csv this returns
and of the summary line the fleet parses back); catch UnicodeDecodeError
alongside OSError so a mis-encoded list exits 2 rather than tracebacking; and
exercise a non-trivial relative srcdir, since in-tree os.path.relpath() is
just "." and would not have caught prefix doubling.

Two comments overstated things: a truncated list does not silently weaken the
oracle -- the comparison is exact, so it surfaces as a page of unexpected
skips.  Rejecting it is about failing where the mistake is, not about the
oracle going blind.  And extras merge into the passes the workflow pins, not
into every pass.
2026-07-31 21:02:55 +10:00
Andrew Tridgell 93e0d01798 testsuite: fail closed on every malformed skip-list spec
Review of the previous commit found four ways the parser or its test fell
short of what that commit claimed:

  - An empty or comment-only list, and an empty entry within a spec (`a,,b`,
    which is what an unset shell variable expands to), both expanded quietly
    to a smaller expected set.  A shrunken expectation is a weaker oracle, so
    these are hard errors now; a wholly empty spec remains the legitimate
    "expect no skips".
  - Name validation accepted a path, so `../testsuite/acls` passed as a test
    name.  Names must be plain and resolve to a regular file.
  - skiplist-spec_test.py built @FILE paths from a relative srcdir and handed
    them back to a srcdir-relative API, which doubled the prefix -- it would
    have failed under `make installcheck` (--srcdir=../src).  It also proved
    the sort check with a name that does not exist, so deleting that check was
    masked by the stale-name check; it now uses two real tests out of order,
    and covers the cases above.  Each guard verified by mutation.
  - fleettest returned an extras-only expected set for a pass whose workflow
    has no matching step (a non-Linux target with protocols=[29]), a
    guaranteed mismatch.  An unpinned lane is now simply unpinned.

Also restores the fleettest CI path filter that the previous commit dropped:
that job must run when runtests.py or a skip list changes.
2026-07-31 21:02:55 +10:00
Andrew Tridgell 2f9bbe835c testsuite: keep the expected-skip lists in files, not in the workflow line
Every branch that added a test which skips somewhere had to edit
RSYNC_EXPECT_SKIPPED, a single ~3 KB YAML line duplicated across seven
workflow steps -- so two such branches always conflicted, and the conflict
was in the one format git cannot merge.

The lists move to testsuite/skiplist/*.txt, one name per line with the reason
as a comment, and RSYNC_EXPECT_SKIPPED takes @FILE entries which runtests.py
expands (relative to srcdir, so out-of-tree builds work).  Several compose,
which lets the 46 names common to Linux/macOS/Cygwin live in one file: adding
a require_tcp test now edits one line of common.txt instead of three lists in
three files.

Lists must be sorted, duplicate-free, and name real tests, and an unreadable
or malformed list is a hard error -- it must never degrade to "expect no
skips", which would silently disarm the oracle on that job.  fleettest passes
the spec through to the remote runtests.py, which expands it against the tree
that was staged there.

The oracle itself is unchanged.  Verified on Linux by running the full suite
in all three lanes (check, check30, check29) against the new files: same
expected sets, all green.
2026-07-31 21:02:55 +10:00
Andrew Tridgell 9022a0a5bc ci: enforce the protocol-29 skips in the check29 oracle
The check29 steps reused the plain check list, so six tests that skip only
under --protocol=29 were unaccounted for and the run failed its expected-skip
comparison.  They gate on the wire version rather than the platform: ACL and
xattr transfers need protocol 30+, and the stdio_daemon helper speaks 30.

Verified by running the full suite at both protocols on Linux: the 29 skip set
is the default set plus exactly these six.  acl-symlink-race already carried a
comment saying its protocol gate had to be represented here.
2026-07-31 21:02:55 +10:00
Filipe Casal c3abc6c095 rrsync: protect no-overwrite auxiliary paths
--ignore-existing protects the live transfer destination, but three
peer-selectable options reach other existing objects inside the
restricted directory: --log-file appends to one, --partial-dir consumes
and then renames or unlinks one, and --delay-updates does the same
through its implicit .~tmp~ directory.  Refuse all three under
-no-overwrite.

Refusal rather than confinement, because confining these paths does not
help: keeping the --log-file append inside the tree still appends to an
existing file, and for the partial directories the peer controls both
the directory and the transferred basename, so a collision is always
reachable.  A pre-exec emptiness check would be raceable.  The cost is
that a push naming --partial-dir on the remote receiver, or using
--delay-updates, is now refused for a -no-overwrite account.

Each regression drives the option through a wrapper WITHOUT
-no-overwrite as well, and requires that to be accepted.  Without that
control the tests cannot tell "refused under -no-overwrite" from
"refused always", and would still pass if the options were disabled for
every rrsync deployment -- verified by making the refusal unconditional,
which the controls then catch.

The wrapper is handed a shim rather than RSYNC directly: RSYNC is a
multi-word command whenever the runner forces --protocol=N, and rrsync
execlp()s its RSYNC as a single executable name, so every one of these
tests died before reaching the policy under test.  A fleet run caught it
on the protocol columns of three targets.

Backup mode belongs in the same set.  Publishing a backup onto a name
that already exists deletes what is there (backup.c make_backup()), and
deleting a file backs it up first (delete.c), so a --delete of an
unrelated file can land on a protected name -- overwriting a file that
--ignore-existing was holding, with rc=0 and no diagnostic.  Disable -b
and --backup-dir.  --suffix is left enabled: with both of those refused
nothing can turn backups on, so it is inert.

The short option must be disabled before short_no_arg_re is built, since
a stock client sends b inside the remote short-option bundle and the
regex is snapshotted there; the whole -no-overwrite block therefore
moves up beside the other policy gates rather than sitting after the
chdir.  Its regression drives the collision through a wrapper without
-no-overwrite, passing --ignore-existing by hand, which both proves the
refusal is conditional and shows the primitive defeating the very
protection -no-overwrite forces.

rrsync.1.md now states what -no-overwrite costs: no explicit
--partial-dir, no --delay-updates, no server-side --log-file, no backups.
2026-07-31 13:18:39 +10:00
Filipe Casal 576ce422fe rrsync: keep remote files-from out of write-only mode
Write-only mode rejects ordinary downloads, but a remote --files-from
makes the receiving child open a server-side file and send it back over
the upload protocol.  Reject server-local --files-from paths under -wo,
keeping the exact "-" sentinel that a client-local files-from upload
sends.

The control now uploads a second, unlisted source file and requires it
NOT to arrive.  With a single file present an ordinary recursive upload
looks identical, so the control passed whether or not the list selected
anything.

The wrapper is handed a shim rather than RSYNC directly: RSYNC is a
multi-word command whenever the runner forces --protocol=N, and rrsync
execlp()s its RSYNC as a single executable name, so this test died
before reaching the policy under test.  A fleet run caught it on the
protocol columns of three targets.

Known gap: this closes the argv route only.  A per-directory merge
filter delivered over the protocol still reads a file outside the
restricted directory and returns its content in an "Unknown filter rule"
error -- reproduced with a stock client against -wo.  rsync's existing
filter-file confinement does not apply because it is gated on am_daemon
and an rrsync server is not a daemon.  Tracked separately.
2026-07-31 12:22:20 +10:00
Filipe Casal 06212f112a daemon: cap peer-selected Zstandard workers
A daemon parses the peer-supplied server argv, so a client naming a large
--compress-threads on a pull makes the daemon-side sender materialize
that many Zstandard workers: 256 was measured as 257 threads in one
connection.  No custom client is needed -- a stock rsync forwards it with
-M--compress-threads=N -- and on an anonymous module no authentication
happens first.  Clamp it to 8 on a daemon; local and remote-shell
invocations keep the operator-requested value.

A push parses and clamps it too, but creates no workers there: the
option affects compression, not decompression.

The test asserts the implementation's own bound, 8 workers plus the main
thread, rather than a looser threshold that a build unable to create
workers at all would also satisfy -- so it first requires a worker pool
to be reachable and skips if it is not, then requires it to be bounded.
It needs --use-tcp and is declared in the workflows that enforce a skip
set.

Whether the platform can be counted at all is asked once, before the
answer is folded into a max(): thread_count() returns -1 where it has no
way to look, and max(0, -1) is 0, so the -1 could never reach the check
meant to catch it and an uncountable host looked instead like a sender
that died.  A fleet run had Cygwin failing for exactly that reason.

Left deliberately open: the cap is silent, is not expressible in
rsyncd.conf, and does not bound the total across connections, since
max connections defaults to unlimited.
2026-07-30 16:45:26 +10:00
Filipe Casal c529163ef0 options: refuse aliases for exact option rules
parse_one_refuse_match() marked only the first long_options row whose
long name matched the configured spelling, then broke out for a
non-wildcard rule.  --compress-threads and --zt are separate popt rows
that both write &do_compression_threads, so "refuse options =
compress-threads" disabled the canonical row and left the alias
accepted: the refused capability was still reachable under its other
name.  The same shape covers zc/compress-choice and zl/compress-level.

An exact rule names a capability, not one spelling of it, so mark every
row that does the same thing.  Comparing the raw table fields is not
enough for that: popt's `val` means different things per argInfo.  For
POPT_ARG_VAL it IS the value stored in `arg`, while elsewhere a nonzero
`val` is an action code for the parser's switch, and POPT_ARG_NONE with
a destination stores 1 whatever `val` says.  --del is
POPT_ARG_NONE/&delete_during/0 and --delete-during is
POPT_ARG_VAL/&delete_during/1: the same destination and the same
resulting value, but unequal as table entries, so "refuse options =
delete-during" was still evaded by --del and the mirror held too.

Compare what a row does instead -- the destination and the constant it
assigns, falling back to table-entry equality for rows that store a
runtime value or only dispatch an action.  Enumerating all 258 rows,
this couples exactly one pair the field comparison missed, del and
delete-during, and changes nothing else.  Opposite switches such as
--foo and --no-foo stay distinct because they assign different values.

Two regressions.  The compress-threads one drives the raw daemon
protocol -- not to preserve the spelling, which -M--zt=N would do just as
well, but because it goes on to observe the worker pool the bypass
delivers.  Its oracle is the refusal itself -- the alias connection torn
down and
"configured to refuse --zt" logged -- and deliberately not the resulting
worker count: an accepted --zt is a defeated refuse rule however few
threads it produces, and the daemon worker cap being added alongside
this holds that count to 9, so a count-based assertion passes while the
alias is still accepted.  Run that test against the cap without this
parser fix and it does exactly that; the two changes were covering for
each other.

The delete one needs neither zstd nor a socket: --remote-option puts the
option in the daemon's argv verbatim, which is the reach an ordinary
user already has, so it drives a stock client both ways round against
modules refusing each spelling, with an unrefused module as the control.

The compress-threads test needs --use-tcp, so it skips in every other
column and is declared in the workflows that enforce a skip set, which a
fleet run otherwise reports as an unexpected skip on fourteen cells.
2026-07-30 06:36:12 +10:00
Andrew Tridgell 33257bdcaa testsuite: parse the generator's request instead of pattern-matching it
malicious-server-partial-basis-symlink-overwrite waits for the client's
generator to ask for index 1 before sending its forged basis-type
response.  It waited by searching the raw stream for one hard-coded
three-byte marker: the ndx delta, then exactly
ITEM_TRANSFER|ITEM_IS_NEW.

The generator is entitled to send more than that.  When it has already
chosen an alternate basis it also sets ITEM_BASIS_TYPE_FOLLOWS and
appends a basis-type byte, which it does once it has noticed the
partial-dir file this test plants.  Whether it does varies between
environments -- consistently not on this Linux box, consistently so on
the OpenBSD VM.  I have not identified what differs; the planted file
and the victim both exist before rsync starts and the malicious sender
does not touch them, so calling it a race would be a guess.

Where the extra flag appears the marker never matched, and the test
timed out after ten seconds reporting "generator did not request file
index 1" -- which was untrue.  The captured bytes decode as the
auto-added perishable filter rule "-p .rsync-partial/", the filter-list
terminator, then ndx 1 with flags 0xa800 and the byte 0x81,
FNAMECMP_PARTIAL_DIR: the very constant this test defines.  It failed in
five of six fleet runs and reproduced on an isolated single-target run,
so it was not load-related flakiness.

Read the request properly instead: consume the filter list, then the
index, the item flags, and the optional basis-type and xname fields,
using the framing rather than hunting for a byte pattern that can also
occur inside file data.

Both outcomes are worth driving, and they prove different things:

  * without the flag the response SUBSTITUTES a partial basis the
    generator never asked for -- the unbound-basis-type bug;
  * with it the generator asked for that basis itself, so the response
    substitutes nothing and the test proves the receiver CONFINES a
    basis it did request.

The comment claimed the first unconditionally, which was wrong wherever
the second happens.  Any other basis type now fails as a fixture change.

Failing the old way was safe -- the forged response was never sent, so
no false pass was possible -- but it cost a red cell on the fleet and
hid whatever else that target had to report.
2026-07-29 19:58:40 +10:00
Andrew Tridgell 2fbb708f15 rrsync: deny device/special creation where creation happens
A restricted dir must not let a client have the spawned rsync create
devices or special files in the served tree, but -a bundles -D into the
client's short options, so refusing -D outright breaks every ordinary
`rsync -a`.  88cee089 forced --no-D instead.  That option also clears
the rdev framing, and rrsync sets it on one end only, so the file list
desynchronised: a FIFO push hung at protocol 29 and corrupted the list
at 30, and a device push failed at every protocol including 32.  Use
--drop-D, which withholds the creation without touching the wire.

Only on the receiving side.  A sender creates no received device or
special entry in the served tree, so there is nothing to deny and
--drop-D is a no-op there; forcing --no-D on a pull was the same
one-sided change in the other direction, and broke pulls the same way.
3.4.4 forced nothing at all and is the behaviour a pull now gets back.

rrsync-specials-denied asserted the option on a "--server --sender"
command line, which conflated the two directions.  It now checks that
the receiving side forces --drop-D and still forwards the client's own
-D -- without which the two ends frame the list differently again --
that the sending side forces neither, and, rather than only what is
forwarded, that a real push cannot create a FIFO while the rest of the
transfer succeeds.  An ordinary file alongside is the control, since a
push that failed outright would "deny" the FIFO too.

The device case gets its own push, because a device desynchronises at
every protocol while a FIFO only does so below 31.  It needs no mknod
privilege: rsync's fake-super "%stat" xattr is what makes a file a
device to rsync, and running the RECEIVER under --fake-super too lets it
record one without privilege -- so the case asserts that nothing of that
name appears, not merely that the transfer survived.

rrsync-pull-arg-shapes could previously assert only that pulling a FIFO
did not hang, because forcing the option on that side broke the transfer
outright.  It now requires the pull to succeed and deliver a FIFO, which
is what a pristine 3.4.4 rrsync does.
2026-07-29 19:58:40 +10:00
Andrew Tridgell b607369f5f rsync: add --drop-D, refusing device/special creation only
-D and --no-D do two jobs at once: they decide whether devices and
special files are created, and they decide whether those entries carry
their rdev fields on the wire.  send_file_entry() and recv_file_entry()
frame those fields with the same condition, but each end evaluates its
own preserve_devices/preserve_specials, so the two only agree because
both normally parse the same command line.

That makes --no-D unusable for a wrapper that controls one end of a
connection and wants to deny creation.  Give it to the receiver alone
and the client's -D sender writes rdev the receiver never reads: the
file list desynchronises from that entry on.  A FIFO or socket breaks
below protocol 31 -- a hang at 29, "File-list index 0 not in 0 - -1" at
30 -- and a device node breaks at EVERY protocol, current ones included,
because its arm of the condition has no protocol clause at all.

--drop-D separates the two jobs: it refuses the creation and leaves the
encoding alone.  The entry is skipped through the existing non-regular
fall-through, so the visible result matches --no-D, and because it
touches no wire state it can be applied to one end by itself.

It has no effect on a sending rsync, which creates nothing.
2026-07-29 19:58:40 +10:00
Andrew Tridgell 1c0bd88f0b rrsync: don't content-open a sender leaf rsync will never open
Two shapes a pristine 3.4.4 rrsync transfers, and 88cee089 broke, both
from one cause: the pin opens the argument's CONTENT, when for a sender
rsync often only needs to name or describe it.

  * an in-tree FIFO wedged rrsync before exec.  O_RDONLY on a FIFO blocks
    until a writer appears, so an authorised user naming one could
    accumulate stuck processes indefinitely.
  * an in-tree dangling symlink failed the transfer.  realpath() resolved
    it to a missing target and the ENOENT was reported as a detected
    race, though a dangling link is an ordinary archive entry that rsync
    transmits by its target string without opening anything.

So only a regular file or a directory gets its content opened; anything
else keeps the realpath()-validated name.  The sender never opens these,
it only describes them.

The leaf is still spelled beneath a pinned directory.  An
earlier form of this commit left the bare name for rsync to re-resolve,
on the reasoning that 3.4.4 passes it that way -- but that puts every
component back in play and reintroduces CVE-2026-53783 for the shape:
with an in-tree "dir/target" that is a dangling symlink, flipping "dir"
to a symlink pointing outside leaked the outside file's content in 3 of
83 raced pulls.  With the parent pinned it is 0 in 104 -- but a race only
samples the window, and zero in 104 still leaves a few per cent of
per-attempt risk unmeasured, so rrsync-sender-parent-pin closes it
deterministically instead: a stub standing in for rsync inherits the
pinned descriptor and blocks, the parent is swapped for a symlink out of
the tree while it is blocked, and only then does the stub resolve the
argument.  It reports the in-tree leaf with the pin and the attacker's
file without it, so it fails outright if the pin is removed rather than
depending on winning anything.  A control first proves the swap really
does redirect the bare name, or the assertions would prove nothing.

Pinning the
parent costs nothing here -- pin_dir() opens it O_PATH, so the special
file itself is still never opened and a FIFO still cannot block, and
whatever the leaf becomes afterwards is reached only from beneath the
held one.

Which directory that is, sender_pinned_arg() already decides, and for
every shape except one it is the immediate parent.  The exception is a
--relative argument with no client "/./": there the whole argument is
the transmitted name, so only the anchor it starts from can be pinned
and the components below it stay raceable.  That limit predates this
commit and NEWS states it; "the parent is pinned" is not true of that
one shape.

Two boundaries this must NOT cross, each found the hard way:

  * a trailing "/" or "/." argument keeps its leaf pin: rsync opens that
    one and does follow a symlink there.  Declining it made
    rrsync-sender-leaf-flip leak the outside directory's content.
  * the decision is not gated on HAVE_PROC_SELF_FD.  It is about what
    rsync does with the argument, not about whether we can pin it, so
    gating it left the dangling-symlink failure in place on the BSDs,
    macOS, Solaris and Cygwin.

The shape matrix grows fifo, dangling-symlink and symlink-to-file cases,
and now asserts what each delivered entry IS -- kind, symlink target and
content -- on every case rather than spot-checking a couple at the end.
A name-only comparison is satisfied by an empty directory called "f1",
or by the correctly-named but empty symlinks that handing the sender a
magic link produced.  It still passes against a pristine 3.4.4 rrsync.

The FIFO case asserts only that the pull does not hang.  What a special
file does on the wire is decided by the --no-D that a restricted dir
forces on the remote side alone: the sender then omits the old-protocol
rdev fields that the client's own -D receiver still reads, so protocol
29 and 30 fail regardless of this change.  Verified by running the FIFO
case under fakeroot at protocol 29 with and without the parent pin --
it hangs identically either way, so the pinned name is not the cause.
That asymmetry is a pre-existing rrsync bug and is tracked separately.
2026-07-29 19:58:40 +10:00
Andrew Tridgell 05dabb0fdb NEWS: state what the rrsync pin actually covers
The CVE-2026-53783 entry claimed rrsync "inode-pins each validated
component and exec's against the pinned fd".  It pins the path and roots
the argument there, which is not the same thing for a sender argument,
and the primitive is Linux-only -- elsewhere rrsync keeps the
realpath()-validated name, as it always did.
2026-07-29 19:58:40 +10:00
Andrew Tridgell ce0f6d5a25 rrsync: only claim the inode pin where the kernel actually provides it
The HAVE_PROC_SELF_FD probe checked that readlink of a DIRECTORY's entry
returned the right path, which is not evidence of an inode pin, and two
platforms fail that assumption in opposite directions:

  * NetBSD makes the entry a symlink for directories only -- readlink of
    a regular file's entry fails with EINVAL -- so the probe passed and
    then every pull of a file died in the post-pin check with
    "post-pin readlink failed (race?): f1 Invalid argument".  This is
    not new: the same failure reproduces on the branch base.
  * Cygwin's readlink returns the right path, but opening the magic link
    RE-RESOLVES it.  Renaming a directory out from under a held fd lets
    the magic link reach the replacement, so the pin protected nothing
    while appearing to.  rrsync-sender-leaf-flip caught this as a real
    outside-content leak, not as flakiness.

Only Linux provides the inode-bound magic link this depends on, so
require that explicitly and keep the runtime probes as a guard for
Linux-like environments where /proc is absent or restricted.  Elsewhere
rrsync falls through to the unpinned path, as it already did on the BSDs
and macOS.  proc_self_fd_pins() mirrors the rule so the race tests skip
rather than report the intended gap, and Cygwin's workflow expects both
of them to skip -- rrsync-symlink only ran there because the old probe
wrongly reported support.
2026-07-29 19:58:40 +10:00
Andrew TridgellandLeonid Bugaev 6edb7dea2a rrsync: pin a sender argument where rsync will actually resolve it
88cee089 rewrote every validated argument to /proc/self/fd/N so the
spawned rsync re-resolves it to the pinned inode.  That is right for a
receiver, which open()s its destination, but a sender never opens its
source argument: send_file_list() lstat()s it first, and lstat of a
procfs magic link is always S_IFLNK.  So the sender described the
argument as a symlink and sent no data -- silent data loss on
"rsync -a user@host:file dest/", the most ordinary command there is.
Of the argument shapes now covered, only a trailing-slash directory
survived.

Leonid Bugaev reported the regression, diagnosed the lstat-vs-magic-link
mechanism, and supplied the first regression test.

Which pin is usable depends on what rsync does with the argument:

  * a trailing "/" or "/." directory is opened, not lstat()ed, and rsync
    does follow a symlink there, so it keeps the leaf pin -- verified:
    without it a flipped leaf transfers the outside directory's content;
  * anything else pins one level up and passes the leaf by name.  rsync
    will not follow a symlink at that position (it sends the symlink
    itself), -L/-k/--copy-unsafe-links are already disabled for a
    restricted dir, and rsync's own leaf open is O_NOFOLLOW.

The directory pin resolves normally, including a symlink at its last
component, which is legitimate and which 3.4.4 accepts; the readlink
check afterwards is what proves the held inode is in-tree.  It uses
O_PATH because reaching a known name beneath a directory needs only
search permission, and a mode 0111 parent is an ordinary way to publish
a file without letting it be listed.

Under --relative the transmitted name is the whole argument rather than
its basename, so the pin moves up to where that name starts and the rest
is spelled after a /./ marker.  The client's own first marker wins if it
supplied one, including when nothing follows it; -R is parsed out of the
short-option cluster rather than sniffed for the letter, so the trailing
capability blob (-e.iLsfxC) cannot be mistaken for it.

Directory pins are keyed by (st_dev, st_ino), so a glob whose matches
share a parent inherits one descriptor rather than one per argument.

Every shape now delivers what a pristine 3.4.4 delivers, which is what
rrsync-pull-arg-shapes asserts -- it passes against 3.4.4 itself, so the
expectations are that behaviour and not this implementation's.  The
"-R --no-implied-dirs" case is gated on protocol >= 30: at protocol 29
the receiver rejects it with "invalid path from sender" and transfers
nothing, which 3.4.4 does identically.

Moving the sender's pin off the leaf invalidates rrsync-symlink's oracle,
so it is reworked here rather than left failing.  It patches rsync to a
stub that open()s its last argument, which is a faithful model for an
intermediate path component -- whatever rsync does with the final name,
it must not reach it through a flipped parent -- but not for the leaf: a
sender lstat()s its source and transmits a symlink there rather than
reading through it.  So it now flips an intermediate directory, and the
leaf is covered against the real binary by rrsync-sender-leaf-flip, which
races both a plain file argument and a trailing-slash directory and
asserts no outside CONTENT is delivered rather than requiring a symptom
from a race that may not be won on a given run.  Its trailing-slash half
is RED against a pristine 3.4.4 rrsync, which delivers outside/dir/loot.

rrsync-symlink is now sender-only.  Measured over a 5s race, the stub
reached the outside marker 28 times in 98 runs as a sender and 25 in 97
as a receiver against 3.4.4; with the pin it is 0 as a sender but still
7-9 as a receiver, on the branch base as well as here.  That residual is
the receiver's not-yet-existing-destination fallback, which predates this
work and is tracked in #139 rather than folded in.

Clearing FD_CLOEXEC goes through F_SETFD rather than os.set_inheritable(),
which prefers ioctl(FIONCLEX) and gets EBADF from an O_PATH descriptor on
older kernels -- every sender pull on Ubuntu 18.04 aborted with "Bad file
descriptor" the moment a directory pin was taken.

Co-authored-by: Leonid Bugaev <leonsbox@gmail.com>
2026-07-29 19:58:40 +10:00
Andrew Tridgell 8678d89b2c rrsync: pass the --files-from stdin sentinel through unchecked
A pull with a local --files-from does not send the list file to the
server: it sends the literal "--files-from=-" and streams the names down
the protocol connection.  88cee089 started inode-pinning every checked
option value, so rrsync tried to realpath() and open a file named "-" in
the restricted dir and killed the connection:

    post-realpath open failed (race detected): - No such file or directory

Every --files-from pull through a restricted account was broken; 3.4.4
delivers the files.  Exempt the exact string "-" only, so a list file
that really is a pathname is still validated and pinned -- which the new
test's control case checks, using the command shape rrsync actually
accepts so that it reaches the pathname check rather than dying earlier
at the syntax check.
2026-07-29 19:58:40 +10:00
Andrew Tridgell e7986502cb auth: parse "auth users" with conf_strtok so a leading comma means commas only
auth_server() tokenised on commas AND whitespace, ignoring the documented
comma-only form, so an entry containing a space was torn in two: the rule
the administrator wrote never matched, and a rule they never wrote
appeared from its tail.  For "@Group Name:deny" that means the deny is
skipped and a later :rw entry can match instead -- an authorization
bypass for a member of the denied group.

conf_strtok() already implements the documented behaviour and the
daemon's gid field already uses it (clientserver.c); this consumer was
missed when that one was fixed.

Reported by Andres Berbescu.  Refs #137.
2026-07-29 13:10:54 +10:00
Andrew Tridgell a52cb0abc9 testsuite: a leading comma in "auth users" must split on commas alone
Four modules -- two defect cases and a control apiece -- driving the
defect in both directions.

"spaced" needs no groups at all:

    auth users = ,@nosuchgroup authuser:deny, authuser:rw

  * parsed as documented -- one entry naming a group that does not
    exist, so no match, then "authuser:rw" grants access;
  * split on whitespace -- "@nosuchgroup", then "authuser:deny", which
    matches the username and refuses a transfer that should succeed.

"grpdeny" drives the direction that was actually reported, a member of a
denied group getting in:

    auth users = ,@[! []*:deny, <realuser>:rw

"[! []*" is a wildmatch class holding a space, matching any ordinary
group name -- one with no "/" in it, which wildmatch treats as a path
separator -- whose first character is neither a space nor a "[".  So the
entry contains a space without needing an NSS group named with one,
which a test cannot create.  Parsed as documented the deny fires; split
on whitespace it becomes "@[!" and "[]*:deny", both unterminated classes
that match nothing, and the later ":rw" lets the member in.

The "[" is excluded from the class for the sake of that second half: the
simpler "[! ]*" splits to "]*", which matches any name beginning with
"]", so on a host with such a user the buggy parser would deny for the
wrong reason and look correct.

That half authenticates as the invoking user rather than the
secrets-file name, because the daemon must resolve the name to a real
uid or getallgroups() finds nothing and no group rule of any spelling
could match.

Each direction needs its own control, because "refused" is the expected
outcome of grpdeny and almost anything can produce a refusal.  "plain"
proves the synthetic credential and the transfer work; "grpctl"
(auth users = @*:rw) proves the daemon resolved the real user to a uid,
enumerated its groups, accepted its secret and could store the file.
Without them, pointing grpdeny at a nonexistent module or breaking the
real user's secret both made it pass while proving nothing.

The deny itself is checked in the daemon log, not the client's output.
A client is told only "auth failed" whatever the server decided, so
"denied by rule", "no matching rule" and "password mismatch" are
indistinguishable to it -- and a parse yielding no rules at all produces
"no matching rule", which would satisfy any client-side check without
the deny having matched anything.
2026-07-29 13:10:54 +10:00
Andrew Tridgell ec6c8fd932 github: register filter-leak as an expected skip where it cannot run
filter-leak plants a backup-dir symlink owned by another uid, so it
needs root and skips without it.  Cygwin runs the suite as an ordinary
user, so it skips and the workflow says so.

Not AlmaLinux: that job is privileged, so the test runs there -- listing
it made the run fail with "expected-but-ran".  Caught by the fleet, not
by inspection, which is the argument for running it before merging a
skip-list change.
2026-07-29 11:31:57 +10:00
Andrew Tridgell 5eb99bb6b2 exclude: exempt the daemon's own filter parameters from the confinement
Confining every parse_filter_file() open to the module root also caught
"filter", "include from" and "exclude from" from rsyncd.conf.  Those name
operator-configured paths and pointing them outside the module -- at
/etc/rsync/excludes, say -- is the ordinary way to write them; rsyncd.conf(5)
puts no constraint on where the file lives.  The result was not a refused
rule but a refused connection:

    failed to open exclude file /etc/rsync/excludes:
        Too many levels of symbolic links (40)
    rsync error: error in file IO (code 11) at exclude.c(1582)

with no symlink involved anywhere -- just a regular file outside the module.

Mark the window in which the daemon loads its own parameters and skip the
confinement there.  Everything else, in particular the peer-driven dir-merge
the leak test exercises, is still confined.  Also fix the trailing whitespace
in the original hunk.
2026-07-29 11:31:57 +10:00
Omar Elsayed 4572d1743c exclude: path resolving to operator path supplied --filter file 2026-07-29 11:31:57 +10:00
Omar Elsayed 32f36fa845 filter-leak_test.py: root-owned backup filter symlink leak
This test verifies that a root-owned backup symlink does not leak out-of-tree file contents through a filter file
2026-07-29 11:31:57 +10:00
Andrew Tridgell 5c20cd157a github: expect the merged read-only-inplace tests to skip
Follow-up to 6b885e51/51618b74, which I merged without updating the
per-workflow expected-skip lists, so every fleet run since has reported
a skip mismatch on eight targets.

Both skips are legitimate:

  readonly-partial-abort-mode-regression exits 77 as root ("root
  bypasses the read-only output-file precondition"), and the fleet runs
  most targets as root -- so it only ever executes in a non-root run.

  daemon-leaf-type-race-fchmod needs Darwin and --use-tcp, so outside
  the macOS tcp cell it always skips.

Worth noting rather than burying: this means neither sec-regression test
runs in the fleet's default cells.  The read-only one is exercised only
by a non-root local run, and the leaf-type one only by macOS over TCP.
2026-07-29 10:14:48 +10:00
Andrew Tridgell 8d82b07b54 testsuite: add the macOS setgid regression, and let it find a usable group
The test only means anything when the scratch directory's group is one
the caller cannot grant, since that is what makes macOS refuse the
setgid bit.  Taking that group from the build tree is fine for a
checkout under a shared parent but not for one under a home directory --
there the group is the user's own and the test skips silently.  I only
got RED/GREEN out of it by chgrp'ing the scratch tree by hand.

So it falls back to /private/tmp, which is group wheel.  On macOS as an
ordinary user it runs and passes.

It does NOT run in our macOS CI: that workflow drives the suite with
sudo, and root can grant every group, so the condition cannot exist --
hence the entry in the macOS expected-skip list alongside the others.
Making it run there needs a separate non-root invocation, not attempted
here.  The skip message says which case it is instead of blaming the
scratch group.

The /private/tmp directory is outside SCRATCHDIR, which the harness
cleans, so it gets a mkdtemp() name and an atexit hook: a fixed name in
a sticky world-writable directory would let concurrent runs delete each
other's live fixture, and a leftover owned by another user would make
every later run skip.  Verified on macOS that a run leaves nothing
behind.
2026-07-29 10:12:24 +10:00
Filipe Casal 5f0f8f298e syscall: preserve ordinary mode when setgid is denied
macOS's fchmodat(..., AT_SYMLINK_NOFOLLOW) returns EPERM and applies
NOTHING when the requested setgid bit is ungrantable, so
"rsync -a --chmod=D2750,F0640" exits 23 and leaves the destination at
0755/0700 where 3.4.4 leaves 0750.  fchmod() on an already-open
descriptor does the right thing: it succeeds, drops the setgid bit it
cannot grant, and applies the ordinary bits.

That fd path existed but was fenced behind "#if defined __linux__".
Guard it on O_NOFOLLOW so every platform that can open a leaf without
following a symlink uses it, and leave the fchmodat/fchmodat2 fallbacks
under __linux__.  Measured on macOS with an ungrantable group:

  fchmodat(2750, NOFOLLOW)  EPERM, 0700 -> 0700   (dir and FIFO alike)
  fchmod(fd, 2750)          ok,    0700 -> 0750
  fchmodat(0750, NOFOLLOW)  ok,    0700 -> 0750

A FIFO observed by the lstat takes the pathname call instead, then one
retry without S_ISGID.  Opening a FIFO -- even O_NONBLOCK -- makes this
process a reader for as long as the descriptor lives, which wakes a
writer blocked in open(O_WRONLY) and can cost it a SIGPIPE or the bytes
it writes before we close; the third line above is why that is
avoidable.  This does not make the function FIFO-open-free: the type
comes from the lstat, so a leaf swapped to a FIFO after it is still
opened, and set_file_attrs() opens FIFOs elsewhere for ACL/xattr work.
Closing that needs the open constrained to the observed type, which is
tracked separately.

The retry is a pathname call and does not pin the inode, so a leaf
swapped for another object of the same name is chmod'd instead;
AT_SYMLINK_NOFOLLOW still keeps it off a symlink's target, and the held
parent fd still confines the ancestors, so the out-of-tree boundary is
unaffected.  Only S_ISGID is retried -- clearing S_ISUID too could
discard a bit that was grantable when only setgid caused the failure.
Linux keeps the fd-first order it has always had.

The raced-to-a-symlink refusal after openat() also accepts EMLINK and
EFTYPE.  ELOOP is not universal for O_NOFOLLOW on a symlink -- FreeBSD
documents EMLINK and NetBSD EFTYPE -- so those two silently skipped the
refusal and fell through to the (still symlink-safe) pathname call.

Verified on real macOS: the regression test FAILS on 93a67aa9 with rc=23
and modes 0755/0700, and PASSES here with 0750/0750.
2026-07-29 10:12:24 +10:00
Andrew Tridgell 93a67aa995 github: expect fake-super-backup-fifo-regression to skip on Cygwin
The fleet run for this change reported it as an unexpected skip there.
Cygwin has no real FIFO for fake-super to represent, so the test skips
by design; every other target runs it.
2026-07-28 15:14:43 +10:00
Filipe Casal 4ce54db5ef syscall: preserve fake-super backups as placeholders 2026-07-28 15:14:43 +10:00
Filipe Casal 51618b74c0 testsuite: pin the leaf type of the EACCES chmod recovery
From PR #90, whose code change is superseded by the preceding commit:
that helper already requires S_ISREG on the fd it chmods, and declines
the recovery entirely on the local/chrooted path #90 left untouched.
The test is kept because it is the only oracle for the directory-
substitution case.

Verified on macOS over TCP: FAIL on acfc94ef, PASS on the preceding
commit.  Two limits worth knowing: it needs both Darwin and --use-tcp,
so it only runs in the macOS tcp cell, and its daemon sets
"use chroot = no", so it covers the fd-based branch only.  It also
accepts a run in which the race window was never reached, so it can
report success without having exercised the check.
2026-07-28 13:50:37 +10:00
Filipe Casal 6b885e5175 receiver: restore read-only mode before in-place transfer
The EACCES recovery added by c1d7b5c6 chmods a read-only destination to
0600 so an --inplace update can proceed, and only restores the mode
after the transfer.  Any abort in between -- peer EOF, checksum failure,
a signal -- leaves the file permanently owner-writable.  3.4.4 fails the
transfer and leaves 0444, so this is new exposure in 3.5.0.

Record the existing mode, add only owner-write, open the writable
descriptor, and put the old mode back before any network data is
consumed.  The descriptor stays writable afterwards, so a complete
read-only --inplace update still works.

What this does NOT promise.  Restoration is best effort, not a
guarantee: an unprivileged fchmod() silently drops S_ISGID when the
file's group is outside the process's groups, so 02444 can come back as
0444 with both calls reporting success, and a signal inside the
chmod/open/restore window still strands the relaxed mode.  The window
goes from "the whole transfer" to a few syscalls, which is the point,
but it is not closed.

The helper also requires a regular file, which closes PR #90's finding:
O_NOFOLLOW refuses a symlink at the leaf but not a directory swapped in
after the type probe, and the recovery would otherwise fchmod that
directory from 0755 to 0600.  On the fd-based branch that check is an
fstat() of the descriptor being chmod'd, so it is genuine.  On the
local/chrooted branch it is only a type check on a stable path --
do_stat() follows a leaf symlink and every later call re-resolves the
name -- so that branch is confined by the chroot, not by this check.
Recovery is also skipped outright when the file is already
owner-writable, since adding S_IWUSR cannot be what such an EACCES is
about and each needless chmod risks a special bit.

Reworked from PR #102, which was written against a tree that already had
secure_recv_open() and deleted it: its helper resolved through
secure_relative_open(), dropping the one_inplace operator-path ownership
policy from the recovery window.  (Its initial O_CREAT open toggled
operator_path_resolve by hand and kept the policy; the Linux
protected-regular retry and the recovery did not.)  partial-protected-
regular-retry-linux catches that -- it passes on the base, fails with
the PR as submitted, and passes here.  This version keeps the recovery
on secure_recv_open(..., one_inplace) and gates it on
"use_secure_symlinks || one_inplace" like every other open in the block.
2026-07-28 13:50:37 +10:00
Andrew Tridgell acfc94ef48 testsuite: harden the LD_PRELOAD hook and stop a crash reading as a skip
Follow-ups from review of the lazy-resolution fix:

- Guard resolution against re-entry.  If dlsym() ever reaches an
  interposed function the nested wrapper would recurse; it now takes the
  raw path instead.

- Forward a mode for O_TMPFILE as well as O_CREAT.  rsync itself never
  uses it, but the hook interposes every library in the process.  The
  test is an equality one because Linux defines O_TMPFILE as
  __O_TMPFILE|O_DIRECTORY, so a plain & would also match O_DIRECTORY.

- Treat death by signal as a failure rather than a skip.  The load marker
  is written by the hook, so a crash before that point is
  indistinguishable from the hook never loading -- which is precisely how
  the AlmaLinux SIGSEGV stayed hidden.

Note the signal branch is not exercised by any current configuration:
with the raw openat(2) fallback in place the hook no longer crashes even
when resolution fails, which is why reconstructing the pre-fix behaviour
does not reproduce it.
2026-07-26 19:40:12 +10:00
Andrew Tridgell abcf37a1d3 sender: null-check the anchor before comparing it to the module root
The copy-links confinement gate null-checks module_dir but then passes
anchor to strcmp() without checking it, which the scan-build gate flags:

    sender.c:291:7: warning: Null pointer passed to 1st parameter
        expecting 'nonnull' [core.NonNullParamChecker]

Not reachable today -- the one caller passes module_dir -- but NULL is a
legitimate value for this parameter: secure_relative_open() reads it as
"relative to the cwd", which the else branch relies on.  Only this branch
would dereference it.
2026-07-26 19:40:12 +10:00
Andrew Tridgell 9694994d5c fleettest: add an AlmaLinux 8 target
RHEL-family LTS coverage in the fleet, matching the almalinux-8-build.yml
CI job that until now was the only place this family ran.  Its container
and this VM do not agree on everything, so two box-specific skips are
recorded: no separate filesystem for a cross-device temp dir, and the
old static client the source-only push omits.

fs.protected_regular is enabled on the box (persisted in
/etc/sysctl.d/90-rsync-fleettest.conf) so protected-regular exercises the
real kernel behaviour here instead of skipping.
2026-07-26 19:40:12 +10:00
Andrew Tridgell 5cf902f87f github: correct the AlmaLinux expected-skip list
sender-remove-source-root-anchor runs and passes there -- the job is
privileged and / is writable -- so listing it as an expected skip made
the whole run fail on the mismatch.  partial-protected-regular-retry-linux
is deliberately not added: with the hook fix it runs there too.
2026-07-26 19:40:12 +10:00
Andrew Tridgell bbcef46455 testsuite: resolve the LD_PRELOAD hook lazily, not in its constructor
A preloaded open() interposes for the whole process the moment the loader
maps the library -- including calls made from OTHER shared objects'
constructors.  The order constructors run between unrelated objects is
unspecified, so resolving real_open in our own constructor is a race we
do not always win.

On AlmaLinux 8 we lose it: OPENSSL_init_library() calls open() from its
constructor before ours runs, real_open is still NULL, and the process
dies in the loader:

    #0  0x0000000000000000
    #1  open () from hook.so
    #2  OPENSSL_init_library () from libcrypto.so.1.1
    #3  call_init ... dl-init.c

Every rsync run under the hook segfaulted, the load marker never
appeared, and the test reported "hook was not loaded" -- so a crash on a
supported platform surfaced only as a skip.  Not a glibc-version thing:
ubuntu-1804 (glibc 2.27, older than AlmaLinux 8's 2.28) wins the race and
passes.

Resolve on demand at the top of each wrapper instead, with a raw
openat(2) fallback for the case where even dlsym() is unusable that
early.  The test now runs, and passes, on AlmaLinux 8.
2026-07-26 19:40:12 +10:00
Andrew Tridgell 72f2ceaa88 testsuite: skip the stdio_daemon copy-links test below protocol 30
Its hand-rolled protocol client greets with version 30 and sends a
protocol-30 argument string.  When the run pins the daemon lower the two
sides cannot agree and the client just sits there until it times out, so
the test failed on every check29 target rather than reporting anything
about copy-links.

The behaviour under test is not protocol-specific: the sibling
daemon-copylinks-parent-escape drives the same sender paths with the real
rsync client and passes at protocol 29, so skipping here loses no
coverage.  Registered in the check29 expected-skip lists.
2026-07-25 17:28:54 +10:00
Andrew Tridgell c5bc4e3677 syscall: defer a literal ".." to the walk before the leaf fast paths
secure_walk_at() has two fast paths for the final component that call
openat(ds_cur(&ds), part, ...) directly instead of going through
ds_descend().  A final component of ".." therefore never met the anchor
floor, and what happened depended entirely on the caller's flags:

    ".." with O_DIRECTORY              -> ELOOP          (refused)
    ".." with O_DIRECTORY|O_NOFOLLOW   -> fd for the directory ABOVE
                                          the anchor
    ".." without O_DIRECTORY           -> parent opened, then closed,
                                          EISDIR returned

That was harmless while every literal ".." was rejected at the front
door, but secure_relative_open_at_beneath() now admits them and
documents the held-fd stack as refusing every climb above the anchor.
The sender's own call passes O_RDONLY|O_DIRECTORY and so was never
affected -- but the guarantee the new API advertises has to hold for
whatever flags the next caller picks.

Route a literal "." or ".." through ds_descend() before the leaf fast
paths.  All three flag combinations now refuse a bare ".." with ELOOP,
and t_secure_relpath covers the matrix.
2026-07-25 17:28:54 +10:00
Andrew Tridgell 1a69d2e20d testsuite: hold down both sides of the copy-links ".." loosening
The shipped regression test covers the in-module target that the fix
enables.  The fix is a loosening, though -- the resolver used to refuse
every literal ".." at the front door, which guarded the module boundary
by accident -- so the escape needs an end-to-end guard too, not just the
unit coverage in t_secure_relpath.

Cover a file and a directory symlink in each direction.  Both types are
needed because they take different paths through the sender: the
directory one already resolved ".." via the dirstack walk while the file
one hit the front-door EINVAL, which is exactly the asymmetry reported on
this PR (a "../dir" symlink copied, a "../file" one was skipped).

The oracle is what landed on disk, not the exit status: a refused escape
legitimately makes rsync exit 23.  The in-module assertions matter as
much as the leak ones -- without them "nothing leaked" would also be
satisfied by refusing everything, i.e. by the bug being fixed.
2026-07-25 17:28:54 +10:00
Filipe Casal 4d8cbbecac sender: allow confined parent-relative copy-links targets 2026-07-25 17:28:54 +10:00
Andrew Tridgell 2532e9c17b testsuite: make the basis-xname injection reliable on slow targets
Two races made this test report a vacuous result -- neither FIFO opened,
so no traversal was attempted and there was nothing to confine -- on the
slower fleet VMs.  It failed 9 runs in 12 on NetBSD.

Wait for each FIFO helper to reach its blocking open() before starting
the transfer, instead of assuming a freshly spawned process is already
there, and retry a run that comes back vacuous.  A vacuous run is a setup
failure, not a security signal: an ESCAPE still fails immediately and is
never retried, so the oracle keeps its strength.

With the stale-object fix as well, NetBSD is 15 passes in 15.
2026-07-25 15:20:21 +10:00
Andrew Tridgell 5da6051243 testsuite: never reuse a stale object when building a patched peer
build_patched_rsync() copies the configured tree, including its prebuilt
objects, then rewrites one source and runs make.  copytree() preserves
mtimes, so on a target whose clock lags the host that pushed the tree the
copied sender.o is NEWER than the freshly patched sender.c: make reuses
it and the instrumentation never makes it into the binary.  The test then
drives an unmodified peer and reports a vacuous result -- basis-xname-
traversal did exactly that on NetBSD, whose clock ran ~1h behind (gmake
warned "modification time in the future" during the build).

Drop the object for each patched unit, and the prebuilt binary too, so
neither the compile nor the link can be skipped.  The function already
carried a comment about the same hazard on Cygwin's coarse mtimes.
2026-07-25 15:20:21 +10:00
Andrew Tridgell 4694b73d75 testsuite: let a daemon test move off a port held by other software
claim_ports() fails loudly when a port is occupied, which is correct for
the 36 tests that bind the port themselves: they must not silently drift
away from the number they are about to bind.  start_test_daemon() owns
both the bind and the URL it returns, so it can move instead -- and needs
to, because a fixed test port can be permanently held by unrelated
software on a shared CI box.  An ASUS service was found sitting on 13010
on the Windows/Cygwin target, which no amount of orphan reaping frees, so
daemon-exclude-namebased failed there on every run.

Add claim_free_port(), which tries the preferred port and then a few
nearby ones, and use it at that single seam.  _probe_bindable() grows a
non-fatal mode to support it; its default behaviour is unchanged.
2026-07-25 15:20:21 +10:00
Andrew Tridgell 957ce5038d testsuite: build the O_CLOEXEC probe the way the tree was configured
The probe compiles the real authenticate.c with a hand-written include
list and ignores the CPPFLAGS configure recorded.  Where a dependency
lives outside the default search path -- openssl from brew on macOS --
that fails at <openssl/sha.h>, for reasons unrelated to O_CLOEXEC, so
the test failed permanently on the macOS fleet target.

Take CPPFLAGS (and CC, when the environment does not override it) from
the configured Makefile so the probe matches the production build.
2026-07-25 15:20:21 +10:00
Andrew Tridgell f0949ad0de sender: keep a Cygwin UNC prefix out of the "/"-anchored cleanup
The absolute-source branch strips every leading slash and resolves the
parent beneath "/".  On Cygwin clean_fname() deliberately preserves
exactly two leading slashes, because //server/share is a separate UNC
namespace -- so //server/share/f would be resolved as /server/share/f,
a different object, and the size/mtime guard would then be comparing the
wrong file before the unlink.  That is the same wrong-target removal this
branch exists to prevent.

Decline the confined open for that shape (errno 0) so the caller falls
back to the path-based cleanup, as it did before.  Exactly two slashes
matches clean_fname's own rule: three or more still collapse to one.
2026-07-25 13:38:12 +10:00
Andrew Tridgell 38bc594f87 testsuite: cover the wrong-file removal an absolute -R cleanup could cause
The sibling anchor test only covers the nested case, where re-anchoring
the cleanup at the sender's CWD merely fails with EINVAL.  For a source
that is a direct child of / the parent component is empty, so the
cwd-backed cache handed back the sender's own working directory and
--remove-source-files unlinked a same-named entry there -- the real
consequence of the defect, and silent: the requested source survived and
the exit status was 0.

Needs root and a writable /, so it skips elsewhere; registered as an
expected skip on the non-root and sealed-root platforms.

The decoy's mtime is copied at nanosecond precision on purpose: the
sender's changed-file guard compares sub-second mtime too, and a
whole-second copy makes it skip the removal for an unrelated reason,
which would leave the test passing on a vulnerable build.
2026-07-25 13:38:12 +10:00
Filipe Casal 7053485ac1 sender: anchor absolute relative-source cleanup at root 2026-07-25 13:38:12 +10:00
Andrew Tridgell 05bd16a469 daemon: refuse peer values holding shell syntax in shell hooks
Context-aware quoting is only correct for one level of shell parsing.  A
hook may re-parse the substituted word in a nested shell:

    pre-xfer exec = sh -c 'printf %s %RSYNC_USER_NAME% >out'

The level-1 quotes are removed before the inner shell sees the value, so
an authenticated peer's username still reaches it as syntax however
carefully it was escaped.  Escaping cannot fix this; refuse instead.

A %RSYNC_*% value substituted into a shell-executed hook (early exec,
name converter, pre-/post-xfer exec) is now rejected if it holds any
character that can become shell syntax in any context: quote, backtick,
dollar, backslash, semicolon, ampersand, pipe, redirection, parenthesis,
or a control character.  Word-splitting and glob characters are left
alone -- they cannot execute anything and paths legitimately contain
them.  The refusal is fail-closed and logged: a hook may be an access
check, so silently skipping it is not an option.

Also fix the quote tracker itself, which moved to SHELL_SINGLE_QUOTED on
an apostrophe even inside "...", where it is an ordinary character.  That
made a value in `printf %s "it's %RSYNC_USER_NAME%"` escape for the wrong
context.  With the refusal above this is defence in depth, and it matters
if the refused set is ever narrowed.

The two existing hook-injection tests asserted that a metacharacter value
was quoted and the transfer still succeeded; both now expect the refusal.
2026-07-25 11:48:40 +10:00
Filipe Casal 4b4c6809ed daemon: quote hook expansions for their shell context 2026-07-25 11:48:40 +10:00
Andrew Tridgell 0293df8a81 github: register the platform-gated partial-retry tests as expected skips
partial-protected-regular-retry-policy is Darwin-only and its new Linux
twin is Linux-only, so each skips on the other's platforms; the Linux one
also skips under check29, which cannot negotiate CF_INPLACE_PARTIAL_DIR.
None of that was in any RSYNC_EXPECT_SKIPPED list, which made every Linux
and Cygwin cell report a skip mismatch.
2026-07-25 10:38:42 +10:00
Andrew Tridgell b3a560061b testsuite: cover the Linux EACCES recovery arm via LD_PRELOAD
The existing partial-dir recovery test only runs under dyld interposing,
so it skips everywhere except Darwin -- and the fs.protected_regular
compatibility retry it is meant to cover sits inside "#ifdef linux",
which Darwin never compiles.  That arm therefore had no coverage on any
platform.

Add a Linux twin driven by LD_PRELOAD: hook open/openat to model the
EACCES on the O_CREAT open of the existing partial leaf and swap the
partial dir for a symlink in the recovery window, and hook fstatat (with
an __fxstatat fallback for glibc < 2.33) to model the swap as foreign-
owned so the ownership walk refuses it.  Between the two tests both
recovery arms are now covered.

The staging path needs one_inplace, i.e. the protocol-30
CF_INPLACE_PARTIAL_DIR capability, so skip below that rather than fail a
control the older protocol can never satisfy.

Order the assertions so the escape is reported before the ownership-walk
control: a vulnerable build runs no walk at all, and that must read as an
escape rather than an inconclusive result.  Apply the same ordering to
the Darwin test, whose foreign-owner marker was built but never asserted.
2026-07-25 10:38:42 +10:00
Andrew Tridgell 0bfcd3b0f2 syscall: honor operator_path_resolve in do_chmod_at/do_lchown_at
Every other mutating do_*_at() wrapper (unlink, symlink, link, mknod,
rmdir, open, mkdir, rename) resolves an operator-supplied path through
owner_walk_parent() when operator_path_resolve is set.  do_chmod_at()
and do_lchown_at() did not look at the flag at all, and both hand an
absolute name straight to the unconfined full-path do_chmod()/do_lchown().

set_file_attrs() is called with operator_path_resolve = 1 precisely so
that "a flipped temp-dir parent then can't redirect the chmod/chown"
(rsync.c).  With no held dirfd -- an absolute --temp-dir or
--partial-dir -- both fell back to these two wrappers, so that promise
did not hold.  Give them the same ownership-walk branch the others use.

S_ISLNK(mode) still takes do_chmod()'s lchmod()/setattrlist() path.

The missing branch was spotted by Omar Elsayed in review on the
partial-dir EACCES recovery PR, together with the fix approach.

Suggested-by: Omar Elsayed <omarelsayed161@gmail.com>
2026-07-25 10:38:42 +10:00
Filipe Casal a646ded755 receiver: retain partial-dir policy across EACCES recovery 2026-07-25 10:38:42 +10:00
Andrew Tridgell 470cb86bdf sender: honor the do_*() guards when removing a source file
secure_remove_source_file() called unlinkat() directly, dropping the
dry_run no-op and the read-only/list-only refusal that do_unlink()
applies on the non-fd path.  That is what let --only-write-batch (which
implies dry_run) really delete the source files once a MSG_SUCCESS
reached the sender.  Use do_unlink_atfd(), which carries both guards.
2026-07-25 09:12:57 +10:00
Codex c0e6948d0f receiver: do not acknowledge batch-only files as installed 2026-07-25 09:12:57 +10:00
Codex 8367407f98 support: retain Python 3.7 compatibility 2026-07-25 06:57:21 +10:00
Codex b471a29888 authenticate: build without O_CLOEXEC 2026-07-25 06:45:26 +10:00
Andrew Tridgell 7b16872eff syscall: silence scan-build dead-store in do_fchmodat_nofollow fallback
When neither AT_FDCWD nor AT_SYMLINK_NOFOLLOW is available, the function body is
a no-op warning that never reads mode or dfd, so the leading 'mode &= CHMOD_BITS'
became a dead store and dfd an unused parameter -- which the pinned clang-18
scan-build gate flags (deadcode.DeadStores).  Move the mask inside the
AT_SYMLINK_NOFOLLOW guard where mode is actually used, and mark dfd/mode used in
the fallback.  No behavior change on any platform that has the symlink-safe
primitive.
2026-07-24 16:01:48 +10:00
Andrew Tridgell 7aea9d5f8e github: register dot-dir delete-scope tests as expected skips
malicious-dot-dir-delete-scope and peer-legacy-implied-delete-scope both need a
real TCP socket (require_tcp), so they skip on the pipe and protocol check
passes.  Add them to RSYNC_EXPECT_SKIPPED for the check/check30/check29 steps so
the CI skip-set matches.  (The squash-merge of the dot-content-scope fix dropped
this registration.)
2026-07-24 15:22:58 +10:00
Codex db380b62ac flist: keep synthetic and legacy implied parents non-content 2026-07-24 15:14:37 +10:00
Andrew Tridgell 4ce6d097fd github: register dot-file transfer-root tests as expected skips
daemon-dot-file-force-wipe and malicious-dot-file-delete-scope both need a real
TCP socket (require_tcp), so they skip on the pipe and protocol check passes.
Add them to RSYNC_EXPECT_SKIPPED for the check/check30/check29 steps so the
fleet skip-set matches.
2026-07-23 15:39:14 +10:00
Codex 21fade1bbb flist: reject non-directory transfer-root entries 2026-07-23 15:39:14 +10:00
Andrew Tridgell ab4e81d749 github: register malicious-server-partial-basis-symlink-overwrite as an expected skip
The new test needs a real TCP socket (require_tcp), so it skips on the pipe and
protocol check passes.  Add it to RSYNC_EXPECT_SKIPPED for the check/check30/
check29 steps so the fleet skip-set matches.
2026-07-23 14:47:21 +10:00
Andrew Tridgell fd86492913 receiver: only reject unconfined partial basis when in-place partial is active
The daemon rejection for a peer-selected FNAMECMP_PARTIAL_DIR basis that the
confined open declined fired at every protocol.  In-place partial updates are
only negotiated at protocol 30+ (CF_INPLACE_PARTIAL_DIR); at protocol 29 no
partial-basis redirect is possible, and the receiver already handled such a
transfer safely by completing it with no basis.  Gate the abort on
inplace_partial so a pre-30 daemon falls back to the safe no-basis path instead
of aborting a legitimate transfer with a protocol error.

Fixes operator-path-partial-dir-daemon at protocol 29.
2026-07-23 14:47:21 +10:00
Codex cfd40f55cb receiver: confine peer-selected partial basis paths 2026-07-23 14:47:21 +10:00
Andrew Tridgell de9000eb30 testsuite: add files-from-leak module-confinement test
Differential test for the daemon files-from/backup-symlink out-of-module read.
It races a --backup-dir push against a parent-swap flipper until a root-owned
backup symlink to an out-of-module secret lands in the backup tree, then tries
--files-from=:backup/sub/<name> and fails if the secret's content is read back
as the file list.  RED before the module-root confinement, GREEN after.

Requires root plus an untrusted uid to plant the cross-uid symlink; skips
otherwise.  Registered in the Cygwin expected-skip list.

Based on a report and proof-of-concept test by seks99x.
2026-07-23 08:28:36 +10:00
Andrew Tridgell 3fe1ed512c rsync: confine the daemon files-from open to the module root
A daemon serving a writable, non-chrooted module reads a client-requested
--files-from=:LIST through open_no_attacker_symlinks(), which follows a
symlink owned by uid 0 or the euid.  The module-root confinement in that
resolver (abspath_excluded_by_module) only fires when operator_path_resolve
is set, and this open left it clear -- so a trusted-owned symlink whose
target escapes the module was followed.

An attacker can obtain such a symlink without owning it: a --backup-dir push
makes the daemon back up the old destination symlink with the daemon's own
(root) ownership, and a parent-swap race can leave that root-owned backup
symlink pointing outside the module.  A later --files-from=:backup/... then
reads out-of-module file content as the file list, bypassing the same-uid
ownership constraint that normally protects files-from.

Set operator_path_resolve around the files-from open so the ownership walk
also refuses a trusted-owned symlink that redirects the list outside the
module root.  A daemon has no rsyncd.conf "files from" of its own, so this
path is always client-requested and confining it is unconditional.  No-op off
a daemon (the module-root check only fires when am_daemon).

The same ownership-walk opener backs the daemon merge/--exclude-from reads in
exclude.c, but those also load the module's own "include from"/"exclude from"
admin files, which on a non-chrooted module may legitimately live outside the
module; confining them there needs a client-vs-admin distinction and is left
to a separate change.
2026-07-23 08:28:36 +10:00
Andrew Tridgell 3b826d6683 github: register basis-xname-traversal in the Cygwin expected-skip list
The basis-xname-traversal test builds an instrumented sender via
build_patched_rsync(), which skips on Cygwin (coarse NTFS mtimes leave the
patched unit unbuilt, and forcing the rebuild trips -fno-common relinks). Add
it to the Cygwin RSYNC_EXPECT_SKIPPED set so its clean skip there is expected
rather than a skip-mismatch.
2026-07-22 14:50:55 +10:00
Andrew Tridgell de6ed4724d rsync: sanitize the peer-supplied basis xname on the client too
read_ndx_and_attrs() sanitized the wire-supplied xname (the alternate-basis
leaf name sent with ITEM_XNAME_FOLLOWS) only when sanitize_paths was set,
which is the daemon side. A client receiver has sanitize_paths == 0, so a
malicious server could send an xname containing ".." and, joined to an
operator basedir (--link-dest / --compare-dest / --copy-dest, or the fuzzy
dir), have the client open an out-of-tree file as the delta basis -- a
client-side arbitrary-read / file-existence-oracle / FIFO-hang. The ownership
walk in secure_basis_open() does not stop this: it deliberately follows a
plain ".." to a regular file (the legitimate --link-dest=../01 sibling, #915)
and only refuses foreign-owned symlink components.

Sanitize xname unconditionally. The operator basedir may legitimately be
relative, but the leaf name that arrives over the wire never legitimately
needs ".." or a leading "/".

Reported by z3r0s.
2026-07-22 14:50:55 +10:00
Andrew Tridgell d978342145 testsuite: add basis-xname-traversal RED test
Builds a malicious daemon-sender (env-gated xname injection patched into
sender.c) and pulls with --link-dest through the production receiver. A FIFO
one level above the link-dest dir, plus a helper blocked in open(O_WRONLY),
detects whether the receiver opened the traversed "../secret" basis. RED on
an unsanitized-xname receiver, GREEN once xname is sanitized.
2026-07-22 14:50:55 +10:00
Andrew Tridgell 594ab1e194 github: register msg-io-timeout-overflow in the Cygwin expected-skip list
The test builds a -fwrapv rsync via build_patched_rsync(), which skips on
Cygwin, so mark its clean skip there as expected.
2026-07-21 15:38:37 +10:00
Andrew Tridgell f10f666306 testsuite: add msg-io-timeout-overflow test
Builds the tree under test with -fwrapv (build_patched_rsync gains an
append_cflags option, which drops the copied tree's prebuilt objects so the flag
is actually applied on the full rebuild) to make the signed overflow
deterministic, then drives set_io_timeout(INT_MAX) via --timeout and asserts the
copy completes instead of spinning in the tight select()-EINVAL loop.  RED on the
unfixed computation, GREEN once the arithmetic is overflow-safe.
2026-07-21 15:38:37 +10:00
Andrew Tridgell 93c0e0144f io: make set_io_timeout() arithmetic overflow-safe
io_timeout can reach INT_MAX -- from an operator --timeout (options.c parses it
as a plain int, unbounded and even negative) or a peer's MSG_IO_TIMEOUT (now
also capped at 86400 in read_a_msg).  Several signed computations then misbehave:

 * allowed_lull = (io_timeout + 1) / 2 overflows to a negative allowed_lull /
   select_timeout; select() then returns EINVAL on the negative tv_sec, which
   isn't EBADF, so the read loop spins at 100% CPU forever (io_timeout ~= 68
   years never fires check_timeout), plus a keepalive flood.  Compute
   ceil(io_timeout/2) in a wider type so "+ 1" cannot overflow.

 * the generator and sender derive an int loop-check limit as allowed_lull * 5
   (generator.c, sender.c), which overflows for a large allowed_lull.  Cap
   allowed_lull so that product stays in range -- invisible to real use, as
   allowed_lull is the keep-alive half-interval and INT_MAX/5 seconds is over
   13 years.

 * a negative --timeout drove allowed_lull / select_timeout negative the same
   way; treat secs < 0 as "no timeout" up front.

The overflows are undefined behaviour, so plain -O2 gcc/clang happen to keep
select_timeout at 60, but -fwrapv / -fno-strict-overflow (common hardening) wrap
to the spin and -ftrapv aborts.

Reported by z3r0s.
2026-07-21 15:38:37 +10:00
Gogs e6044af38f io: cap MSG_IO_TIMEOUT value to prevent signed integer overflow
A malicious server can send MSG_IO_TIMEOUT with val near INT_MAX
(0x7FFFFFFF). The existing val <= 0 guard prevents timeout disabling,
but a large positive value passes through to set_io_timeout() where
(io_timeout + 1) / 2 overflows signed int, wrapping allowed_lull and
select_timeout negative. Every subsequent select() returns EINVAL
immediately, trapping the client in a tight CPU loop.

Cap the accepted timeout at 86400 seconds (24 hours), which is well
above any practical timeout and avoids the overflow in set_io_timeout().

Reported-by: z3r0s <https://github.com/z3r0s6>
2026-07-21 15:38:37 +10:00
Andrew Tridgell 8c78fe8e0b docs: NEWS.md/SECURITY.md for the post-notification hardening batch
Document the security and bug fixes integrated after the initial 3.5.0
security-fix set: peer io_error masking (io.c + flist trailer), log-file
control-character escaping (CWE-117), the --safe-links/--backup hard-link
bypass, the operator-path backup leaf sinks (do_symlink_at/do_rmdir_at), the
hash_search() chain bound (issue #217), and the clean_fname/robust_rename
resolver hardenings; plus the %%, .cvsignore "!", --chmod=a+s and
bracket-expression case-fold bug fixes.

NEWS.md gains the new SECURITY RELATED items and a BUG FIXES section;
SECURITY.md notes the peer error-flag masking, log-injection escaping and
hash_search bound in the malicious-peer section.
2026-07-20 15:24:06 +10:00
Andrew Tridgell 15141f6803 testsuite: make the partial-dir reject check protocol-aware
The outside-module victim-intact oracle holds at every protocol and
stays unconditional.  The stronger 'daemon actively rejects the forced
--partial-dir operand' behavior is protocol-30+ only: at protocol 29 the
operand is handled differently and the transfer completes normally with
the victim still untouched (verified).  So gate that check on protocol
30+, and at protocol 29 require the known-good normal outcome (rc==0,
dest replaced) rather than skipping -- keeping the branch non-vacuous.
Surfaced by the fleet's proto29 pass.
2026-07-20 14:07:05 +10:00
Andrew Tridgell 70c121d86f testsuite: skip the %C sub-case of ki58 at protocol < 30
%C only renders a hex digest for a canonical checksum.  At protocol < 30
the negotiated file checksum is a non-canonical MD4 variant, so %C (via
sum_as_hex) renders empty -- there is no digest to compare and no F_SUM
read to over-run.  Gate the over-wide %C sub-case on protocol 30+, where
checksum_for() still fails on a missing digest so a real regression is
caught.  The %% literal checks are protocol-independent and still run.
Surfaced by the fleet proto29 pass on ubuntu-2204/2404/2604.
2026-07-20 14:07:05 +10:00
Andrew Tridgell 78767976ef github: register operator-path-backup-{rmdir,symlink} in the Cygwin skip set
Both new tests skip unless run as root; the Cygwin CI job runs non-root
and enforces an exact RSYNC_EXPECT_SKIPPED set, so an unregistered skip
fails the suite.  Add them to the list (they run and pass on the
root/sudo Linux, BSD, Solaris and macOS targets).
2026-07-20 14:07:05 +10:00
Omar ElsayedandAndrew Tridgell 71b01c4b5c syscall: confine operator paths in do_symlink_at and do_rmdir_at
do_symlink_at() and do_rmdir_at() were the only operator-path syscall
wrappers still missing the operator_path_resolve branch that
do_mknod_at()/do_open_at()/do_rename_at()/do_unlink_at() already carry:
an absolute operator path (e.g. an absolute --backup-dir) took the
'*path == "/"' arm straight to the bare do_symlink()/rmdir(), whose
libc path resolution follows a parent-component symlink.  A local
attacker who owns a parent component of the backup tree could thus
redirect a backup symlink creation or a backup-dir rmdir outside the
intended tree.

Route both through owner_walk_parent() + symlinkat()/unlinkat() when
operator_path_resolve is set, mirroring the existing wrappers, so a
foreign-owned parent component is refused while the operator's own is
followed.  symlink_optout_allowed() (--insecure-links / 'insecure
links =') restores the legacy following.

Tests: operator-path-backup-symlink and operator-path-backup-rmdir
drive a live parent-component swap (native C flipper) against a local
--backup-dir push and confirm nothing escapes the backup tree (RED
before, GREEN after).

Co-authored-by: Andrew Tridgell <andrew@tridgell.net>
2026-07-20 14:07:05 +10:00
Andrew Tridgell 7105287d46 github: register source-change-size-continues in the macOS/Cygwin skip sets
The new source-change-size-continues test skips on non-Linux platforms
(it needs LD_PRELOAD + /proc/self/fd).  macOS and Cygwin are the two
non-Linux CI jobs that enforce RSYNC_EXPECT_SKIPPED, so an unregistered
skip there fails the suite (runtests.py treats an unexpected skip as a
mismatch).  Add the test to both lists.  The Linux enforcing jobs
(ubuntu, ubuntu-22.04, almalinux-8) run it and are unaffected; the
BSD/Solaris jobs do not enforce the skip set.  The mac2/cygwin fleet
targets read these same lists, so the fleet is covered too.
2026-07-20 14:07:05 +10:00
Zen Dodd d2aa9fb034 testsuite: strengthen regression oracles 2026-07-20 14:07:05 +10:00
Andrew Tridgell 527b61afb9 testsuite: correct the ki62 comment about MSG_IO_ERROR coverage
The header claimed protocol-level crafting of MSG_IO_ERROR is covered by
msg-io-* crafted-server tests; no such test exists (msg-io-timeout-zero
crafts MSG_IO_TIMEOUT).  Say what the test actually locks down.
2026-07-20 14:07:05 +10:00
Andrew Tridgell 912644c94d log: don't parse the second '%' of '%%' as a new format escape
log_formatted() renders %% as a literal '%', but log_format_has() still
rescanned the literal '%' as the start of a new escape, so a format such
as --out-format='%%i' misdetected the 'i' and turned on itemizing (and
'%%b'/'%%c'/'%%C' likewise perturbed log_before_transfer and checksum
retention).  Skip the literal so both parsers agree.

Extends the ki58 test: an attribute-only change must not be logged under
--out-format='%%i %n', while a transferred file still renders the
literal '%i'.
2026-07-20 14:07:05 +10:00
Andrew Tridgell 7761384497 backup: fail closed when a symlink target is unreadable
The --safe-links guard on the backup hard-link fast path only skipped the
backup when do_readlink() succeeded (llen > 0) and the target escaped.  A
failed readlink (e.g. the link vanished between the lstat and the
readlink) fell through to link_or_rename(), which could hard-link the
symlink into the backup area unchecked -- the same bypass the guard was
added to close.

Fail closed: skip the backup when the target can't be read.

Extend the KI-72 test with a safe (in-tree) symlink case to confirm the
guard doesn't over-block and drop legitimate safe symlinks.
2026-07-20 14:07:05 +10:00
Andrew Tridgell 9da5b5450f flist: mask peer-supplied io_error to defined bits
recv_file_list() OR's the wire-supplied end-of-list error value straight
into the local io_error at three sites (the varint-flags path, the
XMIT_IO_ERROR_ENDLIST byte path, and the protocol < 30 int flag).  Like
the MSG_IO_ERROR path fixed in io.c, a malicious peer could set arbitrary
undefined bits, which then accumulate in io_error and can be re-forwarded
to other peers.

Mask each with IOERR_VALID_MASK so only the defined IOERR_* bits survive,
matching the io.c MSG_IO_ERROR handler.
2026-07-20 14:07:05 +10:00
Leonid Bugaev ede9a1a3ba Fix man page errors: --max-alloc=0 contradiction + EXIT VALUES table
- Update --max-alloc documentation: 0 is now rejected (CVE-2026-53794),
  no longer means SIZE_MAX
- Remove nonexistent exit code 6 (never emitted by any code path)
- Add missing exit codes 15 (RERR_CRASHED), 16 (RERR_TERMINATED),
  19 (RERR_SIGNAL1)
- Fix code 20: SIGUSR1 is actually code 19, not 20
  (code 20 is SIGINT/SIGTERM/SIGHUP)
2026-07-20 14:07:05 +10:00
Andrew Tridgell 0f5986cda8 github: register ki62-io-error-mask in the expected-skip lists
ki62-io-error-mask needs --use-tcp (it SIGKILLs the daemon-side sender), so it
skips under the default pipe-mode make check.  Register it alongside the other
tcp-only daemon test (daemon-argv-limit) in every workflow that pins
RSYNC_EXPECT_SKIPPED, so its pipe-mode skip is expected rather than flagged.
2026-07-20 14:07:05 +10:00
Andrew Tridgell 7490ad2420 testsuite: harden ki58/ki62 tests for the fleet
Two of the integrated MC/DC-audit tests were environment-fragile:

- ki58: asserted the exact string '100% done percentfile', but %f expands to the
  transfer-relative path (with leading dirs) when the source is an absolute path,
  so the basename assumption failed.  Assert the literal-percent escape '100% done '
  plus the file name instead (still RED on a broken %%: it emits '100%% done').

- ki62: killed daemon.kill() -- the listener -- but rsyncd forks a child per
  connection, so the child sender kept streaming and the receiver hung.  Parse the
  transfer child's pid from the daemon log ('[pid] rsync on <mod>/') and kill that.
  killpg is not usable: the test daemon shares this test's process group.

Fixes are unchanged; only the test drivers.
2026-07-20 14:07:05 +10:00
Leonid Bugaev 3534cab477 Mask incoming MSG_IO_ERROR to defined bits only
A peer-supplied MSG_IO_ERROR value was OR'd into the local io_error
without masking, allowing a malicious peer to set arbitrary bits that
propagate to exit codes and get re-forwarded to other peers.

Fix: mask the incoming value with IOERR_VALID_MASK (IOERR_GENERAL |
IOERR_VANISHED | IOERR_DEL_LIMIT) before OR'ing.

Test: testsuite/ki62-io-error-mask_test.py
2026-07-20 14:07:05 +10:00
Leonid Bugaev cfbf411fc3 Add %% escape to --out-format and --log-file-format strings
The log_formatted() switch had no case for '%', so %% did not
produce a literal percent character.  Add case '%' to output
a single '%' character, matching the printf convention.

Test: testsuite/ki58-log-format-percent_test.py
2026-07-20 14:07:05 +10:00
Leonid Bugaev feb26929de Fix spurious abort when CVS .cvsignore contains '!' clear-list token
The CLEAR_LIST guard in parse_rule_tok checked rule->rflags for
FILTRULE_NO_PREFIXES, but NO_PREFIXES is a template-level flag
excluded from FILTRULES_FROM_CONTAINER inheritance.  The guard
was always true for CVS rules, causing RERR_SYNTAX abort instead
of clearing the list.

Fix: check template->rflags instead of rule->rflags.

Regression test: testsuite/ki73-cvs-clear-list_test.py
2026-07-20 14:07:05 +10:00
Leonid Bugaev f602f7d86d Fix bypass of --safe-links when --backup hardlinks a symlink
When CAN_HARDLINK_SYMLINK is defined (Linux, macOS), the backup
hardlink fast path at link_or_rename() succeeded for symlinks and
'goto success' skipped the safe_symlinks check.  An escaping symlink
(pointing outside the transfer tree) was silently preserved in the
backup area despite --safe-links.

Fix: check safe_symlinks BEFORE the hardlink path.  If the symlink
target escapes, skip the backup (same as non-hardlink-symlink systems).

Regression test: testsuite/ki72-safe-links-backup_test.py
2026-07-20 14:07:05 +10:00
Andrew Tridgell 81a88dc590 github: exclude the flipper tests on the OpenBSD CI VM
The vmactions OpenBSD VM has the same kernel bugs as the fleet's
OpenBSD box: a connect()-under-rename-load lost-wakeup and an FFS
rename-storm corruption that hang the symlink-race flipper tests to
the 300s timeout for non-rsync reasons (sender-remove-source-secure
just did so in the --use-tcp pass).  Exclude the same three tests the
fleet config already excludes there; the protections they exercise
are verified on the Linux and other BSD targets.
2026-07-20 14:07:05 +10:00
Andrew Tridgell da4354303e docs: fix errors in the rsync.1.md update
Corrections to the preceding man page rewrite, found in review:

- --max-alloc: 0 is rejected as invalid since 3.5.0 (CVE-2026-53794),
  it does not mean "no limit"
- -t: restore "next transfer" -- it is the subsequent run that behaves
  like --ignore-times when times aren't preserved; also note that the
  timestamp-range limit is protocol < 30, not just an old remote
- --max-delete: re-add the caution that a pre-3.0.0 CLIENT treats
  --max-delete=0 as unlimited (a new client forwards it as -1, so the
  client version is the boundary that matters)
- --stats: the deleted-files line needs negotiated protocol >= 31, not
  just a new remote rsync
- --version: the server does not ignore it; only the repeated-option
  JSON output is client-side only
- SECURITY: restore the sentence distinguishing the pre-transfer -c
  checksum from the whole-file transfer-verification checksum (which
  runs unless --checksum-choice=none); fix a singular/plural clash in
  the escape-path paragraph
- remove a leftover XXX comment marker (the paragraph it questioned is
  correct: user@ sets the rsync module user, ssh -l sets the login)
- typo/mechanical: "If it is sufficient", "is output in a JSON",
  missing space before --read-batch, unbalanced paren after
  rsyncd.conf(5), missing "the" before --whole-file, trailing
  whitespace
2026-07-20 14:07:05 +10:00
Paul Mackerras 96ed617a33 docs: Update rsync.1.md man page
Updates to make statements more definite and explicit and improve
English expression, including:

- Globally change dir to directory, arg to argument, parens to
  parentheses.

- Remove some references to behaviour of past versions, on the grounds
  that the man page should describe the current version, and only
  describe past behaviour when talking about how to get a current rsync
  client to deal with an older remote rsync version.

- In most cases where a version number is mentioned, add the month
  and year of release of that version.

- Be more explicit about when "file" means "regular file" vs. "any
  filesystem object".

- Remove some parentheses, where the parenthesized comment is important
  enough to warrant making it a first-class part of the sentence.
2026-07-20 14:06:02 +10:00
Andrew Tridgell c53d107f97 util1: drop null-tests on robust_rename's from/to args
Both callers pass non-null from/to, but the 'to &&' test taught the
clang analyzer that to may be null, and it then walked a null to
through copy_file -> unlink_and_reopen -> robust_unlink into glibc's
nonnull-annotated strlcpy, failing the scan-build gate.  The args are
required non-null, so test the first byte directly.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 4992f7e98b testsuite: make the t_safe_arg link portable to BSD and Solaris make
T_SAFE_ARG_OBJ used $(filter-out main.o,$(OBJS)), a GNU-make-only
function that BSD make and Solaris make silently expand to nothing,
so t_safe_arg linked with no rsync objects at all and failed with
undefined symbols (am_daemon, io_timeout, send_msg_int, ...).  The
fleet missed this because those VMs build with gmake; the FreeBSD,
OpenBSD and Solaris CI VMs use the native make.

Spell out the object list via a new OBJS1_NO_MAIN macro instead;
plain macro expansion works in every make.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 5022446815 github: gate PR CI on a 'run-ci' label to save CI minutes
Now that pull_request triggers fire for any base branch, limit runner
minutes by skipping PR jobs unless the PR carries the 'run-ci' label.
Applying a label needs triage access, so a fork PR can't enable the
matrix by itself.  The 'labeled' trigger type is added so applying the
label starts a run immediately; skipped jobs cost no runner minutes.
Push, schedule and manual dispatch runs are unaffected.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 39fbc02b79 github: run CI on pull requests regardless of base branch
The pull_request triggers were filtered to base branch master, so PRs
targeting staging branches (e.g. pr-rsync350-sec-fixes) got no CI at
all.  A PR is a deliberate request for review, so run the checks on
every PR; the push trigger keeps its master filter to avoid running
the whole matrix on every WIP branch push.
2026-07-20 14:05:32 +10:00
Andrew Tridgell a308186f4b testsuite: cover content changes during a transfer (shrink, vanish)
Adds a reusable harness (mutatefns.py) that mutates a source resource in the
window after the file list is built but before that resource is sent -- the
same window the growing-file regression exposed -- plus tests for a source
file that shrinks or vanishes mid-transfer.  Both are handled gracefully
today (no protocol abort, later files still transfer); these lock that in.

Note: metadata (perms/mtime/xattr/ACL) is captured at flist-scan time and
applied from the flist, so a data-phase change is a no-op -- content is the
only resource re-read at send time, so it is the meaningful surface here.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 42f7c32ce5 receiver: don't abort the transfer when a file grows during the run
The sender records each file's length when it scans the file list, then
re-stats and maps the file at its current size when it later sends the
data (sender.c: do_fstat + map_file + match_sums use st.st_size).  A file
appended to in that window -- a live log written during a nightly backup
is the common case -- is transmitted longer than its flist-recorded
length.

A hardening check added in d33e599c (offset + i > total_size and
offset + len > total_size in receive_data) turned that benign, common
condition into a fatal "received more data than file length"
RERR_PROTOCOL, tearing down the whole connection so every file after the
growing one is skipped.  Stock rsync has no such check: the receiver
writes to a temp file, so the extra bytes just extend it, and the
whole-file checksum-verify already contains a malicious sender -- the
bound bought little while breaking a routine case.

Remove both checks to restore the stock behavior.

Regression test: testsuite/growing-file_test.py (RED before, GREEN after).
2026-07-20 14:05:32 +10:00
Andrew Tridgell 94a7ccd63d testsuite: link t_safe_arg against real objects (portable, drop --gc-sections)
The KI-54 helper linked options.o compiled with -ffunction-sections and relied on
-Wl,--gc-sections to drop the option parser.  That is GNU-ld-only: the fleet
showed macOS (ld64; also its custom rule dropped the openssl include path) and the
cygwin PE linker leave parse_arguments and its deps undefined, failing the build
of every CHECK_PROGS target on those hosts.  Instead link the real rsync objects
(the same set as the rsync binary, minus main.o -- supplied renamed via
t_safe_arg_main.o) so safe_arg's deps all resolve with a plain link on every
platform.  t_safe_arg_main.o depends on $(HEADERS) so the generated proto.h is
built before it under a parallel make.  No behaviour change to the test itself.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell d443e8dc93 wildmatch: fold bracket-expression pattern chars under force_lower_case
dowild()'s literal path folds the pattern char, but the [class]/[a-z] path
compared unfolded pattern bytes against the (folded) text, so iwildmatch() was
still case-asymmetric for character classes and ranges -- e.g. a daemon
'hosts deny = [A-Z]*.EVIL.COM' failed to match a lower-case host (access-control
fail-open, same class as the literal case).  Fold p_ch for the escaped-member,
range-endpoint and plain-member comparisons; the range start (prev_ch) picks up
the folded value too.  Only active under force_lower_case (iwildmatch), so
case-sensitive wildmatch() is unchanged.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell 1777c884cf testsuite: extend iwildmatch test to bracket expressions (KI-53 follow-up)
The KI-53 fix folds only the literal-match path, so a bracket-expression
pattern ([A-Z], [ABC], [\A]) still compares unfolded pattern bytes against the
folded text -- an upper-case character class / range fails to match a lower-case
host, the same access-control fail-open class as the literal case.  Add bracket
cases (plain, range, mid-pattern, and escaped member; RED until the class/range
path folds too).

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell fbeb553b73 util1: fix clean_fname ".." collapse off-by-one
After the backward walk, s points at the first char of the prior component and
s[-1] is its leading '/', so the boundary test must read s[-1] (not *s) and t
must reset to s (not s+1).  The old off-by-one left CFN_COLLAPSE_DOT_DOT_DIRS
dead for all multi-component and absolute paths.  The peer-traversal guard
CFN_REFUSE_DOT_DOT_DIRS is checked first and is unaffected, so this is a
normalization-correctness fix, not a traversal hole.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell 07bf9af5b5 testsuite: add clean_fname ".." collapse test (KI-50)
t_clean_fname links the real util1.o and checks that clean_fname() with
CFN_COLLAPSE_DOT_DOT_DIRS collapses ".." for multi-component and absolute
paths.  RED before the fix: an off-by-one left the collapse dead for all such
paths.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell fd4c75116b util1: confine operator paths in the robust_rename EXDEV fallback
The cross-filesystem fallback copied to the dest and unlinked the source without
operator-path confinement, so an absolute --temp-dir/--partial-dir on another
filesystem was opened/unlinked via plain libc -- a raced parent symlink could
redirect the dest-write or source-unlink out of the module.  The source READ was
already confined; flip operator_path_resolve for an absolute (operator) path
around the copy_file dest-open and the do_unlink_at, leaving relative in-module
paths on the secure_relative_open arm.

This is the EXDEV-fallback backstop for the same absolute --partial-dir /
foreign-owned-parent-symlink escape that operator-path-partial-dir_test.py
already exercises at the handle_partial_dir() layer (which confines the staging
dir before this code runs).  A dedicated test for the EXDEV copy_file path itself
would need the main tmp->final rename to fail first and then hit EXDEV on a
foreign-owned raced parent -- a nested, timing-dependent trigger.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell ad64e99590 log: escape control chars written to the log file
logit() wrote the message to the log file with a raw fprintf, so an
attacker-controlled filename could inject terminal escapes that an admin later
executes when cat'ing the log (CWE-117).  Route it through filtered_fwrite(),
keeping the trailing newline raw.  Also escape C1 controls (0x80-0x9f, incl CSI)
on filtered_fwrite's use_isprint=0 path, which previously caught only C0.

Reported-by: Leonid Bugaev

The C1 escaping is gated by an escape_c1 flag set only for the log path, so
--8-bit-output / iconv terminal output still passes 8-bit bytes (incl. UTF-8)
through unchanged.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 674c175e31 testsuite: add log control-char escaping test (KI-51/52)
Transfers files whose names carry C0 (0x1b) and C1 (0x9b) control bytes via
--log-file and checks the log contains no raw control bytes (only \#NNN
escapes).  RED before the fix: logit() writes the raw filename to the log
(CWE-117), and C1 controls slip through filtered_fwrite.  Skips if the fs
rejects control-char names.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell 0d0e505902 chmod: a+s must set both setuid and setgid
parse_chmod()'s 'a' clause set the "where" bits but not topbits, so "a+s" fell
through to setuid only and dropped setgid (chmod(1) sets both).  Add
S_ISUID|S_ISGID to topbits for 'a'.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell ce0e1c3ced testsuite: add chmod a+s set-id test (KI-55)
Transfers with --chmod=a+s / u+s / g+s (needs -p to apply set-id bits) and
checks the resulting mode.  RED before the fix: a+s sets setuid only and
drops setgid.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell 22efd249ad options: fix safe_arg() uninitialized-byte leak in filename-mode quoting
The escape counter reserved a slot for every backslash, but the writer
suppresses the escaping backslash before a wildcard and -- via the
strchr(WILD_CHARS, '\0') footgun -- on a trailing backslash, so counter and
writer disagreed and left an uninitialized heap byte in the returned string that
is handed to the legacy remote shell (protect_args=0).  Make the counter mirror
the writer and guard the strchr with f[1] (which also correctly doubles a
trailing backslash).

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell a83b76aa30 testsuite: add safe_arg uninitialized-byte test (KI-54)
t_safe_arg links the real options.o (via --gc-sections so only safe_arg is
pulled in), poisons the heap, and checks that filename-mode quoting is exact.
RED before the fix: the counter/writer backslash miscount leaks an
uninitialized heap byte into the returned string.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell 7a6d45b02b wildmatch: fold the pattern char in iwildmatch (case-insensitive)
iwildmatch() folded only the text to lower case, not the pattern, so it was
asymmetric rather than case-insensitive.  An upper-case "hosts deny" token
(e.g. *.BADDOMAIN.COM) then failed to match a lower-case peer name and the
blocked host was admitted -- an access-control fail-open.  Fold the pattern
char too under force_lower_case.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell 54ee418964 testsuite: add iwildmatch case-fold test (KI-53)
t_iwildmatch links the real lib/wildmatch.o and checks that iwildmatch()
folds case on BOTH the pattern and the text.  RED before the fix: an
upper-case pattern token (a daemon 'hosts deny = *.BADDOMAIN.COM') fails to
match a lower-case host -> access-control fail-open.

Reported-by: Leonid Bugaev
2026-07-20 14:05:32 +10:00
Andrew Tridgell 2e0b572057 testsuite: make patched_rrsync robust to the rrsync RSYNC path
patched_rrsync() rewrote support/rrsync's hardcoded RSYNC path to a test stub
with a value-specific str.replace("RSYNC = '/usr/bin/rsync'", ...).  Python's
str.replace silently returns the text unchanged when the needle is absent, so on
a tree whose shipped rrsync uses a different path -- e.g. FreeBSD's net/rsync
port patches it to /usr/local/bin/rsync -- the rewrite no-oped and
rrsync-under-test kept exec'ing the real system rsync, which blocks in server
mode on stdin and hangs the rrsync tests (a testsuite timeout on the BSDs).

Match the RSYNC assignment line itself (re.subn on ^RSYNC\s*=.*$) rather than a
specific value, using a callable replacement so the path is inserted verbatim,
and fail loudly via test_fail if it is not found exactly once.  Centralize
rrsync-symlink_test.py's duplicate inline replace onto the same helper.

Reported-by: Rodrigo Osorio <rodrigo@FreeBSD.org>
2026-07-20 14:05:32 +10:00
Vladimir Marek 6fa1f15c66 acls: implement Solaris facl(2)-based ACL helpers
Solaris has facl(2), which performs ACL operations on an already-open file
descriptor.  This adds Solaris fd-based helpers for the ACL operations that
rsync needs while keeping the existing path-based fallback for callers without
a held fd.

On Solaris, setting an ACL on a directory replaces the combined access and
default ACL set.  The path-based sys_acl_set_file() already handled this by
reading the other half of the directory ACL, merging access and default entries,
and then calling acl(..., SETACL, ...).  The new sys_acl_set_fd_type() preserves
that behavior with fd-based operations: it uses fstat() to identify directories,
reads the other ACL half with sys_acl_get_fd_type(), combines the access and
default entries, marks default entries with ACL_DEFAULT, and finally writes the
combined ACL with facl(2).  Deleting a default ACL similarly fetches the access
ACL through the fd and rewrites only that access ACL with facl(2), which removes
the default ACL without re-resolving the path.

Driving the apply off the held fd also closes a symlink-race on the Solaris ACL
apply (the path-based sys_acl_*file re-resolves the path -- the CVE-2026-53799
class, unfixed on Solaris until now).  Added on integration: for a root receiver
a missing held fd on a confined receiver means the leaf was raced to a symlink
(acl_set_file follows it), so refuse the path-based set/delete rather than apply
the ACL to a redirected inode; a plain non-root receiver keeps the path-based
fallback for a legitimately un-pinnable owned leaf (e.g. a 0300 dir), matching
the operator-path op_pin rule.
2026-07-20 14:05:32 +10:00
Vladimir Marek 13a010959b lib/sysxattrs: make write_xattr more robust
Don't let close() clobber the errno from a failed write, and report a close()
failure on an otherwise-successful write.

(The short-write length, the unsigned bufpos = -1 returning success, and the
size == 0 returning failure that Vladimir also reported were already fixed
in-tree.)
2026-07-20 14:05:32 +10:00
Vladimir Marek 9502c4a1c7 testsuite: lz4-default compress-level and out-of-source-tree build fixes
Use a level-aware compressor for the compress-options --compress-level check.
The default compressor can be lz4, which has no tunable compression levels and
therefore reports level 0.  Select zlibx or zlib for this subtest so it verifies
that --compress-level is passed to a compressor that supports levels.

Read the build config when setting up the symlink-placeholder test.  The
t_symlink_secure helper is compiled against the build directory config.h; use
that same config for the Python test setup so it creates the symlink placeholder
PoC inputs when the compiled helper will exercise them.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 8a3a3e4de1 testsuite: drop the symlink-escape oracle when it runs but crashes (Solaris)
The da7c4208 oracle-exec guard only caught OSError (can't-exec / ENOEXEC, on the
BSDs and macOS).  Solaris execs the Linux x86-64 old_versions/rsync_3.2.7 binary
without ENOEXEC but it SIGSEGVs, so subprocess.run('--version') returned -11
without raising and ORACLE_BIN stayed set -- the oracle daemon launch then died
("rsyncd exited before listening on port 12910, status=-11") and failed the test.

Require the probe to exit cleanly (returncode == 0); a non-zero/signal exit, a
hang (TimeoutExpired), or a can't-exec (OSError) all degrade to the static
contract.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 07a7a19790 testsuite: make copy-xattrs-symlink-race a reliable RED oracle
The test relied on a held-dirfd cache MISS coinciding with the parent flip, so it
only caught the escape occasionally (it read as -j-load flakiness).  Bury the
flipped dir below the held-dirfd cache depth (DEEP, 70 levels > VFS_DPC_MAXDEPTH
64) so set_file_attrs() takes the fd-less metadata path for every file on every
push -- removing the cache-miss race and leaving only the parent flip landing in
the create->lsetxattr window.  Widen that window (64 KB payload) and raise the
file count (N=120) for more attempts per push.

Now reliably RED on the unfixed code (8/8 under load, the CI condition; ~5/6
standalone) and reliably GREEN with the set_file_attrs re-pin/refuse fix
(no false positives, standalone and under load).  Same inline 3-rename flipper
(the reset-each-push model the EXCHANGE c-flipper can't drive).
2026-07-20 14:05:32 +10:00
Andrew Tridgell 03535a62a5 rsync: never path-resolve a confined receiver's xattr/ACL write (copy-xattrs race)
set_file_attrs() pins the entry via the cached held dir fd (held_dfd_for) and
drives the xattr/ACL ops off that fd (fsetxattr).  But when the pin missed --
held_dfd_for() returns -1 (the path is deeper than the dirfd cache, or its dir
isn't the held one), or the leaf openat() loses a race -- held_fd stayed -1 and
set_stat_xattr()/set_xattr()/get_acl_fdat()/set_acl_fdat() fell through to the
path-based branch (sys_lsetxattr(fname,...)).  Unlike the chmod/chown/times path
wrappers (which secure-resolve), that raw lsetxattr re-resolves the parent, so a
concurrent flip of a dest parent component to a symlink->outside lands the xattr
OUTSIDE the destination tree (the intermittent copy-xattrs-symlink-race escape
that surfaces under -j load, which widens the open->setxattr window).

Re-pin through secure_relative_open() when the cached pin misses on a confined,
non-operator receiver path, so the xattr/ACL ops always use a confined fd -- NOT
a raw path lsetxattr; if the re-pin also fails (a genuinely raced parent/leaf
symlink) skip the path-based ops (xattr_refuse) rather than redirecting them.
The re-pin passes O_DIRECTORY for a directory leaf.  Apply the same re-pin/refuse
to gen_entry_copy_xattrs() (the dir xattr copy), whose dfd<0 path likewise fell
to copy_xattrs() with dest_fd==-1.  chmod/chown/times are unchanged (confined via
their *at wrappers); operator paths keep op_pin/op_refuse.
2026-07-20 14:05:32 +10:00
Andrew Tridgell c144c0931c github: drop daemon-chroot-munge-default from the AlmaLinux expect-skip
The test runs and passes on AlmaLinux 8 (chroot works and CI runs as root), so
listing it in RSYNC_EXPECT_SKIPPED tripped the strict skip-set check (expected to
skip but ran).  It only genuinely skips where chroot is unavailable -- cygwin --
whose entry is kept.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 88b4d77b18 testsuite: fall back to the static contract when the 3.2.7 oracle can't exec
daemon-symlink-escape-matrix prefers the in-tree old_versions/rsync_3.2.7 as its
legacy oracle, but that is a Linux x86-64 static binary.  On a full checkout (the
per-platform CI runners) it is present yet cannot exec on a BSD/macOS/Solaris host,
so start_test_daemon() crashed with OSError ENOEXEC and failed the test.  (The
git-archive fleet never hit this: .gitattributes export-ignores old_versions/, so
the binary is absent there and the test already took its static-contract path.)

Probe the selected oracle binary (rsync --version) and degrade to the static
contract on any OSError, the same path used when no oracle binary is present.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 32ef91adcb docs: record the recent security hardenings in NEWS.md and SECURITY.md
NEWS.md: extend the robustness-hardening section with the second-pass source
audit (hashtable/flist size-computation integer overflows, non-positive
MSG_IO_TIMEOUT reject, async-signal-safe SIGUSR2 handler) and the source-side
xattr/ACL metadata read confinement plus the fake-super cross-tree metadata-apply
fd-pin.

SECURITY.md: note in the operator-directory residual writeup that the cross-tree
metadata apply on a --temp-dir/--backup-dir leaf is fd-pinned the same way the
reads now are, including under --fake-super (previously am_root >= 0 only).
2026-07-20 14:05:32 +10:00
Andrew Tridgell 298efc7fe4 xattrs/backup: read source metadata through a held fd, not a path
The hardened receiver confined the destination side of an xattr/ACL copy (the
fsetxattr/acl_set_fd through a held O_NOFOLLOW fd) but still read the SOURCE side
by path: copy_xattrs() did get_xattr_names/get_xattr_data on the source path, and
make_backup() cached the backed-up file's ACL/xattr via get_acl()/get_xattr() by
path.  A local module writer could race the source/basis parent to a symlink
after the confined content/stat open and before that path-based metadata read,
so out-of-module xattrs/ACLs got copied onto an in-module destination or backup.

Thread a source fd through the read side, mirroring the existing dest-fd plumbing:
 - get_xattr_data(), get_xattr() and get_xattr_acl() gain an fd arg (get_xattr_names
   already had one) and use sys_fgetxattr/sys_flistxattr when fd >= 0; this also
   covers the --fake-super ACL-as-xattr read in get_rsync_acl().
 - copy_xattrs() gains a source_fd; copy_file() passes its held source fd (ifd)
   and keeps it open across the xattr copy (closing it on the fsync error path
   too); gen_entry_copy_xattrs() O_NOFOLLOW-opens the basis leaf under the
   confined resolver (with O_DIRECTORY for a directory basis) and passes it.
 - make_backup() pins the source leaf with a confined O_NOFOLLOW fd
   (backup_source_fd, like set_file_attrs's op_leaf_fd) and reads its ACL via
   get_acl_fdat() and its xattrs via get_xattr(fd); the in-place delta-backup in
   the generator pins fname the same way.  On a hardened receiver a raced/absent
   leaf skips the cache rather than reading through a flippable path.

Non-hardened receivers (fd < 0) keep the path-based behaviour unchanged.  The
basis COMPARE reads (the generator deciding a match) stay path-based: they never
copy out-of-module metadata onto a file, so they are not part of this sink.
2026-07-20 14:05:32 +10:00
Andrew Tridgell c304dd74e4 rsync: pin cross-tree fake-super metadata writes to a confined fd
set_file_attrs() pins a cross-tree operator leaf (an absolute --temp-dir /
--backup-dir / --*-dest path) with an O_NOFOLLOW fd (op_leaf_fd) and drives
chmod/chown/xattr/ACL/times off it so a flipped parent can't redirect them, but
the pin was opened only when am_root >= 0.  A daemon module with "fake super =
yes" runs with am_root < 0, so the cross-tree leaf kept op_leaf_fd/held_fd == -1
and the fake-super %stat, preserved-xattr and ACL-as-xattr writes fell back to
path-based sys_lsetxattr(): a local module writer racing the staging parent to a
symlink could redirect those metadata writes outside the module
(CVE-2026-53799 residual on the fake-super path).

Open the pin for fake-super too -- it has nothing to do with privilege: the
daemon owns the freshly-staged leaf it is about to set metadata on, so any
O_NOFOLLOW open failure is a race and is refused rather than redirected through a
re-resolvable path.  Every metadata op already routes through
held_fd/op_leaf_fd/op_refuse, so they all become fd-based; strace confirms the
cross-tree fake-super write uses fsetxattr()/fchmod(), never the l-variant.
2026-07-20 14:05:32 +10:00
Andrew Tridgell fe3ab9ca9b testsuite: symlink-escape matrix covers change_dir/basis + a real 3.2.7 oracle
The previous matrix only drove --copy-dirlinks/--keep-dirlinks, which route
entirely through send_directory -- the one daemon descent site that already
honoured the opt-out -- so it passed even while explicit-path traversal
(change_dir) and alt-dest basis (basis_link_stat) ignored "insecure links =
yes".  It also asserted a hardcoded contract rather than measuring stock 3.2.7.

Add read-plain/write-plain vectors (a plain pull/push through a symlinked
directory -> change_dir) and a compare-dest vector (--compare-dest=/symlink ->
basis_link_stat), and stand up a second daemon running old_versions/rsync_3.2.7
as a live oracle: every insecure=yes cell must follow iff 3.2.7 follows, and no
insecure=no cell may ever escape the module.  Falls back to a static contract
when the 3.2.7 binary is absent.  Red on the pre-fix build for both the escape
and the opt-out follow gap; green after.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 7b6fbf5940 daemon: make "insecure links = yes" fully restore 3.2.7 symlink following
The opt-out is meant to restore stock-3.2.7 "follow existing symlinks in the
module" behaviour, but several daemon symlink-resolution sites called the
confined resolver unconditionally and never consulted symlink_optout_allowed(),
so a module with "insecure links = yes" still refused to follow a symlinked
directory (change_dir ELOOP) or an alt-dest basis.  Gate every such site on the
opt-out -- follow plainly like 3.2.7 when set, confine otherwise:

  - change_dir() relative daemon branch (util1.c): plain chdir() under the
    opt-out instead of secure_relative_open(), so a peer can read a path through
    a symlinked directory again.
  - basis_link_stat() (generator.c): the chrooted-relative and non-chroot
    branches honour the opt-out.  The non-chroot ABSOLUTE branch (a basis rooted
    under the module by check_alt_basis_dirs, which can reach an in-module
    symlink) is, when NOT opted out, resolved through owner_walk_parent so a
    target landing outside the module root is refused -- closing a confirmed
    --compare-dest=/symlink out-of-module read oracle.  The leaf is taken
    O_NOFOLLOW under the confined parent so --copy-links can't follow a leaf
    symlink out; --fake-super folds its %stat via the held fd.  A relative
    sibling basis (--link-dest=../01) keeps the plain path (#915/#930).
  - secure_basis_open() (receiver.c): plain do_open() under the opt-out.
  - use_secure_symlinks (clientserver.c): cleared under the opt-out, so the
    receiver's protected-regular EACCES write fallback is legacy too.
  - secure_sender_parent_fd() (sender.c): declines (errno=0) under the opt-out
    so --remove-source-files re-stats the plain path.

Default modules (insecure links = no) are unchanged and stay fully confined;
the opt-out is per-module and a client cannot enable it.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 2ae254671c testsuite: guard daemon alt-dest basis confinement to the module
A daemon must clamp a peer-supplied --link-dest/--compare-dest/--copy-dest basis
dir to the served module: --link-dest=../sibling (or an absolute path) must not
let the daemon stat files outside the module (an existence/size oracle -- the
KI-48 / CVE-53795 surface).  rsync confines this lexically already (longstanding
sanitize behavior, preserved through this branch's path-confinement rework); the
test locks that in so the rework can't regress it.  The audit's "runtime-
confirmed" KI-48 was a local receiver, where --link-dest=.. is the operator's
own intended choice, not a module-boundary crossing.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 11d3090730 syscall: drop the dead name_is_dir arg from abspath_excluded_by_module
abspath_excluded_by_module() is module-ROOT confinement only by design (the
daemon name/exclude filter is name-based visibility, not a physical-path
boundary -- munge symlinks is that defense), so its name_is_dir parameter was
always discarded ((void)name_is_dir).  Remove it, and the now-redundant
absent-leaf second call in owner_walk_parent()'s leaf check that re-tested the
SAME leafabs with name_is_dir=1 for an identical result, plus the
fstatat()/isdir/absent that only fed the ignored logic.  Behavior-preserving
(the refuse decision is unchanged at every call site); drops one incidental
lstat.

(Leonid Bugaev May-2026 re-audit, KI-49 -- dead defense-in-depth arm, no escape.)
2026-07-20 14:05:32 +10:00
Andrew Tridgell 4e17fd8505 io, main: make the SIGUSR2 handler async-signal-safe
sigusr2_handler called output_summary() (rprintf/vsnprintf/fwrite/iconv/malloc)
and close_all() (fstat/shutdown/close) directly from signal context before
_exit().  SIGUSR2 is sent by the generator/parent to tell the receiver child to
print its summary and exit; if it interrupted the receiver while it was inside
malloc/stdio, the handler re-entered those and could deadlock or corrupt.

The handler now only sets a flag (got_sigusr2, a volatile sig_atomic_t); the
actual summary + shutdown moves to receive_sigusr2(), run from a safe point in
the receiver's post-transfer wait paths:
 - the perform_io() flag checks (next to got_kill_signal);
 - the safe_read()/safe_write() loops (which a --read-batch / --write-batch fd
   uses without going through perform_io);
 - the whine_about_eof() kluge loop, where the receiver waits out the race of
   the sender dying before the kill-signal arrives -- this loop polls for the
   signal, so without the flag check it slept the full 10s and then errored with
   RERR_STREAMIO instead of exiting cleanly;
 - the trailing `while (!got_sigusr2) msleep()` loop in do_recv().

(Leonid Bugaev May-2026 re-audit, KI-14.)
2026-07-20 14:05:32 +10:00
Andrew Tridgell 63f744adb1 testsuite: regression test for the hashtable integer overflow
Add t_hashtable_overflow (links the real hashtable.o, sets a realistic
--max-alloc, requests an absurd size) and a test asserting hashtable_create now
rejects it with RERR_MALLOC instead of under-allocating and crashing on the OOB
node access a regressed build would hit.  The helper defines its own
info_levels/debug_levels (as the other t_* helpers do) for the DEBUG_GTE macro
in the linked hashtable.o.
2026-07-20 14:05:32 +10:00
Andrew Tridgell 96ff1a0ce2 hashtable, flist: fix integer overflows in size computations
hashtable_create() and the grow path computed the slot-array byte count as
new_array0(char, size * node_size) in 32-bit int arithmetic; for a large
peer/data-driven size the product wrapped to a tiny value, bypassing my_alloc's
--max-alloc guard (which only saw the already-wrapped count), so the table was
under-allocated while tbl->size kept the huge size -- a later node access then
ran out of bounds (heap overflow; ASan-confirmed).  Pass size and node_size as
SEPARATE factors so my_alloc checks both before multiplying; guard each *2
doubling against int overflow BEFORE it happens; make HASH_LOAD_LIMIT divide
before multiplying; promote the HT_NODE index multiply to size_t; and test
size < 16 first so a negative req short-circuits the size-1 (INT_MIN UB).

flist_expand()'s int growth math (used+extra and *=4 / *=2 / += FLIST_LINEAR)
could overflow past INT_MAX on a very large file list; guard each operation
before it overflows and refuse rather than under-size the realloc.

(Leonid Bugaev May-2026 re-audit, KI-11/12/13.)
2026-07-20 14:05:32 +10:00
Andrew Tridgell c158d241d4 testsuite: regression test for MSG_IO_TIMEOUT(0) timeout-disable
Drives a tiny crafted rsync daemon that completes the handshake and sends
MSG_IO_TIMEOUT(0) as its first multiplex frame, then holds the socket open well
past the client's --timeout.  The fix ignores the non-positive value so the
client keeps its timeout and self-exits; a vulnerable client disables its
timeout and hangs until the test's watchdog kills it (test_fail).  Runs in any
transport mode (it connects to the local crafted server over a rsync:// URL).
RED on stock 3.4.x, GREEN on the fix.

(Leonid Bugaev May-2026 re-audit, KI-47.)
2026-07-20 14:05:32 +10:00
Andrew Tridgell 27be267ee3 testsuite: regression tests for the temp-dir injection and copy-dest read-leak
Pin the two cross-uid operator-path races fixed in "confine the remaining
cross-tree operator-path syscalls" and "copy_file: confine an absolute operator
source ...":

  temp-dir-symlink-injection  absolute --temp-dir rename pulls an attacker's
                              out-of-tree file into the destination (do_rename_at
                              absolute-side confinement)
  copy-dest-symlink-readleak  --copy-dest basis read follows a flipped foreign
                              parent symlink, leaking out-of-tree content into
                              the destination (copy_file source confinement, KI-46)

Both root+nobody gated (the cross-uid plant needs root), RED on stock 3.4.x and
under --insecure-links, GREEN on the fix; cygwin runs make check non-root so
they skip there (added to its RSYNC_EXPECT_SKIPPED).
2026-07-20 14:05:32 +10:00
Andrew Tridgell 8d3b7be875 copy_file: confine an absolute operator source via the ownership walk
copy_file() routed a RELATIVE source through secure_relative_open (parents
confined) but opened an ABSOLUTE source -- an operator basis such as an absolute
--copy-dest -- with bare do_open_nofollow, which refuses only a leaf symlink and
follows every parent.  basis_link_stat() refuses a foreign-owned basis at stat
time, but a parent flipped to a foreign symlink between that stat and this open
redirects the basis read out of tree (an out-of-tree content read-leak into the
destination; RED on 3.4.x, GREEN here).

Resolve an absolute source's parents through owner_walk_parent (foreign-owned
parent symlink refused, operator's own dirs/uid0/euid symlinks followed).
operator_path_resolve is set only across the walk -- so module-exclude is
enforced -- and restored, leaving the caller's value for the dest side; that is
why confining the source here does not re-open the copy_xattrs dest race that
wrapping the whole copy_altdest_file would (copy-xattrs-symlink-race stays green).

(Leonid Bugaev May-2026 re-audit, KI-46.)
2026-07-20 14:05:32 +10:00
Andrew Tridgell e7c017914f io: reject a non-positive MSG_IO_TIMEOUT from the peer
A peer may use MSG_IO_TIMEOUT only to ask us to adopt a SHORTER I/O timeout
(a stricter cap).  A crafted server sending val <= 0 would instead zero the
client's --timeout via set_io_timeout(0), disabling it entirely and letting the
server hang the client indefinitely.  Ignore a non-positive value.

(Leonid Bugaev May-2026 re-audit, KI-47; pre-existing since 2009.)
2026-07-20 14:05:31 +10:00
Andrew Tridgell a701236c64 confine the remaining cross-tree operator-path syscalls
The backup-dir fix pinned chmod/chown for an operator leaf; extend the same
confinement to every other sink that re-resolves a cross-tree operator path
(an absolute --temp-dir/--partial-dir/--*-dest), each confirmed by a cross-uid
race PoC that is RED on stock 3.2.7 and GREEN here:

- set_file_attrs (rsync.c): route times (do_futimens), and -- by aliasing
  held_fd to the pinned op_leaf_fd -- the xattr/ACL ops through the same
  O_NOFOLLOW leaf fd, not a re-resolvable path.  A raced/refused pin skips them
  (op_refuse) rather than redirecting.  finish_transfer now wraps the pre-rename
  set_file_attrs in operator_path_resolve so an absolute --temp-dir temp file's
  metadata is pinned too (in-tree temps keep their held dirfd, so op_pin is off).

- do_rename_at / do_link_at (syscall.c): an ABSOLUTE side was left at AT_FDCWD
  and followed a flipped parent symlink, letting a name-disclosed --temp-dir /
  predictable --partial-dir rename pull an attacker file into the destination
  (content injection).  Resolve an absolute (operator) side via the ownership
  walk -- with module-exclude enforced -- while a relative (transfer) side stays
  on secure_relative_open.  --insecure-links keeps the legacy path.

- secure_basis_open (receiver.c): an alt-dest basis read
  (--copy-dest/--compare-dest/--link-dest) on a non-daemon receiver used a bare
  do_open; route it through the ownership walk (refuses a foreign-owned basis
  symlink, still allows the "../sibling" basis of #915).  Daemons keep their
  existing confinement branch.

- configure.ac: probe futimens (do_futimens is gated on HAVE_FUTIMENS).

New fd wrappers: do_fchmod, do_fchown, do_futimens.  Documented residuals left
as-is: copy_altdest_file's basis copy (routing it re-opens copy-xattrs-symlink-
race; basis_link_stat already refuses the foreign symlink), crtimes
(do_setattrlist_crtime/do_SetFileTime, path-based on macOS/Cygwin), and
device/socket leaf metadata (pinning a device via open has side effects).
2026-07-20 14:05:31 +10:00
Andrew Tridgell 0f84b4a776 testsuite: regression test for the --backup-dir trust-laundering race
Pins the fix in "backup: confine cross-tree operator-path metadata via a pinned
fd".  A root operator runs `rsync -a -b --backup-dir=<abs> ...` while a non-root
attacker flips a backup parent component between a real dir and a foreign-owned
symlink -> outside; pre-fix, rsync's own backup-dir attribute mirroring lchowns
the planted symlink to root, laundering it into a trusted symlink the owner-walk
then follows, so the backup escapes the tree.

Root+nobody gated (cross-uid plant needs root); RED on stock 3.2.7 and under
--insecure-links, GREEN on the fix.  Uses the compiled flipper for a reliable
RED oracle.  cygwin runs make check non-root so the test skips there -- add it to
that workflow's RSYNC_EXPECT_SKIPPED; the root workflows (almalinux-8 container,
sudo ubuntu/macos) run it for real.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 334aacb448 testsuite: add an on-demand compiled TOCTOU flipper
The Python path-flipper used by the symlink-race tests wins the race window
unreliably on a journaled disk fs -- on the vulnerable binary it reproduced the
escape only ~1/3 of the time, because the interpreter loop caps the swap rate.

Add compile_c_flipper()/start_c_flipper(): a small C flipper, built on demand
against the build's config.h (CC and -I taken from TOOLDIR, then SRCDIR), that
swaps two sibling names with renameat2(RENAME_EXCHANGE) where available -- one
atomic syscall, no transient missing-name window -- and a self-healing 3-rename
fallback elsewhere.  Measured ~2x (plain rename) to ~7x (EXCHANGE) the swap rate
on disk, which turns a flaky RED oracle into a reliable one.  It self-terminates
on parent exit plus a deadline backstop (like start_path_flipper) so a killed
test can't leak an orphan, and falls back to the Python flipper where no
compiler is available.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 1624b6daac backup: confine cross-tree operator-path metadata via a pinned fd
A symlink race on a non-daemon --backup-dir let a local attacker redirect
rsync's backup writes and chmods outside the backup tree when rsync runs as
root.  make_backup() sets operator_path_resolve, but set_file_attrs() had no
held parent dir fd for an absolute backup path (held_dfd_for() returns -1), so
its chmod/chown fell through to the path-based wrappers and, for an absolute
path, to raw chmod()/lchown().  copy_valid_path() mirrors the source dir's
attrs onto each backup subdir; when an attacker flips a backup component to a
symlink in that window the raw lchown retags the planted symlink as root-owned
-- laundering it into a "trusted" (uid 0) symlink that the owner-walk then
follows, so the backup rename/chmod escapes the tree.

Pin the leaf inode of a cross-tree operator path with an O_NOFOLLOW open via
the operator owner-walk resolver and drive fchmod/fchown off that fd; a raced
symlink leaf makes the open fail and the op is refused, never redirected.  Gate
on the INTENDED type (new_mode), not the attacker-controlled on-disk type.  As
root any open failure is the race (a real owned leaf never fails); a non-root
operator, which cannot launder a uid-0 symlink, falls back to the legacy path
op on a benign EACCES.  --insecure-links opts back out.

Adds do_fchmod()/do_fchown() fd wrappers.  Residual cross-tree metadata sinks
(times, ACLs, xattrs, and --temp-dir's finish_transfer set_file_attrs) are not
covered here and are tracked for a follow-up.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 33beee7652 docs: correct vague/wrong option descriptions
Tighten man-page prose for options whose documented behaviour was
imprecise or outright wrong, matching the behaviour now pinned by the
new oracle tests and verified against the C source:

- strict modes: the real secrets-file rule is "st_mode & 06" (other
  read/write) plus root-owner-when-root, so 640 is accepted and 644
  rejected -- not the old "any user ID other than the daemon's".
- munge symlinks: rewrite the default in terms of chroot and the "/./"
  path split (disabled only for a plain chrooted module serving the
  chroot root); drop the bogus "daemon chroot" clause. Fix the helper
  reference: support/munge-symlinks is a python script, not perl.
- --no-implied-dirs: spell out that an existing in-tree dest symlink is
  followed.
- --files-from: ".." handling is collapse-then-reject-survivors.
- --copy-unsafe-links: describe the lexical unsafe-symlink rule instead
  of the old "verbose output" phrasing.
2026-07-20 14:05:31 +10:00
Andrew Tridgell e927a00b2e testsuite: pin documented option behaviour with 8 oracle tests
Add behaviour tests that nail down option semantics the man pages
describe vaguely, each verified to pass against both this branch and the
3.2.7 oracle (so they document long-standing behaviour, not regressions):

  daemon-strict-modes-matrix   secrets-file mode rule (st_mode & 06)
  daemon-chroot-munge-default  munge-symlinks default vs chroot/path /./
  safe-links-unsafe-def        --copy-unsafe-links lexical unsafe rule
  no-implied-dirs-symlink      --no-implied-dirs follows in-tree dest symlink
  files-from-path-clamp        --files-from collapse-then-reject ".."
  relative-implied-symlink     --relative sends implied dirs as real dirs
  keep-dirlinks-rule           --keep-dirlinks opening rule
  backup-dir-relative          --backup-dir resolves relative to dest

no-implied-dirs-symlink relies on the -R "/./" implied-dir marker, a
protocol-30+ feature; under protocol 29 the generator rejects the
multi-component path (same as the 3.2.7 oracle), so it passes through
without testing, matching the sibling relative-implied test.

daemon-chroot-munge-default needs root to exercise the chroot regimes
and skips otherwise; add it to RSYNC_EXPECT_SKIPPED only in the
almalinux-8 and cygwin workflows, which run make check non-root. The
macos workflow runs it as root, so the test runs there for real.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 2d10070c90 SECURITY.md: broaden the robustness-pass example list
The "Robustness against malicious peers" summary enumerated the classes of
peer-triggerable faults closed by the fuzzing/static-analysis pass. Add the two
classes that the later scanner batch introduced -- reads past a file-list
allocation (mostly bounded over-reads of an entry's extra slots) and
option-argument-driven length bounds (plus the suffix-list recursion sink) --
keeping the summary general (no per-finding detail; not every over-read
disclosed memory).
2026-07-20 14:05:31 +10:00
Andrew Tridgell cd22f195ef testsuite: lock down the in-module symlink-escape resolution matrix
daemon-symlink-escape-matrix exercises, for a writable non-chroot module, every
combination of `insecure links` {no,yes} x `munge symlinks` {no,yes} x link
origin {pre-existing, uploaded} x op {read pull, write push} x five symlink
target types (rel-within, rel-outside, rel-transits [.. above the module root
then back in], abs-outside, abs-inside).

It pins the contract: the secure default follows only an in-tree (rel-within)
link and NEVER reaches an out-of-module target (read or write); the
`insecure links = yes` opt-out restores legacy following on sender AND receiver
(so an outside target escapes, matching stock 3.2.7); and an uploaded link never
escapes regardless (munge prefixes it, munge-off sanitises it).  A secure-default
out-of-module access is a hard failure.  require_tcp + root gated; listed in the
per-platform RSYNC_EXPECT_SKIPPED pipe make-check sets.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 502fb584c4 daemon: insecure links opt-out restores legacy symlink following on the receiver
secure_relpath_active() (the gate that routes receiver-side filesystem ops --
get_dir_fd/dpc, do_*_at, open_tmpfile, make_path, link_stat -- through the
symlink-race-safe resolver) checked only am_daemon/am_chrooted/am_sender, not
the symlink_optout_allowed() opt-out.  So `insecure links = yes` (or a non-daemon
--insecure-links) restored the legacy follow only on the SENDER enumeration
(which checks the opt-out directly), while the receiver still confined writes,
mkdirs, renames, unlinks and stats through a pre-existing in-module symlink --
i.e. the admin opt-out did not actually reproduce the pre-3.4.3 behaviour it
documents (rsyncd.conf(5) "munge symlinks"/"insecure links").

Have secure_relpath_active() return 0 when symlink_optout_allowed(), so the
opt-out uniformly disables the secure resolver on both sides.  No effect on the
default (opt-out off): confinement is unchanged.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 68ff15e35c testsuite: daemon-proxy-protocol drop-leg tolerates EPIPE on send
The probe() helper sends the @RSYNCD greeting after the PROXY header, then
reads the daemon's response.  For a `want='drop'` leg (untrusted peer / a
daemon with no `proxy protocol hosts`) the daemon closes the connection,
which on some CI runners surfaces as EPIPE/ECONNRESET on our sendall() before
we ever read -- an uncaught BrokenPipeError that failed the test (seen on
AlmaLinux 8 and Ubuntu 22.04, a timing race; other runners closed read-side).
Wrap the send/recv in `except OSError` and leave `out` empty: for want='drop'
the absent greeting is the expected outcome; want='ok'/'denied' still fail
correctly on an absent greeting.
2026-07-20 14:05:31 +10:00
Andrew Tridgell fe69ae7a7d github: register new audit tests in the expected-skip lists
The four tests added with the audit fixes skip on the standard (non-ASan,
stdio-pipe) CI/fleet runs: daemon-deny-dns-failopen needs a TCP peer
(require_tcp), and the three leak reproducers need an AddressSanitizer build
(require_asan).  Add them to RSYNC_EXPECT_SKIPPED so make check / the fleet
report clean instead of flagging an expected skip as a mismatch.

(cherry picked from commit 272341682b668424e1f87fd1e8f8a5878db272c2)
2026-07-20 14:05:31 +10:00
Andrew Tridgell 5eb05f7409 flist/xattrs/generator/uidlist/clientserver: plug audit-reported leaks
Five error-path/cleanup memory leaks found by an external audit:

- flist.c send_file_name: free the ACL loaded by get_acl() when a later
  get_xattr() fails (and on the get_acl error path).
- xattrs.c copy_xattrs: free the xattr datum buffer when the setxattr fails.
- generator.c recv_generator: free real_sx at the cleanup label (the
  directory branch loaded its ACL via set_file_attrs but only the
  regular-file path freed it); zero-init real_sx so the early gotos are safe.
- uidlist.c send_one_list: free the strdup'd id-0 name after send_one_name.
- clientserver.c start_inband_exchange: free modname on the early error
  returns (it was freed only on the success path).

ASan/LSan regression tests cover the generator, uidlist and clientserver
leaks; the flist and xattrs leaks need a forced syscall failure and are
covered by the audit's standalone harnesses.

Reported-by: Leonid Bugaev <leonsbox@gmail.com>
(cherry picked from commit 078f3b99f4004510d418ee9d97d9b775dc8587bc)
2026-07-20 14:05:31 +10:00
Andrew Tridgell 054d0eb475 access: fail closed when a hosts-deny token can't be resolved
match_hostname() did a forward-DNS lookup of a config-specified hostname
token and, on gethostbyname() failure, returned "no match" -- which
allow_access() cannot distinguish from a real non-match, so a daemon with
"hosts deny = <hostname>" silently admitted the host whenever the token
could not resolve (a resolver-less chroot, or a transient DNS failure).
No attacker DNS control required.

Thread a deny flag through access_match()/match_hostname() and, on a
forward-DNS failure, treat an unresolvable DENY-list token as a match so
the connection is refused (fail closed); allow-list tokens still fail as a
non-match. Sibling of CVE-2026-43617, which fixed only the reverse path.

Reported-by: Leonid Bugaev <leonsbox@gmail.com>
(cherry picked from commit 84e469ea03a98ef31326d3d861336d7e7d582ce1)
2026-07-20 14:05:31 +10:00
Andrew Tridgell 21ddac1a67 testsuite: rsync_ls_lR tolerates non-UTF-8 bytes under Python 3.14
Python 3.14's strict UTF-8 text mode raised UnicodeDecodeError when tls
emitted a non-UTF-8 byte (a filename or symlink target with high bytes),
aborting every test that calls rsync_ls_lR via hands_setup() -- ssh-basic,
hands, delete, files-from, alt-dest, daemon-gzip-*.  Decode the tls output
with errors='backslashreplace' so a stray high byte renders as \xNN in the
listing rather than crashing the run; the result stays a clean str that
write_text()/print() can consume without re-raising.
2026-07-20 14:05:31 +10:00
Andrew TridgellandGreg Kroah-Hartman 3d3150c24a testsuite: code-scanner daemon/metadata coverage tests + gcov flush before chroot
Adds six coverage tests from the code-scanner run, each closing a measured
gap in a daemon or metadata code path the suite never reached:

  - daemon-include-maxconn: rsyncd.conf &include/&merge directives +
    `max connections`/`lock file` (params.c include_config, connection.c
    claim_connection, util1.c lock_range).
  - fake-super-acl-xattr: --fake-super -A stores ACLs as user.rsync.%aacl/
    %dacl xattrs (acls.c am_root<0 IVAL/SIVAL pack, xattrs.c get/set/
    del_def_xattr_acl); Linux-only (the user.rsync.* namespace).
  - backup-crossdev-copy: make_backup() EXDEV copy-fallback for non-regular
    files (do_symlink_at/do_mknod_at/copy_file); skips without a cross-dev
    tmpfs.
  - daemon-http-proxy: RSYNC_PROXY HTTP CONNECT (socket.c
    establish_proxy_connection + base64 Proxy-Authorization + 503 branch).
  - daemon-module-options: motd file, socket options, incoming/outgoing
    chmod, dont compress, list=no, comment, --sockopts.
  - daemon-chroot: `use chroot = yes` incl. the /outer/./inner split and
    `temp dir`; probes CAP_SYS_CHROOT and skips cleanly without it.

clientserver.c flushes gcov counters just before chroot() in rsync_module()
so the per-connection child's pre-chroot lines reach disk (the build-tree
.gcda paths are unreachable post-chroot); no-op without --enable-coverage.

CI: the require_tcp-gated tests (daemon-chroot/-http-proxy/-module-options)
plus the Linux-only fake-super-acl-xattr and the cross-dev backup-crossdev-copy
are listed in the per-platform RSYNC_EXPECT_SKIPPED sets where they skip on the
pipe-transport make-check jobs.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-07-20 14:05:31 +10:00
Andrew Tridgell fe1adcfd73 syscall: do_mknod_at must mkfifoat for an operator-path FIFO on non-Linux
The operator_path_resolve branch of do_mknod_at() called mknodat() and
returned its result directly, without the FIFO/socket fallback that the
bare-path do_mknod() and the secure-relpath branch below it both have.
mknodat() can make a FIFO only on Linux; on the BSDs/macOS/Solaris it fails
with EINVAL, so creating a special file under an operator-supplied path --
e.g. backing up a FIFO into a --backup-dir -- failed there.  Retry race-safely
with mkfifoat() on the held parent dirfd, and fail a nested socket closed
(EOPNOTSUPP) exactly as the secure-relpath path does.
2026-07-20 14:05:31 +10:00
Andrew TridgellandGreg Kroah-Hartman a3de9e553d io/xattrs/rsync-ssl: EOF-sentinel guard, xattr ndx guard, gnutls CA refusal
io.c only treats a short read as the EOF sentinel when the fd is still
open; xattrs.c never stores a -1 from find_matching_xattr() and guards
ndx < 0 in set_xattr; rsync-ssl refuses the gnutls backend without
RSYNC_SSL_CA_CERT.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-07-20 14:05:31 +10:00
Andrew TridgellandGreg Kroah-Hartman 81850e8b27 options/token: bound the -v repetition, output level, and suffix token
Cap -v repetition so the argstr[64] global can't overflow, clamp a
negative --info/--debug level out of counts[], and cap a --skip-compress
suffix token at 32 bytes.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-07-20 14:05:31 +10:00
Andrew TridgellandGreg Kroah-Hartman 49cb45adcc compat: assign metadata ndx after batch flags; pick own preferred name
Move the uid/gid/acls/xattrs *_ndx assignments past check_batch_flags():
a mismatched-flag batch otherwise wrote F_XATTR(file) at offset 0 of
every file_struct, clobbering file->dirname. parse_negotiate_str() no
longer short-circuits on am_server, so each side picks its own #1 mutual
digest/checksum/compress choice rather than deferring to the peer's
order; man pages updated to match.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-07-20 14:05:31 +10:00
Andrew TridgellandGreg Kroah-Hartman 7e58170a9c generator: fix off-by-one length in read_delay_line()
A '!'-prefixed delete-delay entry computed one byte short, dropping the
final character of the name.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-07-20 14:05:31 +10:00
Andrew TridgellandGreg Kroah-Hartman aed7714325 hlink/main: guard the F_SUM checksum-slot lifetime
hlink.c must confirm S_ISREG before quick_check_ok(FT_REG,...) reads
F_SUM, and start_server() must set sender_keeps_checksum when a daemon
sender runs -c with a %C log format so make_file() allocates
SUM_EXTRA_CNT. Without these, F_SUM() reads past the pool slot and (for
%C) hex-encodes adjacent heap into the transfer log.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-07-20 14:05:31 +10:00
Andrew TridgellandGreg Kroah-Hartman 853050d25d flist/receiver: zero/empty the device & symlink extra slots
recv_file_entry (normal and XMIT_HLINKED abbrev branches) and make_file
left F_RDEV_P / the symlink-name slot uninitialized when the matching
preserve option was off, so a later read walked into adjacent pool
memory. Empty the symlink name when !preserve_links, zero F_RDEV_P when
!preserve_devices, and mirror both in the abbreviated branch. receiver.c
saves/restores the --write-devices S_IFBLK mode flip around receive_data
so dest_mode() never sees the mutated mode.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-07-20 14:05:31 +10:00
Andrew TridgellandGreg Kroah-Hartman 0abb4ae6cb testsuite: code-scanner coverage and regression tests + gcov infra
Adds the coverage/regression tests from the code-scanner run and the
gcov plumbing they rely on:

  - scanner-argv-bounds, scanner-batch-flag-mismatch,
    scanner-delete-delay-overread, scanner-daemon-log-checksum:
    regression tests for the argv/-v/--info/--skip-compress bounds, the
    batch metadata-ndx corruption, the read_delay_line off-by-one, and
    the daemon -c/%C checksum-slot leak.
  - daemon-proxy-protocol, daemon-early-exec-nameconv, daemon-auth-group,
    daemon-standalone-detach, misc-coverage, nonroot-restrictive-perms,
    backup-acl-xattr-cache: daemon and path coverage tests.
  - rsyncfns.py: CAP_MKNOD probe in devices_supported().
  - gcov_flush() macro (rsync.h) + calls in the daemon fork/_exit paths
    (clientserver.c, socket.c); no-op without --enable-coverage. Makefile.in
    COVERAGE_EXCLUDE / gcovr / setuid .gcda refinements.
  - CI: list the new TCP/root/ACL tests in the per-platform
    RSYNC_EXPECT_SKIPPED sets.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-07-20 14:05:31 +10:00
Andrew Tridgell ce17dfe8f0 fleettest: per-target xfail list; tolerate crtimes on macOS
Add a per-target `xfail` field (merged with the global --xfail) so a known
platform/version-specific failure can be tolerated persistently without a
command-line flag -- the test still runs, and if it passes the entry is a no-op.

Mark crtimes xfail on mac2: older backport binaries (3.4.x/3.2.7) drive APFS
birthtime via setattrlist differently than current rsync, so the 3.5.0
testsuite's crtimes check fails there; it passes for a current binary.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 0071ba5be2 runtests: allow the variety test 600s, like hardlinks
variety is the heaviest test in the suite; on slow platforms (Cygwin) the
per-component O_NOFOLLOW resolver pushes it past the default 300s per-test
timeout.  Give it the same 600s budget the hardlinks test already gets.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 5d1b15086d syscall: give the dirstack a fixed fd array instead of a malloc'd one
The race-safe resolver's dirstack grew its fd array with realloc() inside the
recursive ds_descend() walk and freed it via a passed pointer.  clang's
unix.Malloc analyzer cannot model that ownership and reported a false "Potential
leak of ds.fds", failing the pinned-clang-18 scan-build gate.

The walk holds one open fd per path component, so its depth is already bounded
by RLIMIT_NOFILE; use a fixed inline array (DS_MAXDEPTH, mirroring DPC_MAXDEPTH)
and drop the malloc/realloc/free entirely.  ds_push() fails with ENOMEM past the
cap.  No behaviour change -- 1024 levels exceeds any reachable depth.
2026-07-20 14:05:31 +10:00
Andrew Tridgell d1d3829901 docs: man pages + README
--insecure-links and the SECURITY sections in rsync(1)/rsyncd.conf(5), the
name-based exclude/filter + munge-symlinks clarification, the rsync-ssl hostname
note, and the README (incl. the thank-you to Wayne Davison, 2004-2024).
2026-07-20 14:05:31 +10:00
Andrew Tridgell 3c674b7fdf docs: NEWS + SECURITY.md for the 3.5.0 security release
NEWS.md: the 3.5.0 security-update section.  SECURITY.md: the platform-residuals
policy, symlink-race-safe path resolution, operator-path symlink defense, the
name-based daemon exclude/filter clarification, and the known residuals.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 065ec23e4b testsuite: fleettest VM harness + version-mix expect manifests
testsuite/fleettest.py builds the branch and runs the suite across a fleet of
remote VMs (BSDs/Solaris/Ubuntu/macOS/Cygwin) over multiple transports, with a
--cleanup that reaps orphaned daemons; the expect/*.expect files are the
version-mixing manifests (current rsync vs old static peers).
2026-07-20 14:05:31 +10:00
Andrew Tridgell 161608034e ci: scan-build gate (pinned clang-18) + informational latest-clang
Gate the build on a pinned clang-18 analyzer run (deterministic checker set,
--status-bugs fails on any new report) and run the latest clang informationally.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 885a0a8ecc ci: per-platform build workflows + RSYNC_EXPECT_SKIPPED baselines
Run the security test suite (pipe + real-TCP daemon transports, proto30/29, and a
targeted non-root pass) across Ubuntu/macOS/Cygwin/AlmaLinux, with per-platform
expected-skip baselines for the tests that legitimately skip there.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 32aa900fd0 testsuite: rrsync, rsync-ssl, batch, backup and misc
rrsync restriction tests, rsync-ssl hostname/CA validation, write-batch quoting/
filter-injection, backup-incremental, delay-updates, variety and xrsync.
2026-07-20 14:05:31 +10:00
Andrew Tridgell ae014a56f3 testsuite: daemon auth / access / proxy / config hardening
auth-digest floor, namecvt empty/newline, proxy-protocol (CRLF, trusted-peer,
over-long lines), connect-prog/exec/remote-shell quoting, unix-socket atfd, and the
daemon scan cwd/dir-escape tests.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 72098206ce testsuite: daemon protocol robustness against malicious peers
The malformed-peer fuzz/crash regression tests: io flood/argv, proto cleared-flist/
ndx/hlink/selftest, match nullmap, max-alloc / size-arg / argv limits, sum-blocklen,
nested-socket specials, malicious-sender delete scope, xattr-wire-cap.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 493a8e99d6 testsuite: ACL / xattr / special-file metadata
acl-symlink-race, acls-unpinnable, xattrs-hlink, and the chown/devices fake-super
metadata tests.
2026-07-20 14:05:31 +10:00
Andrew TridgellandOmar Elsayed a55ab05c19 testsuite: daemon exclude/filter name-based behaviour
The exclude/filter is a name filter, not a symlink boundary (3.2.7-equivalent):
symlink-exclude family, daemon-exclude-namebased, the operator-path exclude /
traversal / dir-daemon cases, filter-merge and implied-trailing-backslash.

Co-authored-by: Omar Elsayed <omarelsayed161@gmail.com>
2026-07-20 14:05:31 +10:00
Andrew TridgellandOmar Elsayed 4705b6fb92 testsuite: symlink-race and operator/peer path-resolution coverage
The TOCTOU / symlink-race suite for the secure resolver and operator-supplied
paths: chdir/chmod/rename/mknod/source/dest symlink races, relative make_path and
symlinked-parent cases, the operator-path matrix (--temp/partial/backup-dir,
alt-dest basis, files-from, log-file, insecure-links), and the admin-file opens
(--password-file / daemon secrets / config / log-file / early-input symlinks),
plus the daemon module-confinement and chroot inner-module cases.

Co-authored-by: Omar Elsayed <omarelsayed161@gmail.com>
2026-07-20 14:05:31 +10:00
Andrew Tridgell 1346bc623f testsuite: shared harness, helpers and unit-test programs
The t_rename_secure / t_symlink_secure / t_acl unit harnesses (C), the rsyncfns.py
helper library (daemon fixtures, symlink matrix, tree compare, xattr/ACL drivers),
runtests.py, and the rsync_proto/xrsync/cmptree/mkvariety helper scripts that the
security tests build on.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 88cee08963 rrsync: pin path components against a TOCTOU and fail closed on anomalies
Pin each validated path component (and a receiver-side new destination's parent)
with O_RDONLY|O_NOFOLLOW and pass /proc/self/fd/N to the exec'd rsync so the child
cannot re-resolve the path; probe the /proc/self/fd magic-symlink at runtime (not
just isdir); fail closed on a readlink anomaly; and don't abort when flock() is
unavailable (Solaris).
2026-07-20 14:05:31 +10:00
Andrew Tridgell 1604890301 batch/rsync-ssl: quote replay-script args and bind the SSL cert hostname
batch.c: single-quote every --write-batch replay-script argument, quote a "--opt="
prefix unless it is a plain option token, and refuse a newline in a filter rule
written to the replay script.  rsync-ssl: bind the server certificate to the
requested hostname in stunnel mode.
2026-07-20 14:05:31 +10:00
Andrew Tridgell d199b43149 daemon: harden authentication, access, socket and config-hook handling
authenticate.c: seed gen_challenge() from /dev/urandom, add an "auth digest" floor
to refuse weak negotiated digests, and fstat the opened --password-file fd rather
than re-stat the pathname; checksum.c carries auth_digest_rank().  socket.c: reject
control bytes in the daemon host before a proxy CONNECT and bind the stunnel server
cert to the requested hostname.  clientserver/access: warn when proxy-protocol
fail-closes.  loadparm + daemon-parm: only shell-quote %RSYNC_*% for shell-executed
hooks, and add the auth-digest / proxy-protocol-hosts module parameters.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 3eeedf8d08 daemon: robustness against malicious-peer protocol input
Refuse malformed/hostile wire input that could crash or corrupt the receiver:
io.c (out-of-range file index, count*blength OFF_T overflow, read_args NUL room,
deferred in_multiplexed), flist.c (sub-flist after the final flist is freed,
FLAG_HLINKED on dirs / gated on preserve_hard_links, parent_ndx bound, cleared-
slot ndx in the transfer phase), hlink.c (undeclared cross-flist gnum -> error not
assert), match.c (clamp peer flength, re-check len before want_i), log.c (drop
peer-reachable asserts and F_SUM deref), exclude.c (merge-file recursion cap,
trailing-backslash heap fix), lib/pool_alloc.c (ASan redzone for pool underflow).
2026-07-20 14:05:31 +10:00
Andrew Tridgell b62d7ed6d4 acls/xattrs: race-safe fd-based metadata application
Apply ACLs and xattrs through a held file descriptor instead of by path, closing
the symlink-race where an attacker swaps the leaf between the transfer and the
metadata set.  lib/acl.c provides fd/at POSIX-ACL primitives (the system libacl
*_at where available, else a /proc/self/fd compat that never follows on the
fallback); acls.c routes through them and stays functional (path-based) where the
OS lacks a race-safe primitive; xattrs.c routes copy_xattrs through a held fd; -VV
(usage.c) reports the runtime race-safe-ACL capability.
2026-07-20 14:05:30 +10:00
Andrew Tridgell dd3e6bfece daemon: treat exclude=/filter as a name filter, not a symlink boundary
The daemon exclude/filter chain is a name-based visibility/tamper filter, as in
stock rsync (verified against 3.2.7): a symlink whose own name is not excluded is
followed to an excluded target, and the documented symlink defense is `munge
symlinks`, not the filter.  Collapse ".." (via sanitize_path) before the daemon
dest / temp-dir / backup-dir / partial-dir / basis filter checks so a "../excluded"
path is matched by name like 3.2.7 (clean_fname's CFN_COLLAPSE_DOT_DOT_DIRS does
not collapse "a/b/c/../../../secret"), and keep a leading "/" for a "path = /"
module so an absolute filter rule still matches.  The module-ROOT confinement of
operator paths is unchanged (previous commit); only the in-module name match is
restored to its 3.2.7 behaviour.
2026-07-20 14:05:30 +10:00
Andrew Tridgell defd7110ac confine operator- and peer-supplied paths to the served module
Route every operator-supplied directory path (--temp-dir / --partial-dir /
--backup-dir / alt-dest basis) and the transfer engine's own dest/source opens
through the secure resolver + ownership walk, so a symlink owned by another uid
can no longer redirect a read, a backup, a staging open, a rename, an unlink or a
new-destination create outside the module.  Covers backup.c, generator.c (alt/
link/in-place basis), receiver.c (basis open), sender.c (remove-source / source
open / copy-links leaf), rsync.c (held-fd attr stat), clientserver.c (pid-file
parent pin), and main.c (relative-basis make-absolute, mkpath dest-arg guard).
--insecure-links / "insecure links = yes" is the local opt-out (a daemon never
honors a peer-forwarded one).
2026-07-20 14:05:30 +10:00
Andrew Tridgell 54965efca9 syscall/util1: race-safe path resolution via a held dirfd-stack resolver
The core symlink-race (TOCTOU) defense for the CVE-2026-29518 class: a portable
secure resolver that walks a path one component at a time holding an O_NOFOLLOW
dir fd per level (the dirfd-stack), plus the do_*_at() filesystem wrappers, the
held-directory fd cache, secure_relative_open[_at](), the operator-path ownership
walk (owner_walk_parent / open_no_attacker_symlinks, follow a uid0/euid symlink,
refuse a foreign one) and its module-root confinement (abspath_excluded_by_module).
util1.c routes change_dir / robust_rename / make_path / handle_partial_dir through
it; the resolver bounds its deep-path fd use against RLIMIT_NOFILE.  Also drops the
obsolete android.c openat2 path and confines delete.c via the held dirfd.
2026-07-20 14:05:30 +10:00
Andrew Tridgell adab781538 build: wire up the secure resolver, the ACL fd helper, and the test harnesses
configure.ac: detect fdopendir + a working dirfd() (a macro/inline on the BSDs,
so the default link probe mis-detects) and getrlimit/setrlimit for the resolver's
deep-path fd budget; add the race-safe-ACL build probes.
Makefile.in: build lib/acl.o; build the new t_rename_secure / t_symlink_secure /
t_acl unit harnesses; drop the obsolete android.o object.
mkgitver: version a git build by the dev version + commit (not the nearest tag),
and harden the version.h parse.
2026-07-20 14:05:30 +10:00
Andrew Tridgell 3b84610ccb manpage: clarify remote-shell daemon user@ handling
The description of user@host::module transfers over a remote shell only
documented the "ssh -l ssh-user" form, which led readers to conclude that
user@ never reaches the remote shell. In fact, for the simple
`--rsh=ssh user@host::module` form the user@ prefix is used both as the
ssh login user (ssh -l user) and as the rsync-user offered to the module;
the two are the same name. rsync only omits its own -l when the remote
shell command already specifies one, in which case user@ becomes the
rsync-user alone.

Spell out the default behaviour and why the explicit -l is needed to use
a different ssh login than the rsync-user.
2026-07-20 14:04:27 +10:00
Zen Dodd 9062840a91 ci: restore default build target 2026-07-20 10:05:43 +10:00
Zen Dodd 3c9a12011e ci: fix no-AT_FDCWD compile check 2026-07-20 10:05:43 +10:00
Zen Dodd 5cb4b8290b syscall: build without AT_SYMLINK_NOFOLLOW 2026-07-20 10:05:43 +10:00
Stuart Inglis e6956e0a30 match: bound the hash_search() chain walk (issue #217)
hash_search() walks the entire hash-table chain for the current rolling
checksum at every byte offset of the source file. Disk and VM images
contain large runs of identical blocks, so a single weak checksum
(get_checksum1) can collide thousands of times and pile every one of
those blocks onto one chain. When the sender then rolls across a region
whose weak checksum keeps landing on that chain without ever producing a
strong-checksum match, it re-walks the whole chain for every byte, giving
O(file_size * chain_length) behaviour. The result is rsync sitting at
100% CPU for hours with no apparent progress -- the long-standing "rsync
hangs on large files" reports.

Cap the number of same-weak-checksum candidates examined per offset at
MAX_CHAIN_LEN. Once the cap is hit we treat the offset as a non-match and
roll forward a byte; any block skipped this way is simply sent as literal
data, so the transferred result is always correct -- only the transfer
size is marginally affected. This is purely a sender-side search limit:
it changes no checksum, emitted byte, or protocol field, so a capped
sender interoperates with an unmodified receiver and vice versa.

On a synthetic 40000-block basis sharing one weak checksum, syncing a
60KB source dropped from ~18.4s to ~0.7s; the unbounded cost grows with
the square of the file size.

testsuite/hashsearch-chain_test.py reproduces the pathology with a tiny
basis of weak-checksum-colliding decoy blocks and asserts, via the
existing false_alarms counter (--debug=deltasum1), that the per-hash-hit
chain walk stays bounded. The assertion is exact and machine-independent
rather than timing-based.
2026-07-20 07:43:45 +10:00
Zen Dodd e2a24e8581 testsuite: C23 bool compatibility 2026-07-20 07:27:22 +10:00
AlphaGlider25 f5fa55672d Fix Solaris xattr retry handling
Use the remaining byte count for retry writes and avoid using a
size_t sentinel for write failures.
2026-06-21 14:14:40 +10:00
Andrew Tridgell 5553271274 ci: run scan-build on pinned clang-18 + latest clang (informational)
Split the scan-build workflow into two non-gating jobs, each uploading
its HTML report as an artifact:

- pinned-clang18: clang-18 / clang-tools-18 on ubuntu-24.04, so the
  checker set -- and thus the report -- is deterministic.
- informational-latest: whatever clang ubuntu-latest ships, to surface
  what newer analyzers see.

Both are informational (no --status-bugs): the tree still has known
clang-18 findings, so the run reports without blocking the build.  Once
the tree is at zero for clang-18, re-add --status-bugs to the pinned job
to turn it back into a gate.  Installs libpopt-dev so configure finds
popt under the scan-build compiler wrapper.
2026-06-16 08:55:39 +10:00
Andrew Tridgell 3f5884a3bb scan-build: close a test-helper FILE* leak
wildtest.c: close the test file before main() returns (a real, if
exit-benign, FILE* leak flagged by scan-build).
2026-06-16 08:55:39 +10:00
Andrew Tridgell 4e67f87479 scan-build: fix resource leaks on error paths
clientserver.c: close the --early-input-file FILE* on the
fstat/oversize/early-EOF error returns; it was only closed on the
success path.
getgroups.c: free the gid list before returning.
2026-06-16 08:55:39 +10:00
Andrew Tridgell 8118744f2a scan-build: drop dead assignments
Remove stores that are never read before being overwritten or going
out of scope. No behavior change except batch.c write_opt, which now
accumulates the leading-space write error into the return value
(consistent with the arg branch) instead of discarding it.

simd-checksum-x86_64.cpp, options.c, util1.c, batch.c
2026-06-16 08:55:39 +10:00
Andrew Tridgell 412cddf6be scan-build: zero-init buffers the analyzer can't prove are written
clang's static analyzer doesn't model SIVAL/SIVAL64/SIVALu or
getpeername/getsockname as initializing their target bytes, so it
reports false "garbage value" reads. Zero-init the affected buffers;
the bytes are always overwritten at runtime, so this only quiets the
analyzer.

io.c:     write_varint/write_varlong b[]
hashtable.c: hash_search buf[]
socket.c: accepted_peer/our_local
2026-06-16 08:55:39 +10:00
Greg Kroah-Hartman 399cf1aa5d generator: fix build warning
in sum_sizes_sqroot() cnt is assigned a variable but never actually
used, so remove it entirely as it's not needed anymore.
2026-06-16 05:54:17 +10:00
Andrew Tridgell fe93ffcd65 fix funding github username 2026-06-15 13:58:46 +10:00
Andrew Tridgell a27095f0e1 Create FUNDING.yml 2026-06-15 13:53:04 +10:00
Andrew Tridgell 14ab548efc runtests: write valgrind logs to a world-writable subdir
Under --valgrind some tests run rsync with reduced privileges: partial_nowrite
wraps it in "setpriv --inh-caps -all --bounding-set -all" to force EACCES, and
chdir-symlink-race's daemon drops to the module's uid.  Such a child cannot
create valgrind's --log-file in a root-owned scratchbase, so valgrind aborts at
startup and the test fails (seen only in the root + --use-tcp cell).

Put the logs in a 1777 valgrind-logs/ subdir so a privilege-dropped child can
always write them.  Scan and cleanup are unchanged; the logs just move one
directory down.
2026-06-14 08:33:03 +10:00
Andrew Tridgell 7bebcee20f generator: don't read an unstat'd sx.st when creating a device/special
this fixes a valgrind error where we could read an uninitialised sx.st
field when we don't fill the stat data.

Also drop the now-obsolete testsuite/valgrind.supp stanzas for these
reads (atomic_create/delete_item, plus the rwrite strlcpy over-read that
master already fixed) -- they are no longer needed now the reads are gone.

Thanks to report from Michael Mess <michael@michaelmess.de>
2026-06-14 08:33:03 +10:00
Andrew Tridgell e9eda5d5df wildtest: don't read past the buffer when scanning a test line
main()'s line parser stepped through the fgets() buffer with `*++s` in
three places without first checking for the terminating NUL, so a test
line whose last token runs to the end of the buffer (e.g. a final line
with no trailing newline) could advance s past the NUL and read out of
bounds.

Guard the flag-separator check and rewrite the two whitespace-skip loops
so they never step past the NUL. No behaviour change for well-formed
input: the existing wildtest.txt still passes, and the crafted overflow
input is now clean under valgrind.

Fixes #776
Reported-by: vikk777 (@vikk777)
2026-06-13 18:56:49 +10:00
Andrew Tridgell 04e4ee4ece log: copy forwarded message by length in rwrite(), not strlcpy()
The valgrind memcheck CI flagged 'Conditional jump depends on uninitialised
value(s)' in rwrite() -> strlcpy() (log.c) and the subsequent logit() fprintf.
rwrite()'s daemon/logfile branch did strlcpy(msg, buf, MIN(sizeof msg, len+1)),
but strlcpy() scans the whole source with strlen(); buf is the data buffer from
read_a_msg() (io.c) holding exactly len bytes of a forwarded MSG_* payload with
no NUL terminator, so strlen() reads past the message into uninitialised stack.

Copy exactly len (bounded) bytes with memcpy() and NUL-terminate, matching the
(buf, len) contract the rest of rwrite() already honours.  Behaviour is
unchanged for the NUL-terminated callers; the over-read is gone.

Full testsuite under valgrind (1572 logs) now reports zero unsuppressed errors.
2026-06-13 18:56:32 +10:00
Andrew Tridgell aae9534a6b ci: build test helpers before the valgrind run
`make` alone does not build the CHECK_PROGS test helpers (tls, trimslash,
t_chmod_secure, ...), so runtests.py exited immediately with "missing
test helper program(s)", produced no valgrind logs, and the scan step
failed every job with "the suite did not run". Use `make check-progs`,
which builds rsync plus the helpers and symlink fixtures without running
the suite.
2026-06-13 18:56:32 +10:00
Andrew Tridgell d0f2444aa5 testsuite: force C locale in reverse-daemon-delta byte-count parse
rsync groups the "sent/received N bytes" summary numbers using the
locale's thousands separator (e.g. de_DE uses '.'), which broke the
[\d,]+ parser and failed the test for testers in non-C locales. Run the
peer client under LC_ALL=C so the output is deterministic.

Reported-by: Michael Mess <michael@michaelmess.de>
2026-06-13 18:56:32 +10:00
Andrew Tridgell e6cb8788f8 testsuite: add gating valgrind memcheck workflow + suppressions
Add a .github/workflows/valgrind.yml that runs the full suite under
valgrind in a 2x2 matrix (user/root x pipe/tcp transport) and gates on
memory errors. It uses --leak-check=no: rsync intentionally leaves
file-list/socket/option memory unfreed at exit, so a leak check is
inherently noisy; the gate flags uninitialised reads, invalid
read/write, bad frees and uninit syscall params instead.

Add testsuite/valgrind.supp covering the known-benign reports (rwrite
strlcpy over-read on a non-NUL-terminated peer message, atomic_create/
delete_item st_mode read under fakeroot, libfakeroot msgsnd padding,
plus popt/xxhash leaks for manual --leak-check audits). runtests.py
--valgrind now loads it automatically.
2026-06-13 18:56:32 +10:00
Andrew Tridgell 11e3e2390a token: allow uncompressed literal runs larger than CHUNK_SIZE
The hardening in c44c90e9 added a check in simple_recv_token() rejecting
any uncompressed literal-run length > CHUNK_SIZE (32k). That assumption
breaks interoperability: other rsync implementations -- e.g. the acrosync
library used by the iOS "PhotoBackup" app -- use a 64k block size and
send literal runs of 65536 bytes, which 3.4.3+ now rejects with
"invalid uncompressed token length 65536".

The check was unnecessary: simple_recv_token() already reads the run
CHUNK_SIZE bytes at a time via the residue loop (n = MIN(CHUNK_SIZE,
residue)), so read_buf() never writes past the static CHUNK_SIZE buffer
regardless of the wire-supplied length. Drop the check to restore
interop; the compressed-token integer-overflow fix from c44c90e9 (the
MAX_TOKEN_INDEX / rx_token caps) is left unchanged.

Fixes #1002
Reported-by: Jack Whitham
2026-06-13 18:01:19 +10:00
Will SargandClaude Sonnet 4.6 d0c0ca2d26 testsuite: fix executability test skip on FreeBSD (EFTYPE)
FreeBSD and OpenBSD return EFTYPE (errno 79) when chmod-ing a sticky bit
onto a regular file as non-root, rather than EPERM/EACCES. Catch OSError
and check errno against the expected skip set so the test skips correctly
on those platforms instead of erroring out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:47:39 +10:00
Andrew Tridgell 4ef775fa97 abdiff: A/B differential regression hunter for rsync
testsuite/abdiff.py runs the same benign transfer with two rsync binaries
(A = build under test, B = a baseline) and compares the OUTCOME -- exit code,
stderr, --stats "Literal data", the destination tree (content + full metadata),
the --itemize list, and (with --cost) peak process-group RSS. For benign input
the two must be indistinguishable; any divergence is a regression candidate.
It is a developer tool, NOT a runtests.py test (does not end in _test.py).

Capabilities:
- Scenario sweeps over options / path shapes / file types / sizes / modes /
  selection / placement / wire / transports, plus domain-knowledge pairwise +
  combo sweeps and a stochastic fuzzer/role matrix.
- Transport lanes: local, ssh split (lsh.sh), stdio-pipe daemon, a REAL TCP
  daemon (bound port + greeting/handshake/auth challenge-response), and the
  restricted rrsync wrapper (support/rrsh.sh; each binary paired with its own
  version's rrsync via --rrsync-a/--rrsync-b, since rrsync ships in the script).
- Stability gate: each binary is run N times and escalated on a candidate diff;
  nondeterministic scenarios are quarantined FLAKY, never reported as regressions.
- Parallel (-j, default 20) with a per-run findings log; --loop runs until
  --timelimit (or Ctrl-C), feeding the pool a half-random / half-systematic
  stream of new combinations. As root an "all" run also folds in the root-only
  sweeps (priv, daemonchroot).
- General coverage levers: a cost oracle (--cost, peak RSS over the whole process
  group), transport lifted as an orthogonal axis, a resume/redo sweep, and
  type-transition / nanosecond-mtime / scale (--scale N) fixtures.

Documented in testsuite/README.md.
2026-06-11 12:32:54 +10:00
Andrew Tridgell 8b042907e5 testsuite: add perftest.py to compare two rsync builds' transfer speed
A standalone dev tool (run directly, not via runtests.py) for catching
performance regressions between rsync releases.  Given two rsync binaries it
builds one deterministic test tree -- heavy-tailed file sizes, a directory
spine, symlinks, hard links and a spread of permission modes, modelled on the
gentestdata generator -- then runs the two binaries ALTERNATELY for N loops,
timing each transfer, and reports the mean and standard deviation per binary.

Each loop times a full copy into an emptied destination and an incremental
no-op against an already-synced one (rsync's scan/file-list/stat overhead,
where many regressions hide); --mode selects.  The first run of each binary is
dropped to reduce page-cache impact, the run order alternates to cancel drift,
and a B-vs-A slowdown is flagged only when it exceeds the run-to-run noise.
2026-06-11 08:23:56 +10:00
Zen Dodd b88080bd49 docs: clarify chmod copy special bits 2026-06-09 14:18:29 +10:00
Zen Dodd 30751ca864 chmod: clear special bits on copy assignment 2026-06-09 14:18:29 +10:00
Zen Dodd 0fdd7cd6a2 chmod: support permission copy modes 2026-06-09 14:18:29 +10:00
Andrew Tridgell fdc58b7cce lib: use .balign in md5 x86-64 asm to fix macOS over-alignment
The file used ".align 16" intending 16-byte alignment (GNU/ELF semantics).
On macOS the Mach-O assembler reads ".align N" as 2^N, so it requested
64KB alignment for __TEXT,__text, producing:

  ld: warning: reducing alignment of section __TEXT,__text from 0x10000
  to 0x1000 because it exceeds segment maximum alignment

The linker clamps it back, so it was harmless, but .balign 16 means
16 bytes on every target and silences the warning.
2026-06-09 12:26:24 +10:00
Andrew Tridgell 85eedb242e checksum: guard the AVX2 roll-asm path with a runtime CPUID check
When built with --enable-roll-asm, get_checksum1() called the AVX2 asm
routine get_checksum1_avx2_asm() unconditionally. Unlike the intrinsic
path (get_checksum1_avx2_64), which is function-multiversioned with a
target("default") fallback and so resolves safely on any CPU, the asm
routine is a single AVX2-only symbol with no fallback. On an x86-64 host
without AVX2 (an older CPU, or a VM that does not expose AVX2) the first
block checksum executes a VEX-encoded instruction and dies with SIGILL,
which surfaces as "connection unexpectedly closed (0 bytes received so
far)" and a code-12 protocol error.

Gate the asm call on a cached __builtin_cpu_supports("avx2") check, the
same signal the intrinsic resolver uses. When AVX2 is absent we skip it
and the SSSE3/SSE2/scalar steps (safe everywhere) do the work. Apply the
same guard in the simdtest harness so it can run on non-AVX2 hosts too.
2026-06-09 12:26:24 +10:00
Andrew Tridgell 806dff20d9 tests: add clang scan-build static-analysis CI (informational)
Run the clang static analyzer over a check-progs build, publish the HTML report
as an artifact, and print the bug count to the run summary. INFORMATIONAL only:
it does not pass --status-bugs, so it surfaces new analyzer findings without
going red on the existing (overwhelmingly false-positive) reports.

Runs on push/PR to master and via workflow_dispatch. No cron: it is
informational and its output only changes with the code (push/PR) or the clang
version, so a daily run on an unchanged tree would add noise without value.
2026-06-08 20:54:57 +10:00
Andrew Tridgell 8f63c498e9 tests: add ASan+UBSan CI gate
Add a clang AddressSanitizer + UndefinedBehaviorSanitizer workflow that builds
rsync with -fsanitize=address,undefined -fno-sanitize-recover=undefined -DNDEBUG
and runs the full test suite over both the stdio-pipe and TCP daemon transports.

UBSAN_OPTIONS=halt_on_error=1 together with -fno-sanitize-recover=undefined makes
any undefined behaviour fatal, so this job gates: the tree must stay UBSan-clean.
The remaining findings are fixed in code (hashtable/mdfour shifts, xattrs, and
log.c's file_struct, kept aligned via rounding.h); only byteorder.h's intentional
unaligned accessors are suppressed, with no_sanitize. -DNDEBUG builds as a release
does (assert() compiled out) so ASan covers the production code paths.

Runs on push/PR to master and via workflow_dispatch, plus a weekly cron to
catch breakage from a moving ubuntu-latest/clang toolchain (push/PR already
cover every code change, so daily would just re-run an unchanged tree).
2026-06-08 20:54:57 +10:00
Andrew Tridgell df2833b318 io: drop the dead/unnecessary read_varint UBSan guard
The cherry-picked #428 wrapped no_sanitize attributes on read_varint() and
read_varlong() in `#ifndef CAREFUL_ALIGNMENT`, but byteorder.h always
#defines CAREFUL_ALIGNMENT (to 0 or 1), so that guard is never true and the
attributes were dead code.

They are also unnecessary: both functions read the assembled value through
an aligned union member (union { char b[5]; int32 x; }), not an unaligned
cast, so UBSan's alignment check never fires there (verified: the ASan+UBSan
suite is clean without them).  Remove the whole block rather than fix the
guard.  (The byteorder.h annotations from #428, which are real and correctly
placed inside the !CAREFUL_ALIGNMENT branch, are kept.)
2026-06-08 20:54:57 +10:00
Sam James 7214372a8a Disable UBSAN for alignment-sensitive functions when !CAREFUL_ALIGNMENT
rsync sets CAREFUL_ALIGNMENT for architectures which do not support
unaligned access. Disable UBSAN for functions which may use unaligned
accesses when CAREFUL_ALIGNMENT is set.

Bug: https://github.com/WayneD/rsync/issues/427
Signed-off-by: Sam James <sam@gentoo.org>
(cherry picked from commit 11c1e934e8)
2026-06-08 20:54:57 +10:00
Andrew Tridgell 497357800a log: align the file_struct built in log_delete()
log_delete() builds a struct file_struct inside a char buffer offset by the
(EXTRA_LEN-granular) extra data.  The EXTRA_ROUNDING block that rounds that
offset up to the struct's alignment (exactly as flist.c does for its pool
allocations) was dead code here: log.c never included rounding.h, so
EXTRA_ROUNDING was undefined and the rounding never ran, leaving the
file_struct pointer potentially under-aligned.  That trips UBSan's alignment
check and would fault on strict-alignment arches.

Include rounding.h (and add the Makefile dependency) so the existing rounding
actually applies -- fixing the alignment at the source rather than suppressing
the sanitizer.
2026-06-08 20:54:57 +10:00
Andrew Tridgell fa084c4ae3 xattrs: fix UBSan-detected undefined behavior
Three pre-existing issues UBSan flags during the xattr tests:

  * xattr_lookup_hash(): the summed hashlittle2() values overflow the
    signed int64 accumulator (UB).  Accumulate in uint64_t and convert back
    at return -- the key is only used for hash-table equality, so the value
    is unchanged.
  * rsync_xal_get(): for an empty list (count == 0) the loop init
    `rxa += count-1` forms `items - 1` on a NULL `items` (UB).  Guard with
    `if (count)`.
  * rsync_xal_store(): `memcpy(dst, xalp->items, 0)` passes a NULL source for
    an empty list (UB).  Guard with `if (xalp->count)`.
2026-06-08 20:54:57 +10:00
Andrew Tridgell 4148419736 hashtable, mdfour: avoid signed left-shift overflow
UBSan flags two spots that shift a value into the top bits of a word via a
signed operand:

  * lib/mdfour.c copy64(): `in[i] << 24` promotes the uchar to int, so a
    byte >= 128 overflows int (UB).  Cast each byte to uint32.
  * hashtable.c NON_ZERO_64(): `(int64)(x) << 32` overflows int64 whenever
    x's high bit is set.  Shift as uint64_t (covers all four call sites).

Behavior-preserving -- only the intermediate type changes; the resulting
bit pattern is identical.
2026-06-08 20:54:57 +10:00
Andrew Tridgell 66712a90b3 rsync-web: updates for the 3.4.4 release 2026-06-08 14:45:03 +10:00
Andrew Tridgell b780749ffb release.py: accept a git worktree in require_top_of_checkout()
In a git worktree .git is a file (a gitdir pointer), not a directory,
so os.path.isdir('.git') wrongly aborted with "no .git dir" when the
release was run from a worktree. Use os.path.exists() so it works from
both a normal checkout and a linked worktree.
2026-06-08 14:44:37 +10:00
Andrew Tridgell d25c5e4b11 ci: move the daily scheduled jobs to weekly
Every platform build (the BSD/Solaris/macOS/cygwin/almalinux/ubuntu jobs),
coverage, the version-mix job and the android static build ran on a daily cron
*in addition to* push and pull_request to master. Since push/PR already cover
every code change, the cron only adds drift coverage -- catching breakage from a
moving runner image or toolchain that no commit triggers. Those images do not
change daily, so a daily run mostly re-tests an unchanged tree.

Move them all to a weekly cron (Mondays, keeping each job's existing time) to
keep that drift coverage at roughly a seventh of the Actions spend and log
noise. fleettest was already weekly. Per-change CI on push/PR is unchanged, and
workflow_dispatch still allows an on-demand run.
2026-06-08 10:25:38 +10:00
Andrew Tridgell 1ddfe17d65 fleettest: --cleanup also kills stray flippers/daemons and root-owned dirs
A run killed without a parent-death backstop can strand a TOCTOU path-flipper
(a busy `python -c` rename loop that pins a CPU) and an orphaned test rsyncd
(--no-detach --address=127.0.0.1) that squats its fixed port -- the wedge the
claim_ports() bind-probe now reports and points at --cleanup. Sweep both, best
effort, before removing the run dirs.

Each sweep counts the pattern, kills it (with a `sudo -n` retry for a process a
root-running test left), then re-counts after a settle: KILLED reports what
actually died, and a process that survives (pkill blocked, no passwordless sudo,
missing/limited pkill) is reported as SURVIVED and fails the run instead of
falsely claiming success.

Run-dir removal falls back to `sudo -n rm` so a dir whose contents a root test
owns is removed instead of failing with "Permission denied" (the failure mode
seen on the ubuntu/mac targets); only a dir that survives even sudo is failed.

The kill patterns use the pgrep self-exclusion trick ('r[e]name', 'det[a]ch')
so they match a real process's "rename"/"detach" but not the literal pattern in
the cleanup shell's own argv -- run_on() passes the whole script as the remote
argv, so without it --cleanup would signal itself. The patterns are host-global
(not scoped to one run), so --cleanup is documented to run between runs, not
during one.
2026-06-08 09:41:59 +10:00
Andrew Tridgell 6e6b4135ab testsuite: verify a claimed test port is actually bindable
claim_ports() takes a POSIX byte-range lock per port, which serializes
concurrent live test runs. But the kernel drops that lock the instant the
holding process dies, even if the run left an orphaned rsync --daemon still
bound to the port -- which happens when a run is SIGKILLed on a platform with
no parent-death backstop (rsyncfns only arms PR_SET_PDEATHSIG, Linux-only, so
the BSDs/Solaris/macOS can strand a daemon). A later run then wins the freed
lock while the socket is still squatted and dies with a cryptic "bind() failed:
Address already in use" / "did not see server greeting".

After taking each lock, actually bind the port (SO_REUSEADDR, so a port merely
in TIME_WAIT is not a false positive; only a live squatter fails) and close it
immediately. On failure stop with an actionable message naming the port and the
likely orphaned daemon. Closes the gap that masked the OpenBSD daemon-auth wedge.
2026-06-08 09:41:59 +10:00
Andrew Tridgell c2b8e4532b fleettest: require runtests.py in --testsuite-repo, not the build tree
When --testsuite-repo provides the suite, the build tree (--repo) need not
carry runtests.py -- it may be an older release whose shell testsuite predates
the Python runtests.py (e.g. a 3.4.1 backport branch built and tested with the
current suite).  Check runtests.py in TESTSUITE_REPO and only require the build
tree to be rsync source (rsync.h).
2026-06-08 06:29:49 +10:00
Andrew Tridgell 7b66c0665f fleettest: add --testsuite-repo to run another tree's suite against this build
--repo couples the built source and the test suite that exercises it.
--testsuite-repo PATH overlays runtests.py + testsuite/ from a second tree onto
the staged build tree, and sources the expected-skip workflows from it, so one
can build an older release (e.g. a 3.4.x stable branch) and run the current
comprehensive suite against that binary. Defaults to --repo, so the existing
single-tree behaviour is unchanged.
2026-06-08 06:29:49 +10:00
Andrew Tridgell 49f8dd1ca4 runtests: stop discovering obsolete *.test shell tests
The shell testsuite was removed in 1f689ec0 (rewritten in Python); only
*_test.py remain, yet collect_tests still globbed *.test and _testbase mapped
foo.test and foo_test.py to the same canonical name. Harmless on a master tree
(no .test files), but when an older tree's *.test files are present -- e.g.
fleettest --testsuite-repo building a 3.4.x release whose shell suite still
exists -- both glob to the same test name and scratch dir and race under -j,
producing spurious failures. Drop .test discovery entirely.
2026-06-08 06:29:49 +10:00
Andrew Tridgell 6fad1d7d74 testsuite,ci: mark recv-discard-nullderef CI skip and tighten its check
The regression test honestly skips when it cannot force the receiver's
output mkstemp() to fail -- as root (root bypasses DAC) and on Cygwin
(chmod 0555 does not deny the owner a write). The ubuntu, ubuntu-22.04,
almalinux and macOS jobs run `make check` as root, and Cygwin can't
enforce the unwritable directory, so the test skips on all of them.
runtests.py fails a run on any skip-set mismatch, so add the test to
those jobs' RSYNC_EXPECT_SKIPPED lists; the BSD/Solaris jobs run as root
too but enforce no expected-skip set, so they need no change.

Also tighten the pass condition. The post-chmod writability probe already
guarantees the receiver discards (mkstemp must fail), so an exit 0 would
mean the file actually transferred and the discard path was never
exercised -- a silent false-pass. Require exactly exit 23 (the forced
discard leaves the file untransferred); 12 remains the pre-fix crash.
2026-06-06 18:56:51 +10:00
pterrorandClaude Opus 4.8 b8562dbf4a testsuite: regression for the receiver discard-path NULL deref
Drives a real sender<->receiver pair (client sender -> daemon receiver,
both the binary under test in the default pipe transport) so the receiver
actually takes the recv_files discard path -- a local `rsync a b` does
not. The basis and source share a leading block so the generator emits
real sums and the receiver gets a block MATCH; the destination directory
is made unwritable so the receiver's output mkstemp() fails and it
discards the delta. Pre-fix the receiver SIGSEGVs in full_fname(NULL),
which the client sees as a protocol-data-stream error (code 12); post-fix
it drains the delta and reports a benign code 23 (or 0).

Skips (exit 77) when run as root, since root bypasses DAC and the
unwritable destination would not make mkstemp() fail -- so the discard
path, and the bug, would never be reached.

Verified red-on-buggy / green-on-fixed against the 0d0399bb receiver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:56:51 +10:00
pterrorandClaude Opus 4.8 d66846351d receiver: fix NULL deref on the delta discard path
receive_data() crashed a receiver that was merely DISCARDING a file's
delta stream. discard_receive_data() calls receive_data() with
fname == NULL and fd == -1, so size_r == 0 and mapbuf == NULL. A normal
block-MATCH token (against a block the basis and source share) then
reaches the !mapbuf branch added in 31fbb17d ("receiver: fix absolute
--partial-dir delta resume"), which calls full_fname(fname). full_fname()
dereferences its argument unconditionally (util1.c: `if (*fn == '/')`),
so fname == NULL faults there -> receiver SIGSEGV.

This is a normal-operation crash with a stock cooperating sender, not an
adversarial one. The generator hands the sender real block sums whenever
the basis is readable and we're in delta mode; the receiver only decides
to discard afterwards, when its output cannot be produced -- e.g. the
destination directory is not writable (mkstemp fails), the basis turns
out to be a directory, or a --partial-dir resume is skipped. A MATCH
token arriving during that discard hit the NULL deref.

The 31fbb17d branch is correct only for a REAL output transfer (fd != -1,
fname valid): there, a block match with no mapped basis is a genuine
protocol inconsistency (the generator promised a basis the receiver could
not open), and honoring it would silently omit those bytes from the
verification checksum or leave a hole, so hard-erroring -- and
full_fname(fname) -- is right. It conflated that with the discard path.

The discriminator is fd, not mapbuf: on the discard path fd == -1 always;
on the real-output inconsistency fd != -1. Scope the "no basis file"
protocol error to fd != -1 (where fname is non-NULL and full_fname is
safe) and, on the discard path (fd == -1), absorb the matched bytes
benignly (offset += len; continue) -- symmetric with the literal-token
handling just above, and restoring the pre-31fbb17d behavior. The
real-transfer inconsistency check is preserved unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:56:51 +10:00
Andrew Tridgell bad790dd2e fleettest: add a per-target max_retry budget for flaky tests
A slow or heavily-loaded fleet box can occasionally flake a concurrency-
sensitive test (e.g. a daemon/lsh test under -j8 on a nested-VM Solaris box).
Rather than dropping the whole target to a lower -j, add a per-target
"max_retry" property: after a run, each failed test is re-run on its own up to
max_retry more times, and any that then pass are dropped from the failure list.
Recovered tests are listed in a new "RECOVERED" report section, so a flake is
surfaced, never silently hidden.

Applies to every pass for the target (pipe, tcp, protoNN, nonroot).  Default 0
keeps the current no-retry behaviour.
2026-06-06 16:41:51 +10:00
Zen Dodd c67d1935d8 docs: fix option summary inconsistencies 2026-06-06 16:11:30 +10:00
Zen Dodd 1d6770edbc ci: test uninstall targets 2026-06-06 16:07:20 +10:00
Zen Dodd 135f2eca01 testsuite: correct files-from comment coverage 2026-06-06 14:54:55 +10:00
Zen Dodd 6bfb487155 testsuite: cover files-from comments 2026-06-06 14:54:55 +10:00
Zen Dodd b1d68089e5 docs: describe files-from comments 2026-06-06 14:54:55 +10:00
Zen Dodd a850e5d57e testsuite: cover groupmap empty source matching 2026-06-06 14:29:15 +10:00
Zen Dodd 6e3f17cea2 docs: clarify empty name groupmap matching 2026-06-06 14:29:15 +10:00
Zen Dodd 4a55da168c docs: clarify batch compression limits 2026-06-06 14:22:51 +10:00
Zen Dodd 7639ce4607 configure: avoid runtime IPv6 availability probe 2026-06-06 14:21:07 +10:00
Zen Dodd 0d31a20845 docs: mention systemd rsync daemon units 2026-06-06 14:17:41 +10:00
Zen Dodd a5a7500707 build: fix rrsync manpage fallback 2026-06-06 14:17:00 +10:00
Andrew Tridgell 24b44290ab fleettest: add per-target protocol passes (check30/check29)
A target can list older "protocols" (e.g. [30, 29]) in the fleet config;
each runs as an extra stdio-pipe pass with runtests --protocol=N, the fleet
analogue of a workflow's check30/check29 steps. The passes reuse the same
parsed RSYNC_EXPECT_SKIPPED list as the default pipe run and appear as protoNN
columns in the report and --timing breakdown. Targets without the key run only
the default protocol and show "-" there.

The example config's ubuntu-2604 target (mirroring ubuntu-build.yml, which has
check30/check29 steps) now sets protocols: [30, 29].
2026-06-06 10:36:13 +10:00
SebMtn 0d0399bb14 rrsync: add -absolute argument to support calling rsync with absolute path
Signed-off-by: SebMtn <102696928+SebMtn@users.noreply.github.com>
2026-06-05 16:01:44 +10:00
Miao Wang c1d7b5c6f9 receiver: try to chmod the target file when denied opening
When the target file exists but its permission modes prevent us from
opening it for writing, we can try first to chmod it and then open it.
2026-06-05 14:31:46 +10:00
Mike-Goutokuji 24e3d4d83c Always clear st out and validate nanoseconds before using it
Otherwise we get errors.
Fixes: https://github.com/RsyncProject/rsync/issues/927
2026-06-05 12:28:29 +10:00
Andrew TridgellandStiliyan Tonev 9df00b6dc3 testsuite: regression for #880 --mkpath --dry-run file-to-file
Covers both halves: a --mkpath file-to-file --dry-run must succeed and
match the real run (the #880 abort), and a plain file-to-file --dry-run
onto an existing differing destination must still itemize the real change
rather than report it as brand new.  Both compare "--dry-run -i" output
against the real run.

Co-authored-by: Stiliyan Tonev (Bark) <stiliyan21@gmail.com>
2026-06-05 11:51:30 +10:00
Andrew TridgellandStiliyan Tonev 3cd70a3761 main: fix --mkpath + --dry-run file-to-file copy (#880)
A single-file --mkpath copy whose destination parent does not exist
failed under --dry-run: make_path() only *reports* the directories it
would create in a dry run, so change_dir#3 then tried to chdir into a
parent that isn't there and aborted with "change_dir#3 ... failed".

When the parent is genuinely missing in a dry run, skip the chdir and
mark the destination as not-yet-present (dry_run++), exactly as the
multi-file/dir-creation path already does, so the generator doesn't
probe the missing tree.  Gating it on the missing-parent case keeps an
ordinary file-to-file dry run chdir'ing into and itemizing against an
existing destination.

Fixes: #880

Co-authored-by: Stiliyan Tonev (Bark) <stiliyan21@gmail.com>
2026-06-05 11:51:30 +10:00
Andrew Tridgell 981ba2a7b1 Drop stale "redo manual as SGML" TODO entries
The SGML manual idea is long dead (man pages are markdown now, and the
DocBook source was just removed). Remove both TODO mentions.
2026-06-05 11:09:36 +10:00
Andrew Tridgell 5de07c13c1 Remove obsolete DocBook manual
doc/rsync.sgml is a 1996-2002 DocBook user manual (with README-SGML
describing the docbook-utils build) that was long ago superseded by the
markdown man pages. It is unmaintained and referenced by nothing in the
build. This empties doc/.
2026-06-05 11:09:36 +10:00
Andrew Tridgell a2ce82b35e Remove obsolete design notes
rsync3.txt and rsyncsh.txt are Martin Pool's 2001 design proposals
("notes towards a new version of rsync", an interactive rsync shell),
neither of which reflects the current implementation. doc/profile.txt is
stale profiling notes. None are referenced by the build, tests, or docs.
2026-06-05 11:09:36 +10:00
Andrew Tridgell 5e88945a3c Remove obsolete testhelp/maketree.py
This Python 2 test-tree generator (print statements, string.letters,
.next()) has been broken on modern Python for years and is referenced
nowhere in the build, tests, or any script. Drop it.
2026-06-05 11:09:36 +10:00
Zen Dodd fb7daf02f6 fix: daemon upload delete stats 2026-06-05 11:06:48 +10:00
Andrew Tridgell c5b7ea0bd2 token: drain the matched-block insert deflate (#951)
send_deflated_token() adds a matched block to the compressor history with
deflate(Z_INSERT_ONLY).  Our bundled zlib implements Z_INSERT_ONLY (it
produces no output and consumes the input in one call), but a build
against a system zlib lacks it and falls back to Z_SYNC_FLUSH (see the top
of the file), which emits a flush block into obuf.  For a large
incompressible matched token that block exceeds AVAIL_OUT_SIZE(CHUNK_SIZE),
so deflate returned with avail_in != 0 and the transfer aborted:

    "deflate on token returned 0 (N bytes left)"  at token.c

The insert output is never sent -- the receiver rebuilds the matching
history itself in see_deflate_token() -- so loop, resetting the output
buffer, and discard it.  Drain with the same condition as the data loop
above: until the input is consumed AND avail_out != 0.  Stopping at
avail_in == 0 alone can leave pending output in the deflate stream (a
full output buffer with bytes still buffered), which would then be emitted
by the next real deflate send and corrupt the stream.  A bundled-zlib
build still finishes in one iteration.

Fixes: #951
2026-06-05 10:38:03 +10:00
Zen Dodd 0b08fa4285 fix: install generated manpages out of tree 2026-06-05 09:39:21 +10:00
Zen Dodd cb44fc5f1b fix: update skips different file type 2026-06-05 09:39:09 +10:00
Andrew Tridgell eb3796a8c5 ci: add ubuntu-latest fleettest workflow against a localhost fleet
fleettest is a developer tool meant to run on a modern Ubuntu box, so a
bitrot check belongs in its own ubuntu-latest job rather than in the
testsuite (which runs on the BSD/Solaris/macOS/Cygwin matrix, whose
older Pythons may not even parse it).

The job sets up passwordless ssh to localhost, writes a two-target
fleet config that both ssh to localhost (distinct build dirs), and runs
a real fleettest pass. Two targets exercise the parallel multi-target
path and the per-run dir / port isolation; the run exits 0 only if
every cell is OK. Triggered on changes to fleettest.py or this
workflow, manually, and weekly.
2026-06-05 08:48:17 +10:00
Andrew Tridgell 571f87dd12 fleettest: add --timing to show per-target wall-clock
Records wall-clock per phase (push, build, each test transport, nonroot)
plus a total in TargetResult, and with --timing prints a breakdown after
the report, sorted slowest-target-first. Targets run in parallel, so the
run is gated by the slowest one; the phase columns show whether that
hold-up is the push, the build, or a test pass. A target that failed
early (no total) falls back to the sum of the phases it reached.
2026-06-05 08:48:17 +10:00
Andrew Tridgell ea866650be fleettest: tighten --cleanup sweep scope and rm hardening
Address review findings on the cleanup paths:

- --cleanup no longer removes a bare <builddir>, only the suffixed
  <builddir>-* run dirs it created. This keeps the sweep within its
  documented scope and avoids clobbering an unrelated tree.

- Add _unsafe_builddir(): reject empty/root/$HOME and any absolute path
  directly under / (e.g. a misconfigured builddir of "/tmp") before
  building a destructive command, in both cleanup paths.

- Use `rm -rf --` so a path with a leading dash can't be read as options.

- Soften the docs: run-dir removal on Ctrl-C/kill is best-effort (a
  signal arriving mid-push can still leave a remnant for --cleanup).
2026-06-05 08:48:17 +10:00
Andrew Tridgell c7c0109944 fleettest: isolate concurrent runs and add config/cleanup options
Each run now builds in its own randomly-named dir on every target
(<builddir>-<run_id>), so two or three fleettest runs can share the same
fleet without colliding on the pushed tree, the build, or the testtmp
scratch. Port collisions were already handled by claim_ports() locks.

The run dir is removed when the run ends -- on success, failure, or
Ctrl-C/kill (atexit + SIGINT/SIGTERM handlers); --keep retains it. A new
--cleanup mode sweeps stray <builddir>-* dirs left by a SIGKILL.

Incremental builds are dropped (every run is a fresh dir + full build):
--no-push removed, --clean removed.

Also look for the fleet config at ~/.fleettest.json first, then
testsuite/fleettest.json (still overridable with --fleet PATH).
2026-06-05 08:48:17 +10:00
Andrew Tridgell ac282725cd testsuite: regression for the #829 daemon --chown/--groupmap wildcard
Maps every source group to a second group the test user belongs to via a
daemon upload (--groupmap='*:GID') and checks the wildcard took effect.
Runs both arg modes: the default path (the '*' is safe_arg-escaped and the
daemon must un-backslash it -- the regression) and --secluded-args (the '*'
is sent raw over the protected channel, a guard that the fix left that path
alone).  Needs no root -- a non-root receiver can chgrp to a member group --
and was verified RED on a pre-fix binary (the escaped '\*' is ignored, gid
unchanged) and GREEN after the fix.
2026-06-05 06:35:12 +10:00
Andrew Tridgell 6777170037 daemon: un-backslash escaped option args (#829)
Without --secluded-args, the client's safe_arg() backslash-escapes shell
and wildcard chars in option values before sending them to the server, so
--chown's --usermap=*:user is transmitted as --usermap=\*:user.  Over ssh a
remote shell removes the backslashes before rsync parses the args, but a
daemon has no shell and read_args() stored option args verbatim -- so the
receiver saw the literal "\*", the usermap/groupmap wildcard never matched,
and the module's configured uid/gid won instead.  A regression from the
secluded-args hardening; rsync 3.2.3 (protocol 31) worked.

Un-backslash option args in read_args() on the daemon's first
(non-protected) read, mirroring what the ssh-side shell does.  File args
after the dot are already handled by glob_expand(); the protected (NUL,
already-unescaped) re-read and the server's stdin read pass unescape=0 so
their raw args are left untouched.

Fixes: #829
2026-06-05 06:35:12 +10:00
Andrew Tridgell b3107260a2 build: fall back to do_mknod() when mknodat() is unavailable (#896)
do_mknod_at() (the symlink-race-safe variant used by a non-chrooted
daemon receiver) calls mknodat()/mkfifoat(), but the at-variant was
gated only on AT_FDCWD.  Older Darwin declares AT_FDCWD without
mknodat(), so the build failed with "mknodat undeclared".

Probe mknodat()/mkfifoat() in configure and require HAVE_MKNODAT for the
at-variant; without it do_mknod_at() falls back to do_mknod(), exactly
as it already does where AT_FDCWD is missing.  Linux keeps the mknodat
path since HAVE_MKNODAT is defined there.

Fixes: #896
2026-06-05 06:35:12 +10:00
Andrew Tridgell 7db73ad9a1 alloc: revert "zero all new memory from allocations" (#959)
Commit d046525d made my_alloc() calloc every fresh allocation and made
expand_item_list() memset the freshly grown tail, to hand out predictably
zeroed memory.  But that forces the kernel to back pages callers never
touch: each per-directory file_list pre-allocates a FLIST_START-entry
(32768) pointer array -- 256KB -- and calloc now zeroes the whole array
even for an empty directory.  With incremental recursion over many
directories the resident set explodes; 80000 empty dirs went from ~336MB
to ~10.8GB.

Restore the pre-d046525d malloc/calloc split: fresh allocations use
malloc (so untouched tails stay lazy) and only explicit do_calloc
requests (new_array0) are zeroed.  Callers that need zeroed memory
already ask for it, and the full test suite passes.

Fixes: #959
2026-06-05 06:35:12 +10:00
Andrew Tridgell 3691b719fa testsuite: regression for short-checksum --append-verify s2length
Forces --checksum-choice=xxh64 (an 8-byte transfer checksum) with a
corrupted-prefix --append-verify so the full-checksum redo path runs.
Before the generator capped s2length at MIN(SUM_LENGTH, xfer_sum_len)
this died with "Invalid checksum length 16 [sender]"; the test is RED on
the prior generator and GREEN with the cap.  Reproduces on any build that
has xxhash, so it guards the fix without an old-libxxhash host; skips when
xxh64 is absent (a build without xxhash).
2026-06-04 14:33:20 +10:00
Andrew Tridgell fe946581ba generator: cap block s2length at the negotiated checksum length
sum_sizes_sqroot() capped the strong-sum length at SUM_LENGTH (16), the
legacy MD4/MD5 digest size.  Since 0902b52f the sum2 array elements are
xfer_sum_len bytes and the sender rejects a sums header whose s2length
exceeds xfer_sum_len.  When the negotiated transfer checksum is shorter
than 16 bytes -- xxh64 (8), used when the build's libxxhash lacks
xxh128/xxh3 (e.g. Ubuntu 20.04) -- the generator still emitted s2length
up to 16, so --append-verify and other full-checksum (redo) transfers
died with "Invalid checksum length 16 [sender]" (protocol incompatibility).

Cap s2length at MIN(SUM_LENGTH, xfer_sum_len): unchanged for any checksum
>= 16 bytes (md5/xxh128/sha1), corrected for short ones.  Also closes a
latent over-read of the xfer_sum_len-sized digest buffer.
2026-06-04 14:33:20 +10:00
Andrew Tridgell 4634b0ada7 android: probe openat2 usability behind a SIGSYS handler
Android's seccomp sandbox traps openat2() with SECCOMP_RET_TRAP, which
raises SIGSYS and kills the process instead of returning ENOSYS, so the
secure resolver cannot simply try openat2() and inspect errno.  Add
openat2_usable() in a new android.c: it probes openat2() once behind a
temporary SIGSYS handler and caches the result.

Gate every SYS_openat2 call on openat2_usable(): in the resolver via an
openat2_beneath() wrapper, and in t_chmod_secure's kernel probe directly,
so a blocked openat2 reports ENOSYS and the caller falls back to the
portable O_NOFOLLOW resolver.  Only openat2 is gated -- a plain openat()
(e.g. opening an operator-trusted absolute basedir) is left free.

The probe body compiles only on Android -- __ANDROID__ is a Bionic target
macro, so it is set for NDK cross-builds and native Termux alike and unset
everywhere else, where openat2_usable() collapses to a constant 1.  Link
android.o into the secure-resolver test helpers too so their self-tests
survive on Termux.

Adapted from PR #909.
2026-06-04 13:41:07 +10:00
Andrew Tridgell 83a24c2117 configure: require <linux/openat2.h>, not just SYS_openat2
The openat2 secure resolver in syscall.c needs struct open_how and
RESOLVE_BENEATH from <linux/openat2.h>, not only the SYS_openat2 syscall
number.  Some setups expose the syscall number via glibc without the
kernel header present, so probing SYS_openat2 alone still left the build
broken (#905).  Exercise the header and struct in the configure check so
HAVE_OPENAT2 is defined only when both are actually usable.
2026-06-04 13:41:07 +10:00
Markus Mayer 39aa750b1c t_chmod_secure: use HAVE_OPENAT2 to check for openat2() support
To prevent using openat2() in situations where it is not supported, use
    #if defined(__linux__) && defined(HAVE_OPENAT2)
in t_chmod_secure.c, just like it was already being done in syscall.c.

Signed-off-by: Markus Mayer <mmayer@broadcom.com>
2026-06-04 13:41:07 +10:00
Markus Mayer c73e0063b7 build: auto-detect the presence of the openat2() syscall
Let configure detect if the openat2() syscall is supported by the kernel
headers we are building against. Do not attempt to use openat2() if
support is not present.

Users can still disable using the openat2() syscall manually if so
desired.

Signed-off-by: Markus Mayer <mmayer@broadcom.com>
2026-06-04 13:41:07 +10:00
Andrew Tridgell 09656e19c1 testsuite: add fleettest.py fleet CI harness
fleettest.py builds the committed HEAD of a checkout on a fleet of remote machines over ssh and runs the test suite under both the stdio-pipe and --use-tcp transports in parallel, reporting only the unexpected results. Each target mirrors a .github/workflows/*.yml job: its configure flags, and the RSYNC_EXPECT_SKIPPED list parsed from the workflow.

The fleet is described by a JSON file (testsuite/fleettest.json, git-ignored); fleettest.json.example is a worked template. Use --fleet to point at another config and --repo to build a tree other than the current directory.

A target with nonroot:true reruns, as the unprivileged ssh user, the tests that declare a module-level fleet_nonroot=True (here ownership-depth and daemon). The set lives in the test files, so new privilege-sensitive tests join the non-root pass with no fleet-config change.

Also rename testsuite/README.testsuite to README.md and rewrite it as markdown documenting the current testsuite: runtests.py, the make check/check29/check30/installcheck/coverage targets, the result/exit-code conventions, and fleettest.py.
2026-06-04 13:00:04 +10:00
Andrew Tridgell 5972ebdaf8 syscall/receiver: honour a relative alt-basis dir on a daemon receiver (#915)
The symlink-race hardening routed the receiver's basis open through
secure_relative_open(), which rejects any '..' -- so a sibling
--link-dest=../01 on a use-chroot=no daemon was silently ignored and every file
re-transferred (#915/#928, a regression from 3.4.1).

Narrow the confinement to the sanitizing daemon (am_daemon && !am_chrooted) and
re-anchor it at the module root, the real trust boundary: secure_relative_open()
prefixes the cwd's module-relative path (from rsync's logical curr_dir[], a
guaranteed lexical prefix of module_dir) and resolves beneath module_dir, so
RESOLVE_BENEATH permits an in-module '..' climb while still rejecting one that
escapes the module.  secure_basis_open() opens with a bare do_open() in the
non-sanitizing cases.  t_stub.c gains weak curr_dir[]/curr_dir_len for the
helpers (via #pragma weak on non-GNU compilers, where rsync.h erases
__attribute__).

Two tests: link-dest-relative-basis asserts the in-module '..' is honoured;
link-dest-module-escape asserts a --link-dest=../../OUTSIDE climb that leaves
the module is refused (not hard-linked to an outside file).  See upstream
PR #930.
2026-06-04 07:41:41 +10:00
Andrew Tridgell 489f3e4521 sender: open a module-root-absolute path for a path = / module (#897)
A daemon module with path=/ makes F_PATHNAME absolute, so the secure_path built
for the content open starts with '/'.  secure_relative_open() rejects an
absolute relpath with EINVAL, so a use-chroot=no daemon with path=/ could not
send any file ('failed to open ...: Invalid argument (22)') -- a regression
from 3.4.2.  Strip leading slashes to a module-relative path; resolution stays
confined beneath module_dir.
2026-06-04 07:41:41 +10:00
Andrew Tridgell ebfb3c0056 flist: accept the missing-args mode-0 entry in recv_file_entry (#910)
--delete-missing-args (missing_args==2) sends a missing --files-from arg as a
mode-0 entry (IS_MISSING_FILE), the generator's delete signal.  The mode-type
validation in recv_file_entry() rejected mode 0 as an invalid file type,
aborting the transfer with 'invalid file mode 00 ... code 2' before the
generator could act (a regression from 3.4.1).  Allow mode 0 through only when
missing_args==2 (the delete mode -- not --ignore-missing-args, which never
sends a mode-0 entry); all other modes are still rejected.
2026-06-04 07:41:41 +10:00
Andrew Tridgell e16a001d39 testsuite/runtests: count XFAIL (exit 78) as expected, not a failure
The regression tests use test_xfail() (exit 78) to assert a known, documented
residual on platforms where the fix can't apply -- e.g. link-dest-relative-basis
XFAILs where the receiver has no openat2/O_RESOLVE_BENEATH and the portable
resolver rejects the '..' for safety.  runtests.py counted exit 78 in the
generic else->failed branch, so a bare XFAIL failed the whole suite; tally it
separately ('N xfailed (expected)') and exclude it from the failure exit code.
Also add --race-timeout plumbing (race_timeout env) for race tests.
2026-06-04 06:09:25 +10:00
Michael Mess 5cf7c50524 Corrected test case broken for locales that uses , instead of . for decimal numbers in human readable form. 2026-06-02 18:23:40 +10:00
Andrew TridgellandClaude Opus 4.8 ad3bfab05d ci: version-mixing workflow, expect manifests, check-progs target
Adds .github/workflows/ubuntu-version-mix.yml (ubuntu-latest) and a
per-release manifest testsuite/expect/rsync_<ver>.expect for each of the
nine peers. The workflow builds the current rsync, then runs the two-
sided suite against every old binary over both the pipe and --use-tcp
daemon transports. All peers run in a SINGLE looped job (not a matrix)
so the PR shows one check line; each peer/transport is a foldable log
group and a failure annotates which one broke.

A new phony `check-progs` target builds rsync plus the test helper
programs and check symlinks without running the suite -- the build half
of `make check` -- so the workflow's direct runtests.py invocation has
the helpers it needs.

Notable expected results encoded in the manifests:
 - The four May-2026 security tests xfail against every released peer:
   the suite demonstrates each release is vulnerable to those findings
   while current master is fixed.
 - symlink-dirlink-basis xfails on 3.4.0/3.4.1 (issue #715: their
   secure_relative_open O_NOFOLLOW-confines the basedir, breaking a -K
   dir-symlink update; current master fixes it with secure_basis_open).
 - Older peers carry more xfails for options/negotiation they lack;
   2.6.0 (protocol 27) fails most daemon tests. reverse-daemon-delta
   passes against all peers, confirming backward compat down to 2004.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:21:35 +10:00
Andrew TridgellandClaude Opus 4.8 e8d10dc2ad old_versions: commit static binaries of old rsync releases
Nine statically-linked, stripped binaries for the version-mixing test
suite (and ad-hoc cross-version behaviour checks): every x.y.0 release
from 2.6.0 (2004, protocol 27) through 3.4.0, plus the 3.1.3/3.2.7/3.4.1
point releases. 2.6.0 is the practical floor; older tags need more
porting to build on a current toolchain.

build_static.sh rebuilds any release from its git tag, applying the
minimal patches needed to compile old sources on a modern toolchain:
K&R lseek64 redecl, gettimeofday, -std=gnu11, --disable-openssl, and
_FORTIFY_SOURCE disabled (modern FORTIFY=3 turns latent benign over-reads
in old rsync into aborts when it runs as a server). Pre-3.0 trees ship
configure.in, so it regenerates configure (autoheader/autoconf) after
neutralizing the dead AC_LIBOBJ replacement fallbacks, generates proto.h,
and stubs the dropped vendored lib/addrinfo.h -- all guarded to no-op on
newer versions.

.gitattributes marks the binaries binary (so the text=auto rule can't
corrupt them) and export-ignore (kept out of the release tarball).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:21:35 +10:00
Andrew TridgellandClaude Opus 4.8 ad14569561 testsuite: reverse-direction smoke test (old client -> current daemon)
Every other two-sided test drives with the current binary, covering
new-client -> old-server. This adds the backward-compat direction that
matters most for a project shipping new servers to a world of old
clients: a current daemon must keep serving the installed base of old
rsync clients.

reverse-daemon-delta_test.py starts the daemon with the current build
(via start_test_daemon's rsync_cmd override) and drives it with the old
binary. It does a push and a pull, each with and without -z, with the
receiving side pre-seeded with an older version of the file so the delta
algorithm actually runs -- exercising delta encoding both ways (old->new
on push, new->old on pull) and compression negotiation both ways. It
asserts the bytes crossing the wire are far smaller than the file, so a
silent fallback to a whole-file copy is caught, and accepts both the
modern "sent/received" and the old "wrote/read" summary wording so an
old client's output parses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:21:35 +10:00
Andrew TridgellandClaude Opus 4.8 e21cdabd71 runtests: add --rsync-bin2 / --expect-result for version-mixing tests
Let the suite run with two rsync binaries so the current build can be
tested against the actual old code of a previous release, rather than
only forcing the current binary to speak an old protocol (check29/30).

  --rsync-bin2 PATH  exports RSYNC_PEER, the binary used for the SERVER
                     side of two-sided transfers (the daemon process and
                     the remote-shell --rsync-path target). Defaults to
                     RSYNC, so single-binary runs are byte-for-byte
                     unchanged.
  --expect-result F  the manifest's listed tests ARE the run set; each
                     test's actual outcome (pass/skip/fail/xfail) is
                     compared to its expected one and any mismatch --
                     including an unexpected pass (xpass) -- fails the
                     run. --expect-skipped and the default exit logic
                     are untouched.

rsyncfns gains the RSYNC_PEER global and launches the daemon with it
(start_rsyncd / start_test_daemon, the latter with an optional rsync_cmd
override used by the reverse-direction test); the remote-shell tests
pass --rsync-path={RSYNC_PEER}. All no-ops when no peer is selected.

Direction is fixed: the current binary always drives (only it
understands the new test scripts); the old binary is only ever the
server/daemon side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:21:35 +10:00
Andrew TridgellandClaude Opus 4.8 c0219caf15 runtests: add --exclude / RSYNC_EXCLUDE to skip tests entirely
Some tests cannot run in certain build/CI environments. In particular the
protected-regular test self-re-execs under "unshare --map-users" to exercise
fs.protected_regular handling, and that user-namespace path hangs in a
restricted buildd chroot (e.g. Launchpad/sbuild), tripping the per-test
timeout and failing the whole "make check".

Add an --exclude option (comma-separated test names/globs), with an
RSYNC_EXCLUDE environment fallback so it can be set without touching the
make/check command line. Excluded tests are dropped before running -- they
are neither executed nor reported as skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:52:42 +10:00
Andrew TridgellandClaude Opus 4.8 68df17ae00 docs: document the rsync-latest snapshot PPA
Add the new ppa:rsyncproject/rsync-latest (development snapshots rebuilt
from git master) alongside the existing stable PPA in INSTALL.md and the
download page.  Notes that snapshot versions (3.5.0~git...) sort below the
matching stable release, so the two PPAs can coexist without a stable
release being silently replaced by a snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:37:10 +10:00
Andrew Tridgell 3748c3288d testsuite: added a test for symlinks to the same dir
when a symlink is to the same directory as the source then it can be
considered unsafe if it goes via a path outside the directory.

This came up on the mailing list, added a test to make the case clear
2026-05-31 18:42:37 +10:00
Andrew TridgellandClaude Opus 4.7 907505c004 ci: halve CI artifact retention from 90 to 45 days
GitHub Actions artifact storage is approaching our quota. Each `make`/build
job uploads its rsync binary + manpages, the coverage job uploads its full
HTML tree, and Android uploads its dist/ -- 11 jobs producing artifacts per
PR/push, all kept for the repo default of 90 days.

Set retention-days: 45 explicitly on every upload-artifact step so they
expire at half the previous lifetime; older artifacts can still be re-built
from the commit if needed. No other workflow behaviour changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 05:44:14 +10:00
Andrew TridgellandClaude Opus 4.7 8bbea98392 runtests.py: accept a relative --rsync-bin
Tests are launched with subprocess.run(..., cwd=TOOLDIR) so the
subprocess's argv[0] resolves against TOOLDIR, not the runner's
invocation cwd. A user-supplied --rsync-bin=../foo/rsync therefore
worked when invoked from inside TOOLDIR but silently failed (or
ENOENT'd inside individual tests) when invoked from a sibling
directory.

Fix: absolutize rsync_bin via os.path.abspath() at parse time, before
it propagates into build_rsync_cmd()/RSYNC. abspath() captures
os.getcwd() now, which is the operator's invocation cwd -- exactly
what the --rsync-bin=../path form expresses.

Regression check:

  cd /tmp/somewhere-else
  ln -s /path/to/rsync ./alt/rsync
  python3 /path/to/rsync-git/runtests.py \
      --rsync-bin=./alt/rsync \
      --srcdir=/path/to/rsync-git --tooldir=/path/to/rsync-git \
      00-hello

Before this commit the test failed at subprocess time with the relative
path being looked up under TOOLDIR; after, it passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:00:24 +10:00
Andrew TridgellandClaude Opus 4.7 f2eef1f0d2 ci: add actionlint workflow to lint GitHub Actions YAML
Adds .github/workflows/actionlint.yml which runs rhysd/actionlint over
.github/workflows/*.yml on push and PR to master.  Triggers only when
something in .github/workflows/ (or the actionlint config) changes, so
the rest of the platform matrix isn't billed when nothing here moves.

The job downloads a pinned actionlint binary (1.7.12) via the upstream
download script (which verifies a SHA256) -- no third-party Action
dependency, matching the inline-install style of the existing
ubuntu/macos/cygwin workflows.  Bump the pinned version deliberately.

actionlint catches a) GitHub Actions expression / type errors, b)
unsupported runner images, c) missing secrets / inputs, and d) the
embedded shellcheck class of issues in 'run:' scripts that the previous
commit cleaned up.  Keeping it in CI prevents regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:46:08 +10:00
Andrew TridgellandClaude Opus 4.7 d395d8df06 ci: clean up workflow shellcheck nits
actionlint (rhysd/actionlint) reported a handful of shellcheck-class issues
across the GitHub Actions workflows.  All are 1-line mechanical fixes:

  * Replace legacy backticks in --rsync-bin=`pwd`/rsync with
    --rsync-bin="$PWD/rsync" (SC2006 + SC2046; almalinux-8-build,
    macos-build, ubuntu-22.04-build, ubuntu-build).
  * Quote >>$GITHUB_PATH redirects as >>"$GITHUB_PATH"
    (SC2086; coverage, macos-build, ubuntu-22.04-build, ubuntu-build).

After this commit `actionlint .github/workflows/*.yml` exits 0.

(Also cleaned up 6 editor backup *.yml~ files from the local working
tree; those weren't tracked -- *~ is gitignored -- so the cleanup is
local-only and not part of this commit.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:46:08 +10:00
Andrew TridgellandClaude Opus 4.7 14d6c29d81 testsuite: close minor assertion gaps
symlink-dirlink-basis  assert the --backup file holds the pre-update content,
                         not merely that the backup file exists.
  acls-default           check that clearing the inherited default ACL actually
                         succeeded, so the no-default-ACL cases can't silently
                         test against the scratch dir's seeded default ACL.
  alt-dest               assert --copy-dest produces a distinct inode from the
                         alt-dir candidate (a copy, not a hard link) -- the
                         property that distinguishes it from --link-dest, which
                         checkit's tree comparison alone doesn't capture.

(crtimes' "independently pin the historical create time" gap is left as-is: the
touch-trick pinning is APFS-specific and not locally verifiable, and a mistuned
probe would make the test skip on macOS and break its expected-skip set.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:43:00 +10:00
Andrew TridgellandClaude Opus 4.7 34f40b9ea7 testsuite: tighten metadata-precision and symlink-target assertions
Replace loose/partial oracles with exact ones:

  omit-times      under -O, require EVERY directory mtime to be omitted, not
                  just one (the old "at least one differs" missed partial bugs).
  dir-sgid        assert the created dirs' actual gid: a setgid parent makes
                  them inherit its group (set to a secondary group to be
                  discriminating), while the non-setgid case gets the process's.
  relative-implied pin a deterministic umask and assert the exact default mode
                  (0o755) for --no-implied-dirs, not merely "not the source's".
  safe-links /    compare the preserved symlink TARGET strings via readlink,
  unsafe-links    not just that a symlink exists.
  preallocate     verify do_punch_hole via st_blocks on the --inplace --sparse
                  case (guarded by a sparse-capability probe).

Note: --preallocate --sparse leaves the file fully allocated on a fresh write
(the zero run is not punched), so that case stays content-only rather than
asserting hole-punching -- see the test comment; rsync.1's claim that the
combination yields sparse blocks does not hold for the fresh-write path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:43:00 +10:00
Andrew TridgellandClaude Opus 4.7 5b36673d0a testsuite: add content and return-code assertions
Several tests proved only that rsync exited cleanly (or that a file merely
exists), so a no-op/short transfer would pass:

  protected-regular  compare the dst bytes to the source after --inplace.
  00-hello           re-assert one/two were copied on the RSYNC_OLD_ARGS=1
                     env-var path (the explicit --old-args case already did).
  missing            check the dry-run's exit status in test 1.
  mkpath             compare transferred bytes (not just existence) and add a
                     negative control: a transfer WITHOUT --mkpath must fail
                     and create no intermediate path.
  size-filter        compare each kept file's content to its source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:43:00 +10:00
Andrew TridgellandClaude Opus 4.7 1687230672 testsuite: verify destination content/listings in daemon tests
These daemon tests confirmed refusals/exclusions but accepted the allowed
transfers on exit status alone, so a transfer that exited cleanly while moving
nothing would pass:

  daemon-refuse  allowed() imported verify_dirs but never called it; now it
                 confirms the allowed push/pull actually populated the dest.
  daemon-filter  pull()/the incoming push ignored their exit status, and the
                 outgoing-chmod loop iterated only files that exist -- a
                 zero-file pull passed vacuously. Check the codes and require
                 at least one file to have been mode-checked.
  daemon         run_and_check's unused `expected` param is dropped; the
                 hidden-module and glob listings now compare the exact set of
                 listed paths (catching a leaked extra path), replacing the
                 per-path containment check and the dead normalise() helper
                 whose regex never matched the -r listing format anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:43:00 +10:00
Andrew TridgellandClaude Opus 4.7 f196279c29 testsuite: add positive controls to the symlink-race security tests
The symlink-race tests only asserted that an outside sentinel was unchanged or
unlisted while ignoring rsync's exit status, so an attack transfer/listing that
failed before reaching the vulnerable receiver/sender path would pass without
the security property ever being exercised. Add a positive control to each --
an ordinary in-module write (bare-do-open, chdir) or an in-module listing
(sender-flist-leak) that must succeed -- so a globally broken/refusing daemon
can no longer make the sentinel checks vacuous, and assert the attack run did
not die from a signal.

clean-fname-underflow now also enforces a non-zero exit: clean_fname()
collapses "a/../test" to "test", whose merge file is absent, so rsync must
reject it; accepting it (rc 0) would mean the crafted name was mis-collapsed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:43:00 +10:00
Andrew TridgellandClaude Opus 4.7 ed11852ed0 testsuite: harden output-options checks
Several subcases ran rsync without checking the exit status, so a silent
failure could pass as the expected (often empty) output -- most notably -q,
which only asserted empty stdout. Route every expected-success run through a
helper that asserts the exit status, and verify -q actually transferred the
tree. Replace the "-h/-8 didn't break the transfer" check with positive format
assertions: -h must render byte counts with a K/M/G suffix (and the default
must not), and -8 must leave a high-bit filename byte unescaped (\#371 absent)
where the default escapes it -- best-effort, self-skipping where the platform
can't store the raw byte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:43:00 +10:00
Andrew TridgellandClaude Opus 4.7 9f0afbea4f testsuite: verify the negotiated compressor/checksum selection
compress-options only checked that each requested algorithm yielded
byte-identical output, which proves parsing/non-corruption but not that the
advertised algorithm was actually used -- the test would pass if the choice
were silently ignored. Capture --debug=NSTR (compat.c / checksum.c) and assert
the selected compressor, compress level, and checksum match the request
(anchored so zlib != zlibx). --skip-compress / --checksum-seed stay content
checks: they have no comparable negotiation-string signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:43:00 +10:00
Andrew TridgellandClaude Opus 4.7 034a4f3b1e testsuite: verify --fuzzy actually selects a basis
Both fuzzy tests asserted only that the final file content matched, which a
full transfer that ignored --fuzzy would also satisfy -- so a broken fuzzy
basis selection would pass undetected. Drive rsync directly with --debug=FUZZY
and assert the generator reports the expected basis ("fuzzy basis selected
for <f>: <basis>", generator.c find_fuzzy): rsync2.c for fuzzy, and the
closest-named candidate archive-v1.tar for fuzzy-basis. fuzzy switches from
checkit() to a manual run plus verify_dirs() so the output can be captured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:43:00 +10:00
Andrew TridgellandClaude Opus 4.7 4f5a5857ce Fix --preallocate --sparse to actually produce sparse files
rsync.1 says combining --preallocate with --sparse yields sparse blocks
wherever the filesystem can punch holes, but since 2019 (commit c2da3809,
"keep file-size 0 when possible") it has silently left the file fully
allocated. Two problems, both rooted in that commit switching --preallocate /
--inplace to fallocate(FALLOC_FL_KEEP_SIZE):

  * do_fallocate() then returned 0 instead of the reserved length, so the
    receiver's preallocated_len was 0 and write_sparse() always lseek'd over
    null runs instead of punching them (and the over-preallocation trim in
    receiver.c never fired either).

  * more fundamentally, KEEP_SIZE leaves the file size at 0 while data is
    written incrementally, so the FALLOC_FL_PUNCH_HOLE call lands on blocks
    beyond EOF and is a silent no-op -- the reserved blocks are never freed.

Fix both: don't request KEEP_SIZE when --sparse is also active, so the file is
preallocated at full size and the punch lands within it; and return the
reserved length from do_fallocate() so preallocated_len drives the punch
decision and the over-allocation trim. --preallocate without --sparse keeps
the KEEP_SIZE (file-size-0) behaviour. t_stub.c gains a sparse_files stub since
do_fallocate now references it and the test helpers link syscall.o.

preallocate_test.py now asserts via st_blocks (where the filesystem can punch
holes) that --preallocate --sparse ends up sparse, guarding the regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:03:58 +10:00
Andrew TridgellandClaude Opus 4.7 bc63ea82f2 ci: run the OpenBSD --use-tcp test step at -j2
The OpenBSD job runs inside a nested VM. At -j8 the --use-tcp run starts
many concurrent loopback daemons, and under that resource pressure the
daemon connection handshake occasionally loses a timing race and one test
hangs to the 300s runner timeout. It is an environment artifact, not an
rsync defect: the daemon handshake writes-then-reads with unbuffered early
I/O (no flush/mutual-wait deadlock), the indefinite wait is the documented
no-timeout daemon behaviour, and it does not reproduce off OpenBSD even with
the full suite pinned to a single CPU at -j8.

Drop just this job's --use-tcp parallelism to -j2 so the nested VM stops
over-subscribing; the pipe `make check` and every other platform are
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:44:12 +10:00
Andrew TridgellandClaude Opus 4.7 0d4fb1bc89 testsuite: cover more path/file-operation code (syscall.c, util1.c, delete.c)
Target previously-uncovered functions in the path/file-operation files the
resolver restructure touches, confirmed hit under coverage:

  preallocate   --preallocate (syscall.c do_fallocate) and sparse hole-punching
                via --preallocate --sparse and --inplace --sparse (do_punch_hole),
                on a file several levels deep.
  fuzzy-basis   --fuzzy basis selection with similar-named candidates and no
                exact match, so the generator scores them (util1.c fuzzy_distance).
  delete-deep   add a --backup --delete case so removing an extraneous
                backup-suffixed file consults delete.c is_backup_file.

preallocate probes --preallocate support up front and skips where it is
unavailable: macOS, the *BSDs and Solaris build without fallocate/posix_fallocate
(and FALLOC_FL_PUNCH_HOLE is Linux-only), and reject the option outright. It runs
on Linux and Cygwin. fuzzy-basis and delete-deep are plain local transfers with
no skips. All green on master and under --protocol=29/30.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:44:12 +10:00
Andrew TridgellandClaude Opus 4.7 52480aaac2 runtests: compare expected-skipped order-insensitively; register daemon-access-ip
The --expect-skipped check compared the skip list as an ordered string, so the
per-platform RSYNC_EXPECT_SKIPPED lists had to match runtests' collection order
(sorted filenames) exactly -- a subtle, easy-to-break ordering dependency.
Compare the skipped SET instead; which tests skipped is what matters.

Register the new require_tcp test daemon-access-ip in the per-platform
expected-skipped lists (it skips in the pipe-transport make check, like
daemon-chroot-acl and proxy-response-line-too-long).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:44:12 +10:00
Andrew TridgellandClaude Opus 4.7 702a8f61b7 testsuite: cover daemon access-control, config includes, --stop-at
Target the lowest-coverage rsync files identified from a merged (pipe + proto29/30
+ tcp) gcov report:

  daemon-access-ip  hosts allow / hosts deny with exact-IP and CIDR patterns over
                    --use-tcp, exercising access.c make_mask/match_address/
                    match_binary (19% -> 62% lines), plus client --address
                    (socket.c try_bind_local). require_tcp.
  daemon-config     the &include rsyncd.conf directive (params.c include_config/
                    parse_directives, 48% -> 60%) and a module with a missing path
                    (clientserver.c path_failure).
  stop-time         --stop-at future/past (options.c parse_time) and --stop-after
                    (options.c 59% -> 64%).

Merged scoped coverage: lines 67.3%->68.3%, functions 87.5%->88.4%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:44:12 +10:00
Andrew TridgellandClaude Opus 4.7 2928b2742e build: scope gcov report to rsync's own source; add coverage-all
The coverage report counted bundled third-party code (zlib/, popt/, and the
PostgreSQL/ISC lib/ imports getaddrinfo/getpass/inet_ntop/inet_pton) that rsync
ships but does not own, muddying the percentages. Add a COVERAGE_EXCLUDE gcovr
filter (shared by all coverage targets) so the report reflects rsync's own code:
on the same data, lines 63.9%->65.5%, functions 81.4%->85.0%, branches
55.0%->56.5% (rsync's own md5/mdfour/wildmatch/etc. stay in the report).

Add 'make coverage-all': run the suite under pipe + --protocol=30 + --protocol=29
+ --use-tcp, accumulating into the shared .gcda (not cleared between runs), then
one merged scoped report -- covers the daemon/TCP and protocol-compat paths a
single pipe run misses (lines 67.6%, functions 87.6%, branches 58.6%). Also add
'make coverage-fallback' for a separate --disable-openat2 build (different .gcno,
so it can't merge with the openat2 report). CI is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:44:12 +10:00
Andrew TridgellandClaude Opus 4.7 f1d5a3c815 ci: declare new metadata-coverage test skips for macOS and Cygwin
acls-depth skips where ACLs/setfacl are unavailable (macOS, Cygwin) like the
existing acls tests, and sparse skips on APFS (macOS), where a seek-written
hole isn't allocated sparsely. Add them to the per-platform RSYNC_EXPECT_SKIPPED
lists so the skip-set assertion stays accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 3086dbc0fd ci: add an Ubuntu gcov coverage job
Builds with --enable-coverage and runs the suite under both transports
(make coverage, then make coverage-tcp). gcovr's line/branch/decision totals
are printed to the step log and also written to the GitHub step summary, so the
coverage numbers are visible directly in the CI output; the HTML reports are
uploaded as an artifact. make coverage exits with the suite's status, so a test
regression fails the job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 63e599b921 build: add 'make coverage-tcp' and drop deprecated gcovr --branches
coverage-tcp reuses the coverage recipe with --use-tcp (daemon tests over a real
loopback rsyncd, which also runs the require_tcp-only tests) and a separate
report directory, via COVERAGE_RUNFLAGS / COVERAGE_DIR. Verified end to end:
pipe run reports 63.9% lines, the TCP run 64.5% (it exercises more code).

Also drop gcovr's --branches flag: it is deprecated in gcovr 8 and branch +
decision coverage still appear in --print-summary and the HTML without it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 340238421d testsuite: assert absolute --partial-dir delta resume now works
partial_test.py sub-test 5 deterministically asserts a delta (--no-whole-file)
resume from an absolute, outside-tree --partial-dir reproduces the source and
consumes the basis -- the regression guard for the receiver fix. Sub-test 4
keeps asserting the cross-directory partial WRITE on interrupt. Drop the
--whole-file workaround and the 'broken on master' notes in the docstring and
COVERAGE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 31fbb17d23 receiver: fix absolute --partial-dir delta resume (false verification)
A delta (--no-whole-file) resume whose basis is an absolute --partial-dir
looped forever on exit code 23 ("failed verification -- update put into
partial-dir"), stranding the correct data in the partial-dir and never
populating the destination.

Cause: an absolute --partial-dir makes the basis path absolute, but the
receiver opened it with secure_relative_open(NULL, fnamecmp, ...), which by
design rejects an absolute relpath (EINVAL). The basis fd was then -1, so
receive_data() mapped no basis and (because the matched-block sum_update() is
guarded by "if (mapbuf)") computed the whole-file verification checksum over
the literal data only -> a spurious mismatch every run. (The data itself was
correct, since the in-place update leaves the matched basis bytes in place.)
Under a non-chroot daemon the in-place write went through the same call and
failed outright.

Fix: add secure_basis_open(), which treats an operator-trusted absolute basis
path as (trusted directory + confined leaf) -- the same way secure_relative_open
already trusts an absolute basedir while keeping O_NOFOLLOW on the leaf -- and
use it for both the basis read and the inplace-partial write. The strict
"reject absolute relpath" contract of secure_relative_open is left intact.

Defense-in-depth: receive_data() now treats a block-match token with no mapped
basis as a protocol inconsistency (it can only arise from a basis that the
generator opened but the receiver could not), failing cleanly instead of
silently dropping those bytes from the verify checksum or the output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 edf298ace5 testsuite: add COVERAGE.md matrix and -u/--force coverage
COVERAGE.md is the living checklist mapping every CLI option (~142) and daemon
parameter (~54) to its test(s), with depth / cross-dir status and remaining
gaps, so the path-resolution restructure can see exactly what is guarded.

update_test.py closes two of the documented gaps: -u/--update (keep a newer
destination, update an older one) and --force (replace a non-empty destination
directory with a file), both at depth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 1d5b5ab83a build: add gcov coverage and --disable-openat2 knobs for the test suite
Two test-coverage build knobs (both behaviour-neutral by default):

  --enable-coverage  appends '--coverage -fprofile-update=atomic -O0' and adds
                     a 'make coverage' target (whole suite, run serially, then
                     gcovr HTML with branch + decision coverage). rsync forks
                     and its children exit without running the gcov atexit
                     flush -- the generator via its SIGUSR1 handler
                     (_exit_cleanup) and the receiver via the SIGUSR2 handler
                     -- so under GCOV_COVERAGE we call __gcov_dump() at both, or
                     receiver.c/generator.c record no coverage at all.

  --disable-openat2  gates the Linux openat2(RESOLVE_BENEATH) sites in syscall.c
                     on HAVE_OPENAT2 (defined by default), so disabling it forces
                     the portable per-component O_NOFOLLOW resolver to run as the
                     primary on ordinary Linux -- exercising and
                     coverage-counting that fallback tier without a pre-5.6
                     kernel. NOTE: coordinate with the parallel syscall.c
                     path-resolution restructure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 b0ba699031 testsuite: probe RESOLVE_BENEATH support functionally for the #715 test
Add resolve_beneath_supported() to rsyncfns: it functionally probes whether the
rsync binary can follow an in-tree directory symlink under its secure resolver
(an initial transfer plus a delta update through a dir-symlink, the operation
issue #715 is about). This tracks the actual binary instead of a platform name.

Use it in symlink-dirlink-basis_test.py in place of the SunOS/OpenBSD/NetBSD/
Cygwin name check: it skips on those platforms too, and additionally on
Linux < 5.6, a seccomp-blocked openat2, and the new --disable-openat2 build,
where the portable O_NOFOLLOW fallback rejects the in-tree symlink.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 e57c7f5d87 testsuite: output, comparison and algorithm-selection option coverage
Breadth pass for options not yet exercised:

  output-options    output shape of --version/--help/-i/-n/--stats/
                    --out-format/--list-only/-q/--progress/-h/-8 (these control
                    output, not path handling, so they're checked for shape).
  compare           -c and -I catch a stealth change (same size+mtime, new
                    content) deep in the tree; --size-only skips a same-size
                    change; --modify-window absorbs a 1s mtime difference.
  compress-options  --compress-choice for every advertised compressor,
                    --compress-level, --skip-compress, --checksum-choice for
                    every advertised checksum, and --checksum-seed -- each a
                    clean byte-identical transfer at depth.

Green on master and under --protocol=29/30.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 05f30c05c9 testsuite: daemon parameter coverage (loopback)
Drive a loopback daemon (secure stdio-pipe transport by default, also green
under --use-tcp) via the new write_daemon_conf helper and assert the behaviour
of the security-relevant rsyncd.conf parameters, transferring >=3-deep trees:

  daemon-access  path / read only / write only / list, incl. a deep sub-path
                 pull and that a list=no module is hidden yet usable by name.
  daemon-filter  daemon exclude hides matching files everywhere; incoming /
                 outgoing chmod rewrite modes of every transferred file.
  daemon-auth    auth users + secrets file accept the right password, reject a
                 wrong one and an unauthenticated request; strict modes rejects
                 a world-readable secrets file.
  daemon-exec    pre-/post-xfer exec run with RSYNC_MODULE_NAME /
                 RSYNC_EXIT_STATUS; a failing pre-xfer exec aborts the transfer
                 (marker files polled for, since post-xfer exec runs after the
                 client disconnects under TCP).
  daemon-munge   munge symlinks stores incoming links with the /rsyncd-munged/
                 prefix and strips it on the way out.
  daemon-refuse  refuse options: a named option, a wildcard, and the '* !a !v'
                 allow-list idiom.

Green on master under pipe and --use-tcp transports and under --protocol=29.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 922681e140 testsuite: filtering coverage at depth
Assert exactly which entries are/aren't transferred, deep in the tree:

  filter-depth      --exclude/--include precedence on files at every level, and
                    a -F per-directory .rsync-filter loaded from a deep dir that
                    applies to that subtree only (not above it).
  cvs-exclude       -C built-in cruft patterns (*.o, *~) at every level plus a
                    deep per-directory .cvsignore scoped to its subtree.
  size-filter       --max-size / --min-size select the right files all the way
                    down.
  files-from-depth  --files-from selects only the listed deep paths (implied
                    parents created); --from0 NUL-delimited; --exclude-from /
                    --include-from filter at depth.

(--existing / --ignore-existing are covered in delete-deep_test.py.)
Green on master and under --protocol=29/30.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 273b9f265f testsuite: metadata preservation coverage at depth
Set each attribute distinctively on a file AND a directory at every level of a
>=3-deep tree and verify it per entry after transfer (metadata is applied as a
single-component op on an entry whose parent chain the resolver restructure
rewrites):

  metadata-depth   -p preserves exact file/dir modes; -t preserves file
                   mtimes; --chmod=D710,F600 rewrites them.
  omit-times       -O omits directory times (files still preserved); -J omits
                   symlink times.
  sparse           -S preserves a deep file's hole (allocated << size);
                   --no-sparse fills it.
  xattrs-depth     -X reproduces a user xattr on every entry (gated on xattr
                   support).
  acls-depth       -A reproduces a POSIX ACL on every entry (gated on ACL
                   support + setfacl/getfacl).
  ownership-depth  --groupmap and --chown=:GROUP remap the group of every
                   entry (non-root, to a secondary group); -o/--usermap gated
                   on root.

All green on master and under --protocol=29/30.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 0d546ee3b4 testsuite: structure / recursion / link coverage at depth
Cover the structure and link options at >=3 levels and across directories,
asserting each option's specific effect:

  links            -l keeps a symlink, -L dereferences it, -k follows a
                   directory symlink -- all on a symlink several levels deep.
  dirs             -d copies the top layer (file + empty dir) without recursing.
  prune-empty-dirs -m drops empty chains and chains emptied by an exclude,
                   keeps populated ones.
  hardlinks-deep   -H preserves a hard link whose names live in different
                   directories at depth; without -H they become separate inodes.
  delete-deep      --delete removes a deep extraneous file/subtree; the four
                   delete-timing variants agree; --max-delete caps deletions;
                   --existing / --ignore-existing select/skip correctly.
  relative-implied -R mirrors an implied directory's mode at depth;
                   --no-implied-dirs does not (proto 30+).

Green on master and under --protocol=29/30 (the --no-implied-dirs sub-case is
gated to protocol >= 30, where multi-component sender paths are accepted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 d6124a82a4 testsuite: cross-directory/temp/backup/dest coverage at depth
Fill the highest-restructure-risk gap: options that do two-directory / rename /
outside-tree work, asserted at >=3 levels deep with the aux tree kept outside
the main tree, and asserting the option's specific property rather than just
tree equality (which the ported tests already cover).

  alt-dest-deep  --link-dest hardlinks unchanged files (same inode), --copy-dest
                 copies (never links), --compare-dest omits unchanged files;
                 ref tree outside both src and dest.
  temp-dir       cross-dir temp->final rename at depth; temp dir left clean; a
                 missing --temp-dir fails (so the option is proven consulted).
  partial        --partial keeps the partial in the dest file; relative
                 --partial-dir stages per-directory at depth (pre-seed +
                 interrupt/resume); absolute --partial-dir writes the partial
                 outside the tree.
  inplace        --inplace keeps the destination inode across a delta update;
                 the default temp+rename path replaces it.
  append         --append completes truncated files tail-only; --append-verify
                 repairs a corrupted prefix (protocol >= 30).
  backup-deep    --suffix saves <name>S beside the new file; --backup-dir
                 relocates old files to a parallel deep tree outside the dest
                 and captures deletions under --delete.

All green on master and under --protocol=29/30.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 1d828f35ca testsuite: add depth/cross-dir/daemon coverage helpers to rsyncfns.py
Add helpers for the option-coverage expansion (the path-handling restructure
changes parent-component resolution, so options must be exercised at depth and
across directory boundaries):

  * make_tree() builds a tree with a regular file at every level so a property
    can be checked at the tree root and >=3 levels deep;
  * walk_files()/walk_dirs() iterate entries for per-level assertions;
  * assert_same/assert_mode/assert_mtime_close/assert_is_symlink/
    assert_hardlinked/assert_not_hardlinked/assert_exists/assert_not_exists
    assert the concrete property an option controls (not just dest == src);
  * write_daemon_conf() writes an arbitrary rsyncd.conf (globals + modules)
    for daemon-parameter tests, beyond build_rsyncd_conf's fixed four modules;
  * forced_protocol() lets protocol-sensitive tests gate sub-cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew Tridgell 7bba25e675 start on 3.5.0 2026-05-23 07:52:55 +10:00
Andrew TridgellandClaude Opus 4.7 6e3140d5ba testsuite: read xattrs natively instead of shelling out to getfattr
xattr_set() sets attributes with the native os.setxattr(), but
xattr_dump() read them back by running "getfattr -d". That asymmetry
breaks "make check" on any system where rsync is built with xattr
support (libattr headers present) but the attr package's CLI tools are
not installed -- common on Android/Termux and minimal CI images: setting
succeeds via os.setxattr, then xattr_dump's getfattr raises
FileNotFoundError, which crashes the test (reported FAIL) instead of
running or skipping it. That's why "make check" was failing here on
xattrs / xattrs-hlink.

Read the xattrs natively with os.listxattr()/os.getxattr() on Linux,
symmetric with xattr_set(), so the suite needs no external getfattr; the
output still mimics "getfattr -d" and only has to be self-consistent
between the source and destination dumps. Cygwin keeps the CLI path
(Python there lacks os.*xattr). make check now passes with no attr
package installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:15:22 +10:00
Andrew TridgellandClaude Opus 4.7 1d8f47cc71 testsuite: generate predictable fixture files instead of reading /etc, /bin, /
The Python rewrite of the suite carried over the shell habit of
populating the test tree by capturing "ls -l /etc" / "ls -l /bin"
(falling back to "ls /"): hands_setup() built etc-ltr-list / bin-lt-list
that way, and longdir_test.py did the same for its leaf files. That ties
the fixtures to the host filesystem layout -- those directories are
absent or unreadable on Android/Termux and other minimal environments,
where "ls /" fails outright -- and the captured content was never
reproducible from run to run.

Add a deterministic make_text_file() helper to rsyncfns.py and use it for
hands_setup()'s two fixture files and longdir's leaf files. The names
etc-ltr-list / bin-lt-list are unchanged (chmod, chmod-temp-dir and
alt-dest reference them by name); only the content source changes, so the
fixtures are now self-contained and identical on every platform. This
also drops longdir_test.py's date(1) and ls(1) subprocess calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:15:22 +10:00
Andrew TridgellandClaude Opus 4.7 743d715d43 docs: add rsync Discord server link
Add a link to the rsync Discord server (https://discord.gg/Avfvy9zhdp)
below the mailing lists section in README.md and on the lists.html web
page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:06:21 +10:00
Andrew TridgellandClaude Opus 4.7 4b862306e5 testsuite: restore non-Linux xattr/fake-super coverage
The Python rewrite had gated the xattr / fake-super tests (xattrs,
xattrs-hlink, chown-fake, devices-fake) to Linux because it used the
Linux-only os.*xattr. Restore them on macOS, FreeBSD, Cygwin and Solaris
via a per-OS xattr surface in rsyncfns.py (xattrs_supported / xattr_set /
xattr_dump):
  * Linux   -- os.*xattr
  * macOS   -- xattr
  * FreeBSD -- setextattr / lsextattr / getextattr
  * Cygwin  -- getfattr / setfattr (from the `attr` package; CPython on
               Cygwin has no os.*xattr)
  * Solaris -- runat(1), with the script on stdin and the attr name/value
               passed via the environment (the runat -c form mangles args)

Test attribute names are logical; the "user." namespace prefix is added
only on the Linux-style platforms (Linux, Cygwin). RSYNC_PREFIX/RUSR vary
per OS (macOS and Solaris use rsync.nonuser to avoid rsync's reserved
rsync.* space). The macOS and Cygwin workflows no longer skip these tests;
the FreeBSD/Solaris jobs use IGNORE skip-checking so need no change.

Verified on real Linux, macOS, FreeBSD, Cygwin and Solaris hosts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:34:52 +10:00
Andrew TridgellandClaude Opus 4.7 70948a9dc3 testsuite: post-review fixes and lock-file hardening
* chmod-option: pin umask to the suite-wide 022 baseline (mirroring the
    old rsync.fns) so rsync's --chmod `D+w` is computed and applied under
    the same umask -- fixes failures under a different ambient umask (077).
  * daemon module-list test: assert the `list = no` module does NOT leak
    into the listing (the substring check alone missed regressions).
  * claim_ports() lock file: open with O_NOFOLLOW and only fchmod a file we
    O_EXCL-created, rejecting a symlink OR hard link planted at the
    well-known /tmp path -- which, with the TCP tests running under sudo in
    CI, could otherwise chmod an arbitrary root-owned target. Require a
    pristine (regular, nlink==1) file.
  * CI: extend the Linux/Cygwin expected-skip lists for the gated tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:34:52 +10:00
Andrew TridgellandClaude Opus 4.7 951bf0a446 socket: enforce socketpair_tcp()'s anti-hijack guarantee
socketpair_tcp() fakes a connected socket pair via a loopback TCP
self-connect (socket -> bind 127.0.0.1:0 -> listen -> connect ->
accept), used by sock_exec() for RSYNC_CONNECT_PROG. Its comment has
long promised that "nobody else can attach to the socket, or if they
do that this function fails", but nothing actually verified it: the
code accept()ed whatever connection arrived first without checking it
was the one our own connect() made.

Between listen() and accept() the ephemeral loopback port is
connectable by any local user. With backlog 1 a same-host attacker who
races a connection in before our connect() lands could have their
socket returned by accept(), handing them one end of the rsync
protocol stream. The exposure is small (loopback only, random
ephemeral port, sub-millisecond window, local users only), but the
promised guarantee was simply not enforced.

Enforce it: after the connection is established, require that the peer
address of the accepted end (fd[0]) equals the local address of our
connecting end (fd[1]), and that both are 127.0.0.1. A hijacked
connection has a different source port and is rejected (errno EPERM,
fail closed). The legitimate self-connect always matches, so there is
no behaviour change for the normal path.

Verified: rebuilds clean with -Wall -W; the full testsuite still
passes in both transports (pipe `make check` 57/3, `runtests.py
--use-tcp` 59/1) -- the pipe transport exercises this code path on
every daemon test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:34:52 +10:00
Andrew TridgellandClaude Opus 4.7 bea8a3a16f testsuite: secure stdio-pipe daemon transport by default, opt-in TCP
Daemon-mode tests default to the stdio-pipe transport (RSYNC_CONNECT_PROG),
which opens no listening socket -- so `make check` never exposes a network
service. Real TCP is opt-in via `runtests.py --use-tcp`, with the daemon
bound to loopback (127.0.0.1) on a claim_ports()-reserved port; CI runs the
suite both ways.

start_test_daemon() is the single seam every daemon test uses: the secure
pipe by default, a real rsyncd on a claimed loopback port under --use-tcp.
Tests with no pipe equivalent (the fake-proxy listener and the reverse-DNS
hostname-ACL daemon test) are gated behind require_tcp().

`make check` also now runs the suite in parallel by default (CHECK_J=8);
the claim_ports() byte-range locks make that safe across concurrent runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:34:52 +10:00
Andrew TridgellandClaude Opus 4.7 bf8aab51e8 testsuite: add claim_ports() for parallel-safe TCP-port coordination
rsyncfns.claim_ports(*ports) takes exclusive POSIX byte-range locks on
/tmp/rsync_test.lck (offset = port number) so any number of test
processes can run concurrently without colliding on a TCP port: a test
asking for a port already held blocks until the holder exits. The
kernel drops the locks automatically when the holding process dies, so
a crashed test releases its ports with no manual cleanup.

Ports are claimed in sorted order so two callers requesting the same
set in different orders can't deadlock. The lock file is forced to
mode 0o666 after creation (the umask would otherwise trim it and lock
out a second user on a shared CI runner; EPERM when we're not the
owner is fine).

proxy-response-line-too-long is the first user: it switches from an
ephemeral port to a claimed fixed port (12873).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:34:52 +10:00
Andrew TridgellandClaude Opus 4.7 1f689ec0c2 testsuite: rewrite the shell testsuite in Python
Replace the entire shell-based testsuite with Python. runtests.py
already drove the suite (it had replaced runtests.sh earlier); this
converts all 60 test scripts from *.test shell to *_test.py and adds
testsuite/rsyncfns.py as the shared helper module -- the Python
counterpart of the now-removed rsync.fns.

runtests.py:
  * Discovers and runs both *.test and *_test.py; dispatches the
    Python tests via the same python3 that runs the harness.
  * Extends PYTHONPATH so tests can `import rsyncfns`.

testsuite/rsyncfns.py provides everything the ports need:
  * environment wiring (scratchdir / srcdir / TOOLDIR / RSYNC /
    TLS_ARGS, and HOME pointed at the per-test scratch dir);
  * result reporting -- test_fail / test_skipped / test_xfail mapping
    to the 0 / 1 / 77 / 78 exit-code convention;
  * the transfer-and-verify helpers checkit, checkdiff, verify_dirs,
    rsync_ls_lR, check_perms and the v_filt output filter;
  * fixture builders hands_setup, build_symlinks, build_rsyncd_conf,
    make_data_file, cp_p / cp_touch, makepath / rmtree.

All 60 tests are converted, including the four split-variant tests
that share one source via a Makefile-built symlink (chown/chown-fake,
devices/devices-fake, xattrs/xattrs-hlink, exclude/exclude-lsh);
Makefile.in's CHECK_SYMLINKS now points at the *_test.py names.

The dead rsync.fns shell library is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:34:52 +10:00
Andrew TridgellandClaude Opus 4.7 8839314025 ci: add static Android NDK build workflow
Cross-compiles statically-linked rsync binaries with the Android NDK for
arm64-v8a (all modern phones) and armeabi-v7a (older 32-bit devices), and
uploads them as workflow artifacts for adb push / Termux use.

The build is self-contained (optional external libraries disabled; keeps
md5/md4 and the bundled zlib) and forces a few configure cache values
that can't be probed when cross-compiling: lchmod()/lutimes() off (Bionic
doesn't declare them until API 36 though the symbols link), and
socketpair / mknod-FIFO / mknod-socket on (Android runs a Linux kernel,
so these match the native result). IPv6 is enabled explicitly.

Since the binaries are cross-compiled the test suite can't run; the job
instead asserts each binary is static and the correct architecture, and
smoke-tests `--version` under qemu-user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:09:47 +10:00
Andrew Tridgell 47e087d8eb testsuite: portable make_data_file helper; drop hard /dev/urandom dependency
symlink-dirlink-basis.test and chdir-symlink-race.test both
require a multi-kilobyte non-trivial-content source file for the
rsync delta algorithm to exercise.  Both used dd / head against
/dev/urandom directly, which fails on platforms that don't ship
/dev/urandom (e.g. HPE NonStop).  The dd error gets swallowed by
'2>/dev/null' and the test then fails with a misleading 'failed
to create test file' that hides the real cause.

Add make_data_file <path> <size> to testsuite/rsync.fns.  Prefers
/dev/urandom when readable (kernel-provided randomness, fast),
falling back to a deterministic awk LCG seeded from PID and a
POSIX cksum of the destination path.  Output is constrained to
printable ASCII (33..126) so the helper survives two awk-portability
quirks:

  - printf '%c', 0 terminates the string in some awks, emitting
    fewer than sz bytes;
  - gawk in UTF-8 locales encodes printf '%c', N for N > 127 as
    a 2-byte UTF-8 sequence, emitting more than sz bytes.

The tests don't need 8-bit binary entropy -- they just need
non-trivial bytes for rsync's block-matching algorithm.

Update both call sites to use the helper.  Linux/FreeBSD/macOS
still take the /dev/urandom fast path; NonStop and any other
platform missing the device get the awk fallback transparently.
Both paths verified locally with the symlink-dirlink-basis test.
2026-05-21 07:40:30 +10:00
Andrew Tridgell e1c5f0e93a t_chmod_secure: probe kernel RESOLVE_BENEATH at runtime; drop test skip
The chmod-symlink-race test was previously a no-op on Solaris,
OpenBSD, NetBSD, and Cygwin via a case 'uname -s' skip.  The skip
was too broad: of the four scenarios the helper exercises, only
the 'legitimate within-tree dir-symlink' one actually needs
RESOLVE_BENEATH-equivalent kernel support.  The other three
(attack rejection, plain relative path, top-level file) behave
identically on the per-component O_NOFOLLOW fallback and would
have caught the t_stub.c max_alloc=0 bug fixed in the previous
commit if the test had been allowed to run.

Make the helper probe the running kernel for either
openat2(RESOLVE_BENEATH) on Linux 5.6+ or openat(O_RESOLVE_BENEATH)
on FreeBSD 13+ / macOS 15+ by opening '.' under the requested
confinement.  Honour the result:

  - If RESOLVE_BENEATH-equivalent confinement is available, the
    within-tree symlink scenario must succeed (status quo).
  - If not, the per-component O_NOFOLLOW fallback rejects every
    symlink including legitimate ones; expect the within-tree
    symlink scenario to be rejected (rc != 0) and the file mode
    to remain unchanged.

The attack-rejection, plain-path and top-level scenarios are
unchanged: they expect the same outcome on both code paths.

Drop the case-based skip from chmod-symlink-race.test so the test
runs everywhere and the per-component fallback gets the CI
coverage that the SunOS/OpenBSD/NetBSD/Cygwin runners can
provide.  HPE NonStop -- which lacks RESOLVE_BENEATH but isn't in
the existing skip list -- is also covered by this change.
2026-05-21 07:40:30 +10:00
Andrew Tridgell cfdc27c613 t_stub.c: raise max_alloc default so test helpers can allocate
The t_stub.c shim defined max_alloc = 0 as a placeholder to satisfy
the link against util2.o.  This was harmless when the test helpers
made no allocations, but the secure_relative_open() implementation
in 3.4.0+ calls my_strdup() in its per-component O_NOFOLLOW
fallback (syscall.c around line 1857), and the 3.4.3 do_*_at()
hardening series added more such calls.  With max_alloc=0, every
allocation in that path trips the 'exceeded --max-alloc=0' check in
util2.c's my_alloc(), and t_chmod_secure (which exercises
do_chmod_at via secure_relative_open) fails on the very first
my_strdup.

The failure is invisible on Linux 5.6+ / FreeBSD 13+ / macOS 15+ /
recent Cygwin because those platforms take the kernel-enforced
openat2(RESOLVE_BENEATH) or openat(O_RESOLVE_BENEATH) branch and
never reach the per-component fallback.  It also goes unobserved
on the SunOS/OpenBSD/NetBSD/CYGWIN* CI runners because the
chmod-symlink-race.test script case-skips on those platforms (the
legitimate dir-symlink scenario the test exercises can't pass on
the per-component fallback).  HPE NonStop is the first platform
that lacks RESOLVE_BENEATH support AND isn't in the skip list AND
has someone actually running the test suite, so it surfaced the
latent bug.

Raise max_alloc to SIZE_MAX so the helpers can allocate freely.
A follow-up patch makes t_chmod_secure adapt at runtime so the
skip list can be removed and the per-component fallback gets real
CI coverage.
2026-05-21 07:40:30 +10:00
Andrew Tridgell 7e7372a0c5 packaging: add ftp.filt, the FTP mirror filter file
The .filt file in /home/ftp/pub/rsync on samba.org controls which
subtrees release.py's FTP mirror excludes (currently /binaries/
and /generated-files/).  Without it, step-10-push-ftp's
'rsync --del' would propagate local deletions to the server even
for those archive subtrees.

Until now the only copy of this two-line file lived on the server.
Bundle it in source at packaging/ftp.filt so it survives a disaster
on samba.org, and have step_1_fetch seed FTP_DIR/.filt from the
bundled copy on every run (with --exclude=/.filt on the rsync pull,
so the server's copy can't silently drift the bundled one).
step-10-push-ftp then propagates any in-source updates to the
filter back to the server.
2026-05-20 15:36:44 +10:00
Andrew Tridgell 8cad2097e9 packaging: remove obsolete samba-rsync and send-news scripts
Both scripts were pre-release.py legacy helpers:

  * samba-rsync rsync'd ~/samba-rsync-{ftp,html}/ to the samba.org
    server.  release.py step-10-push-ftp and step-11-push-html now
    do exactly this, using ../release/rsync-{ftp,html}/ as the
    local mirrors.

  * send-news copied README/INSTALL/NEWS .md + .html files into
    ~/samba-rsync-ftp/ and rsync'd them to samba.org.
    release.py step-8-update-ftp already does this
    (./md-convert --dest=FTP_DIR README.md NEWS.md INSTALL.md and
    the surrounding rsync of html files into FTP_DIR), and
    step-10-push-ftp pushes the result.

Update the trailing instructions printed at the end of
step-12-push-git to drop the now-obsolete 'run packaging/send-news'
suggestion, and tighten the comment in step_1_fetch that referred
to samba-rsync as a current sibling tool.
2026-05-20 15:36:44 +10:00
Andrew Tridgell d039cfa829 packaging/release.py: rsync-web is now an in-tree subdirectory
Track the move of rsync-web from sibling git checkout to a regular
subdirectory of the rsync source tree:

  * HTML_SRC: '../rsync-web' -> 'rsync-web'.
  * step_1_fetch: drop the .git-presence probe and the 'make sure
    it's up to date' reminder.  Both made sense when rsync-web was
    a separate repo the maintainer had to clone and pull, but the
    directory is now part of the same checkout as this script.
  * rsync invocation no longer needs --exclude=/.git: there is no
    .git inside rsync-web/ (it is just a subdir of the parent
    rsync-git checkout).
  * Header comment block and step-1 help text rewritten to describe
    the new layout.
2026-05-20 15:36:44 +10:00
Andrew Tridgell 0af88421dc import rsync-web website content as a subdirectory
Fold the standalone rsync-web repo into the rsync source tree as
rsync-web/, eliminating the sibling-checkout convention and the
drift it causes between the release-time HTML snapshot in
../release/rsync-html and the source of truth in ../rsync-web.

Flat-copy import (no git history merge).  The standalone repo at
github.com/RsyncProject/rsync-web is retained for historical
reference and will be archived once the in-tree copy proves itself.

Add /rsync-web/ to .gitattributes with export-ignore so the
website content does not bloat the release source tarball
produced by 'git archive' in packaging/release.py step_7_tarball.

A follow-up commit repoints HTML_SRC in packaging/release.py at
the new in-tree location.
2026-05-20 15:36:44 +10:00
Andrew Tridgell 9d014670df INSTALL.md: point Ubuntu users at the ppa:rsyncproject/rsync PPA
Most Ubuntu users landing on INSTALL.md want to install rsync, not
build it.  Add a short section near the top that offers the
Launchpad PPA as the one-line path for the four currently supported
series (jammy 22.04 LTS, noble 24.04 LTS, questing 25.10,
resolute 26.04 LTS), and clarify that the rest of the file is about
building from source.
2026-05-20 15:36:44 +10:00
Andrew Tridgell 647a00a278 start on 3.4.4 2026-05-20 11:50:33 +10:00
Andrew Tridgell 2c7777aaa6 Preparing for release of 3.4.3 [buildall] 2026-05-20 10:07:26 +10:00
Andrew TridgellandClaude Opus 4.7 6af41d2357 version.h: bump to 3.4.3 for the release
Drops the "dev" suffix on RSYNC_VERSION ahead of the
2026-05-20 00:00 UTC public release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew Tridgell a0b9a8e989 NEWS: prepare 3.4.3 release entry with six CVEs
Set the date to 20 May 2026, add a SECURITY FIXES section listing
all six May 2026 CVEs (CVE-2026-29518, -43617, -43618, -43619,
-43620, -45232) with reach, root cause, fix and reporter for each,
plus a note on the defence-in-depth hardening that goes with them.
Also list the new symlink-race regression tests under DEVELOPER
RELATED.
2026-05-20 10:01:22 +10:00
Andrew Tridgell ac692b199c util1: handle out-of-range times in timestring 2026-05-20 10:01:22 +10:00
Andrew Tridgell 147e9bea8c main: reject hyphen-prefixed remote-shell hostnames 2026-05-20 10:01:22 +10:00
Andrew Tridgell a5fc5ebe7a socket: reject over-long proxy response line
fixes a one byte stack overflow when using RSYNC_PROXY with a
malicious proxy.

Reach: only when RSYNC_PROXY is set and a malicious or MITM'd
proxy returns the pathological response.  The byte written is
always '\0' and the attacker doesn't choose the offset, so impact
is corruption of one adjacent stack byte and possible later
misbehaviour or crash -- no information disclosure beyond the
existing rprintf of buffer contents.

Reported by Aisle Research via Michal Ruprich
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 c79cb81a4f rsync.h: lower MAX_WIRE_DEL_STAT to avoid signed-int overflow in read_del_stats
read_del_stats() in main.c accumulates 5 wire-supplied counts into
the int32 stats.deleted_files field:

    stats.deleted_files  = read_varint_bounded(..., MAX_WIRE_DEL_STAT, ...);
    stats.deleted_files += stats.deleted_dirs     = ...;
    stats.deleted_files += stats.deleted_symlinks = ...;
    stats.deleted_files += stats.deleted_devices  = ...;
    stats.deleted_files += stats.deleted_specials = ...;

With the previous MAX_WIRE_DEL_STAT = 2^30 (1.07 GB) the worst-case
sum is 5 * 2^30 = 5.37 GB; three maximal values already exceed
INT32_MAX = 2.15 GB on the third "+=", triggering signed integer
overflow (C99 6.5/5 -- undefined behaviour, the compiler may assume
it cannot happen and elide subsequent checks).

The bound was introduced in f0155902 ("defence-in-depth: bound
wire-supplied counts and lengths") with a commit message claiming
"per-summand cap so the total can't overflow", but 2^30 * 5 does
overflow.  Lower the per-summand cap to 2^28 (= 268M) so the worst
case is 5 * 2^28 = 1.34 GB < INT32_MAX with margin.  2^28 deletions
per category is still vastly above any plausible real transfer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 650643109e defence-in-depth: receiver block-index bounds + read_delay_line null check
Two assorted audit findings:

  - receive_data() never bounds-checked the block index returned
    by recv_token() against sum.count before computing offset2
    and feeding it to map_ptr(). An out-of-bounds index from a
    hostile sender produces invalid memory access. Add a
    sum.count bounds check.

  - read_delay_line()'s strchr() call could return NULL when no
    space was found, but the code unconditionally added 1 to the
    result before dereferencing. Low impact (just a disconnect on
    exit of the client-specific forked process) but the NULL
    deref is real. Guard the NULL.

Both reported by Joshua Rogers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 4cf08983e8 defence-in-depth: guard cumulative snprintf against length underflow
Two cumulative-snprintf patterns in log.c (rsyserr) and main.c
(output_itemized_counts) had the shape

    len = snprintf(buf, sizeof buf, ...);
    len += snprintf(buf+len, sizeof buf - len, ...);

with no guard between calls. snprintf returns the would-have-been
length on truncation, so a truncated first call leaves
"sizeof buf - len" as a negative-then-promoted-to-size_t value,
underflowing into a huge size_t and writing past buf.

Realistic exposure is small in both cases (log header well under
buffer, only ~5 itemized iterations writing ~25 chars each into a
1024-byte buffer) but the defect class matches bb0a8118 and the
fix is cheap. Guard before each subsequent call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 8112445318 defence-in-depth: bound wire-supplied counts and lengths
Multiple receiver-side fields read from the wire were trusted
without upper-bound checks. A hostile peer could either request
extreme allocations (DoS via --max-alloc) or, on platforms where
read_varint returned a negative value, push ~SIZE_MAX through the
size_t conversion to wrap downstream length checks.

Introduce read_int_bounded(), read_varint_bounded() and
read_varint_size() in io.c so wire-derived integer ranges are
checked at the read site rather than scattered across each
caller, with RERR_PROTOCOL on out-of-range input.

Apply the bounded primitives to:
  - sum->count (checksum count -- previously could overflow
    (size_t)count * xfer_sum_len on 32-bit with raised max-alloc)
  - xattrs: count, name_len, datum_len, plus rel_pos overflow
    detect to stop chain wrapping the num accumulator
  - acls: ida-entry count
  - flist: file mode S_IFMT validation, modtime_nsec range check
  - delete-stat counters in main: per-summand cap so the total
    can't overflow a signed 32-bit accumulator

Reporters include Joshua Rogers (checksum-count overflow finding).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 c38f20c5ff clientserver: fix hostname ACL bypass when using daemon chroot
On an rsync daemon configured with "daemon chroot", the reverse-DNS
lookup of the connecting client was performed *after* the chroot
had been entered. If the chroot did not contain the files glibc
needs for resolution (/etc/resolv.conf, /etc/nsswitch.conf,
/etc/hosts, NSS service modules), the lookup failed and
client_name() returned "UNKNOWN". Hostname-based deny rules
("hosts deny = *.evil.example") therefore could not match, and
an attacker controlling their PTR record could connect from a
hostname the administrator had intended to deny. IP-based ACLs
were unaffected.

Do the reverse DNS lookup before chroot/setuid; client_name()
caches its result, so the post-chroot call uses the cached value
and hostname-based ACLs work even when DNS is unavailable
post-chroot.

Adds testsuite/daemon-chroot-acl.test as end-to-end regression
coverage. The test sets up an empty chroot directory, configures
"hosts deny = <localhost-resolved-name>" with daemon chroot, and
asserts the connection is refused with @ERROR access denied.
Uses unshare --user --map-root-user for non-root CAP_SYS_CHROOT;
skips cleanly on non-Linux or when user namespaces aren't
available.

Reporter: Joshua Rogers (MegaManSec).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 0cf200ecbb receiver: add parent_ndx<0 guard, mirroring 797e17f
Commit 797e17f ("fixed an invalid access to files array") added a
parent_ndx < 0 guard to send_files() in sender.c, but the visually-
identical block in recv_files() in receiver.c was not updated. A
malicious rsync:// server can therefore drive any connecting client
into the same out-of-bounds dir_flist->files[-1] read followed by a
file_struct dereference in f_name() one line later.

Reach: protocol-30+ default (inc_recurse) makes flist.c:2745 set
parent_ndx = -1 on the first received flist when the sender omits a
leading "." entry; rsync.c flist_for_ndx() does not reject ndx == 0
in that state because the range check evaluates 0 < 0 = false; and
read_ndx_and_attrs() only validates ndx with the ITEM_TRANSFER bit
set, so iflags=ITEM_IS_NEW (or any other non-transfer iflag word)
bypasses the check.

Apply the same guard receiver-side. Confirmed: the same PoC (a
minimal Python rsyncd that handshakes with CF_INC_RECURSE, sends a
no-leading-"." flist, and emits ndx=0 with ITEM_IS_NEW) crashes
unpatched 3.4.2 with SEGV_MAPERR si_addr=0x4101a-class in the
receiver child; with this guard it exits cleanly with code 2
(RERR_PROTOCOL).

The attack surface delta over the sender variant is large:
the original was malicious-client -> daemon, this is
malicious-server -> any rsync client doing a normal rsync://
or remote-shell pull.

Reported by Pratham Gupta (alchemy1729).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 e4c681fefd testsuite: cover 'refuse options = compress' for the daemon
Add a daemon-refuse-compress test that builds a module configured with
'refuse options = compress' and asserts that:
  1. an attempted -z transfer to that module fails with an error
     mentioning --compress, and
  2. the same transfer without -z still succeeds.

This pins down the documented way to disable all compression on a
daemon, which previously had no automated coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 c44c90e946 token: harden compressed-token decoding against integer overflow
The receiver's three compressed-token decoders --
recv_deflated_token (zlib), recv_zstd_token, and
recv_compressed_token (lz4) -- accumulated rx_token (a 32-bit
signed counter) without overflow checking. A malicious sender
could craft a compressed-token stream that walked rx_token past
INT32_MAX, with careful manipulation leaking process memory
contents to the wire (environment variables, passwords, heap
pointers, library pointers -- significantly weakening ASLR
and facilitating further exploitation).

Cap rx_token at MAX_TOKEN_INDEX = 0x7ffffffe. Fold the
bookkeeping into recv_compressed_token_num() and
recv_compressed_token_run() shared by all three decoders. Reject
negative or out-of-range token values explicitly. Also cap the
simple_recv_token literal-block length at the source: any
wire-supplied length > CHUNK_SIZE is ill-formed (the matching
simple_send_token never writes a chunk larger than CHUNK_SIZE),
so reject before looping on attacker-controlled bytes.

Reach: an authenticated daemon connection with compression
enabled (the default for protocols >= 30 when both peers
advertise it). Disabling compression on the daemon
("refuse options = compress" in rsyncd.conf) is the available
workaround.

Reporter: Omar Elsayed (seks99x).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 fc592a8e25 ci(cygwin): mark all symlink-race regression tests as expected-skipped
Cygwin lacks RESOLVE_BENEATH-equivalent kernel support and the
per-component O_NOFOLLOW fallback also can't be exercised meaningfully
under the cygwin runner's filesystem semantics, so every test that
asserts the secure_relative_open / do_*_at machinery actually blocks
the attack would skip. Make those skips expected in the workflow's
RSYNC_EXPECT_SKIPPED list:

  - chdir-symlink-race
  - chmod-symlink-race
  - bare-do-open-symlink-race
  - sender-flist-symlink-leak
  - daemon-chroot-acl

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 40a6e13071 testsuite: end-to-end regression test for chdir-symlink-race
testsuite/chdir-symlink-race.test runs an actual rsync daemon
(via RSYNC_CONNECT_PROG to avoid the network) configured with
"use chroot = no", plants a symlink at module/subdir -> ../outside,
and runs four flavours of attacker-shaped transfer (single-file
poc_chmod, -r push into the symlinked subdir with --size-only and
without, -r push into the module root). All four must leave the
outside-the-module sentinel file's mode AND content unchanged.

Portability:
  - file_mode() helper falls back to BSD stat -f %Lp when GNU
    stat -c %a is unavailable (macOS, FreeBSD).
  - Pre-saved pristine copy + cmp(1) replaces sha1sum, which
    differs across platforms (sha1sum / shasum / sha1).

Tests are kept running as root in the user-namespace re-exec
wrapper used by symlink-race tests so the daemon's setuid path
doesn't drop into the test user's identity (which on Linux
would mean the chmod-escape code path can't trigger because
the test user doesn't have CAP_FOWNER over the outside file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 3cc6a9e8cd util1+syscall: secure copy_file source/dest opens; bare-path defence-in-depth
Three related codex audit findings:

  Finding 3a: copy_file()'s source open in util1.c used
  do_open_nofollow(), which only rejects a final-component
  symlink. A parent-component symlink (e.g. --copy-dest=cd where
  cd -> /outside) follows freely and reads outside the module.
  Route through secure_relative_open() with O_NOFOLLOW.

  Finding 3b: generator.c's in-place backup-file create still
  used a bare do_open with O_CREAT, leaving a tiny but reachable
  parent-symlink window between the secure unlink (already
  through do_unlink_at) and the create. Add do_open_at() that
  goes through a secure parent dirfd, and route the call site
  through it.

  Finding 3c: copy_file()'s destination open in
  unlink_and_reopen() had the same bare-do_open pattern; route
  through do_open_at as well.

Adds testsuite/copy-dest-source-symlink.test and
testsuite/bare-do-open-symlink-race.test as regression coverage
for both attack shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 30656c5e35 syscall: add symlink-race-safe do_*_at() wrappers and harden secure_relative_open
Add the rest of the path-based syscall wrappers and migrate every
receiver-side caller:
  - do_lchown_at, do_rename_at, do_mkdir_at, do_symlink_at,
    do_mknod_at, do_link_at, do_unlink_at, do_rmdir_at,
    do_utimensat_at, do_stat_at, do_lstat_at

Same shape as do_chmod_at: open each parent under
secure_relative_open(), call the *at() variant against the dirfd,
fall through to the bare path-based syscall in non-daemon /
chrooted / absolute-path / no-parent cases. macOS's
setattrlist-based set_times tier is also routed through the
utimensat_at path on daemon-no-chroot.

Hardenings to secure_relative_open() itself:
  - confine basedir resolution under the same kernel mechanism
    used for relpath (basedirs from --copy-dest / --link-dest are
    sender-controllable in daemon mode)
  - reject any '..' component (bare '..', 'foo/..', 'subdir/..')
    so the per-component O_NOFOLLOW fallback can't escape
  - return the dirfd we built up from the per-component fallback
    when the caller passed O_DIRECTORY (otherwise every do_*_at
    failed with EINVAL on platforms without RESOLVE_BENEATH)

Adds testsuite/alt-dest-symlink-race.test and
testsuite/secure-relpath-validation.test (with t_secure_relpath
helper) as regression coverage for the new hardenings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 15d2964256 util1: secure change_dir() against symlink-race chdir-escape
The receiver's chdir(2) into a destination subdirectory followed
attacker-planted symlinks at every path component. Once CWD
escaped the module, every subsequent path-relative syscall (open,
chmod, lchown, ...) inherited the escape -- defeating
secure_relative_open's RESOLVE_BENEATH anchor against AT_FDCWD,
since the anchor itself was now outside the module.

Route change_dir's relative target through secure_relative_open()
and fchdir() to the resulting dirfd in am_daemon && !am_chrooted
mode, so the chdir step itself can no longer follow a parent-
symlink. Same treatment applied to the CD_SKIP_CHDIR /
set_path_only path so it also can't follow attacker symlinks
during path tracking.

Adds testsuite/sender-flist-symlink-leak.test covering the
sender-side flist resolution variant of the same primitive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 862fe4eeaf syscall+receiver: secure receiver-side do_chmod against symlink-race TOCTOU
CVE-2026-29518's fix routed the receiver's open() through
secure_relative_open(), but every other path-based syscall the
receiver runs on sender-controllable paths is vulnerable to the
same TOCTOU primitive. This commit closes the chmod variant.

Add do_chmod_at() that opens the parent of fname under
secure_relative_open() and uses fchmodat() against the resulting
dirfd. Gate the secure path on am_daemon && !am_chrooted (the same
gate use_secure_symlinks already uses for the receiver basis-file
open), so non-daemon callers and chrooted daemons keep the original
do_chmod() fast path.

Migrate the receiver-side do_chmod() call sites in delete.c,
generator.c, rsync.c, and xattrs.c.

Adds testsuite/chmod-symlink-race.test (with t_chmod_secure helper)
as regression coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.6 859d44fa4f sender: fix read-path TOCTOU by opening from module root (CVE-2026-29518)
The sender's file open was vulnerable to the same TOCTOU symlink
race as the receiver-side basis-file open. change_pathname() calls
chdir() into subdirectories, which follows symlinks; an attacker
could race to swap a directory for a symlink between the chdir and
the file open, allowing reads of privileged files through the
daemon.

Reconstruct the full relative path (F_PATHNAME + fname) and open
via secure_relative_open() from the trusted module_dir, which
walks each path component without following symlinks. This is
independent of CWD, so the chdir race is neutralised.

CVE-2026-29518.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 f1c24ab03b syscall+clientserver: am_chrooted and use_secure_symlinks for daemon-no-chroot (CVE-2026-29518)
CVE-2026-29518: an rsync daemon configured with "use chroot = no"
is exposed to a TOCTOU race on parent path components. A local
attacker with write access to a module can replace a parent
directory component with a symlink between the receiver's check
and its open(), redirecting reads (basis-file disclosure) and
writes (file overwrite) outside the module. Under elevated daemon
privilege this allows privilege escalation. Default
"use chroot = yes" is not exposed.

Add secure_relative_open() in syscall.c. It walks the parent
components under RESOLVE_BENEATH (Linux 5.6+) /
O_RESOLVE_BENEATH (FreeBSD 13+, macOS 15+) / per-component
O_NOFOLLOW elsewhere, anchored at a trusted dirfd, so a parent-
symlink swap is rejected by the kernel. Route the receiver's
basis-file open in receiver.c through it when use_secure_symlinks
is set in clientserver.c rsync_module().

Reporters: Nullx3D (Batuhan SANCAK); Damien Neil; Michael Stapelberg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 b9cc0c6176 ci(almalinux-8): use python39 module for runtests.py
The default python3 on AlmaLinux 8 is 3.6, but runtests.py uses
subprocess.run(capture_output=...) and check_output(text=...) which
were introduced in 3.7. Install the python39 module stream and point
/usr/bin/python3 at it via alternatives so the existing shebang
resolves correctly.

Reproduced as: TypeError: __init__() got an unexpected keyword
argument 'capture_output' at runtests.py line 75.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 05:47:29 +10:00
Andrew TridgellandClaude Opus 4.7 c60550bff9 ci: add Ubuntu 22.04 and AlmaLinux 8 workflows for backporting
The intent is to validate that future security fixes still build and
test cleanly on the oldest still-supported LTS releases of the two
mainstream Linux families, so backports can be developed against the
same CI surface as the trunk:

  - ubuntu-22.04: oldest GitHub Actions runner image still available
    (20.04 was retired in April 2025). Mirrors the existing
    ubuntu-build.yml step list.
  - almalinux-8: RHEL 8 rebuild, full support until 2029. Runs in an
    almalinux:8 container on ubuntu-latest because GHA has no native
    runner for the Fedora/RHEL family. Pulls libzstd/xxhash/lz4 dev
    headers from PowerTools + EPEL; commonmark via pip for the man
    page generator.

Both jobs follow the same paths-ignore convention as the other
workflows so a workflow-only change to one file won't fan out across
the whole CI matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 05:47:29 +10:00
Andrew TridgellandClaude Opus 4.6 67f1dcf604 testsuite: run protected-regular test as non-root using unshare
Use unshare with user namespace UID mapping to run the
protected-regular test without real root privileges. Falls back
to skipping if unshare or uidmap is not available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 09:27:12 +10:00
Andrew TridgellandClaude Opus 4.7 79fd7d5885 Start 3.4.3dev going.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:43:14 +10:00
Andrew TridgellandClaude Opus 4.7 dfdcd8f851 ci: add symlink-dirlink-basis to Cygwin's expected-skipped list
The test correctly skips on Cygwin (which lacks RESOLVE_BENEATH), but
the workflow's RSYNC_EXPECT_SKIPPED list still treats any change in
the skipped set as a CI failure. Add the new test name so the
skipped/got comparison matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:30:31 +10:00
Andrew TridgellandClaude Opus 4.7 04e2fc2c76 testsuite: skip symlink-dirlink-basis on platforms without RESOLVE_BENEATH
secure_relative_open() has a kernel-enforced "stay below dirfd" path
on Linux 5.6+ (openat2 RESOLVE_BENEATH) and FreeBSD 13+ (openat
O_RESOLVE_BENEATH). On Solaris, OpenBSD, NetBSD, and Cygwin the code
falls back to the per-component O_NOFOLLOW walk, which by design
rejects every directory symlink in the path -- the very case this
test exercises. Mark the test skipped there rather than have it
fail with a known regression that's tracked separately.

macOS is intentionally not in the skip list: although it does not
have O_RESOLVE_BENEATH either, the test passes there in practice;
investigation of the underlying reason is left as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:30:31 +10:00
Andrew TridgellandClaude Opus 4.7 7f60ec001a syscall: also use O_RESOLVE_BENEATH on FreeBSD and MacOS
FreeBSD and MacOS have O_RESOLVE_BENEATH as an openat() flag with the same
"must not escape dirfd" semantics as Linux's RESOLVE_BENEATH. The
kernel rejects ".." escapes, absolute symlinks, and symlinks whose
target lies outside dirfd, while still following symlinks that
resolve within it -- the same trade-off that fixes issue #715 on
Linux.

Add a parallel BSD path in secure_relative_open(), gated on
declared. Unlike Linux, BSD doesn't have the header/runtime split
where the symbol can exist without kernel support, so no runtime
fallback is needed: if the flag compiles in, the kernel honours it.

OpenBSD and NetBSD have no equivalent kernel primitive and continue
to use the existing per-component O_NOFOLLOW walk; issue #715
remains visible on those platforms (a userland resolver or
unveil(2)-based fence would be follow-up work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:30:31 +10:00
Andrew TridgellandClaude Opus 4.7 4fa7156ccd syscall: use openat2(RESOLVE_BENEATH) on Linux for secure_relative_open
The CVE fix in commit c35e283 made secure_relative_open() walk every
component of relpath with O_NOFOLLOW. That blocks every symlink in the
path, which is stricter than the threat model required: legitimate
directory symlinks within the destination tree (e.g. when using -K /
--copy-dirlinks) are also rejected, breaking delta transfers with
"failed verification -- update discarded".  See issue #715.

On Linux 5.6+, openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) gives
us exactly what we want: the kernel rejects any resolution that would
escape the starting directory (via "..", absolute paths, or symlinks
pointing outside dirfd) while still following symlinks that resolve
within it. /proc magic-links are blocked too.

Use openat2 first; fall back to the existing per-component O_NOFOLLOW
walk on ENOSYS (kernel < 5.6). The lexical "../" checks at the head
of the function are kept as defense in depth. The Linux gate is
plain #ifdef __linux__: the runtime ENOSYS fallback covers the only
case that actually matters (header present + old kernel), and any
Linux build environment without linux/openat2.h will fail with a
clear "no such file" error rather than silently disabling the
protection.

Verified manually that openat2(RESOLVE_BENEATH) blocks all four
escape patterns (absolute symlink, ../ symlink, lexical .., absolute
path) while allowing direct and within-tree symlinks. The new
testsuite/symlink-dirlink-basis.test (taken from PR #864 by Samuel
Henrique) exercises the issue #715 regression and passes; full
make check passes 47/47.

Test: testsuite/symlink-dirlink-basis.test (8 scenarios)
Fixes: https://github.com/RsyncProject/rsync/issues/715

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:30:31 +10:00
Andrew TridgellandClaude Opus 4.7 dcf364dac5 testsuite/xattrs: ignore SUNWattr_* in the Solaris xls helper
The Solaris xls() function listed every entry in the file's xattr
directory, which on Solaris includes OS-managed SUNWattr_ro and
SUNWattr_rw pseudo-attributes. SUNWattr_rw embeds the file creation
time, so its bytes naturally differ between the source and destination
files, making the xattrs and xattrs-hlink tests fail with diffs that
have nothing to do with rsync.

Rsync's own listxattr wrapper already filters these out
(lib/sysxattrs.c), so the right fix is to filter them in the test
display too. Other platforms are unaffected because each has its own
xls() branch in the case statement.

With the test now actually passing on Solaris, drop the CI hack that
overwrote testsuite/xattrs.test with a skip stub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:25:58 +10:00
Andrew TridgellandClaude Opus 4.7 d1eff8f0dc ci: add OpenBSD and NetBSD build jobs, run 'make check' on the BSDs
Mirror the existing FreeBSD workflow for OpenBSD and NetBSD using
vmactions/openbsd-vm and vmactions/netbsd-vm so we get cross-BSD
coverage on push, PR, and the nightly schedule.

Also extend the FreeBSD and Solaris workflows to actually exercise the
test suite by running 'make check' after the build. The Linux, macOS,
and Cygwin jobs already did this.

The Solaris xattrs and xattrs-hlink tests are removed before 'make
check' because the Solaris SUNWattr_ro / SUNWattr_rw system attributes
leak into the test diff; that's a real rsync-on-Solaris issue to follow
up on, but skip the tests for now so the suite goes green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:15:37 +10:00
Andrew TridgellandClaude Opus 4.7 8f727166d9 runtests.py: error early when test helper programs are missing
When invoked directly (rather than via 'make check'), runtests.py
previously left the user with a wall of confusing "not found" errors
from inside individual test scripts if the CHECK_PROGS helpers had not
been built. Detect this up front and point the user at the make
target that builds them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:00:55 +10:00
Andrew Tridgell 5bcb3deb2f packaging: remove old release system 2026-04-28 15:08:25 +10:00
Andrew Tridgell de3cc03b03 Preparing for release of 3.4.2 [buildall] 2026-04-28 14:29:48 +10:00
Andrew Tridgell 006ee327d6 packaging: new release script 2026-04-28 14:27:41 +10:00
Andrew Tridgell 9b6363fa10 update NEWS.md ready for 3.4.2 2026-04-28 12:55:38 +10:00
Andrew Tridgell 9e2f0fe9ae packaging: remove support for rsync-patches 2026-04-28 12:55:38 +10:00
Michal Ruprich 4f6e4ea64a Do not clean DISPLAY unconditionally 2026-04-22 13:05:35 +10:00
Andrew TridgellandClaude Opus 4.6 567c40935f call tzset() before chroot to cache timezone data
localtime/localtime_r need /etc/localtime for timezone info.
After chroot this file is inaccessible, causing log timestamps
to fall back to UTC. Calling tzset() before chroot ensures the
timezone data is cached by glibc for subsequent calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 13:02:10 +10:00
Michal Ruprich 8e11f0c169 Using a correct time in log file 2026-04-22 13:02:10 +10:00
Andrew TridgellandClaude Opus 4.6 e9dbc8d66d rsyncd.conf: document the temp dir parameter
The temp dir parameter was functional but undocumented in the man page.

Fixes: https://github.com/RsyncProject/rsync/issues/820

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 12:34:58 +10:00
Andrew TridgellandClaude Opus 4.6 bd2dbd2f32 runtests.py: preserve test-execution order in skipped list
The sorted() call reordered skipped test names alphabetically,
causing CI expected-skipped mismatches (e.g. acls,acls-default
instead of acls-default,acls). Sort by original test order instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 12:34:39 +10:00
Andrew TridgellandClaude Opus 4.6 350e295d1c runtests.py: add -j/--parallel option for parallel test execution
Add parallel test execution using concurrent.futures. With -j8 the
test suite completes in ~4s vs ~29s sequential (~7x speedup).

Also fix two issues that caused failures under parallel execution:
- rsync_ls_lR now prunes testtmp/ so parallel tests don't see each
  other's temp files when scanning the source tree
- clean-fname-underflow.test now uses $scratchdir instead of /tmp

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 12:34:39 +10:00
Andrew TridgellandClaude Opus 4.6 066156fcd9 replace runtests.sh with runtests.py
Rewrite the test runner in Python with proper command-line options
including --valgrind which directs valgrind output to per-process
log files so it doesn't interfere with test output comparisons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 12:34:39 +10:00
Holger Hoffstätte a5bbe859db Fix glibc-2.43 constness warnings
Glibc 2.43 added C23 const-preserving overloads to various string functions,
which change the return type depending on the constness of the argument(s).
Currently this leads to warnings from calls to strtok() or strchr().
Fix this by properly declaring the respective variable types.

Signed-off-by: Holger Hoffstätte <holger@applied-asynchrony.com>
2026-04-22 12:10:08 +10:00
Andrew TridgellandClaude Opus 4.6 d046525de3 zero all new memory from allocations
Change my_alloc() to use calloc instead of malloc so all fresh
allocations return zeroed memory. Also zero the expanded portion
in expand_item_list() after realloc, since it knows both old and
new sizes. This gives more predictable behaviour in case of bugs
where uninitialised or stale memory is accidentally accessed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 11:44:10 +10:00
Andrew Tridgell bb0a8118c2 xattrs: fixed count in qsort
this fixes the count passed to the sort of the xattr list. This issue
was reported here:

https://www.openwall.com/lists/oss-security/2026/04/16/2

the bug is not exploitable due to the fork-per-connection design of
rsync, the attack is the equivalent of the user closing the socket
themselves.
2026-04-22 10:38:14 +10:00
Andrew Tridgell d1df0aaf70 fix signed integer overflow in proxy protocol v2 header parsing
The len field in the proxy v2 header was declared as signed char,
allowing a negative size to bypass the validation check and cause
a stack buffer overflow when passed to read_buf() as size_t.

This bug was reported by John Walker from ZeroPath, many thanks for
the clear report!

With the current code this bug does not represent a security issue as
it only results in the exit of the forked process that is specific to
the attached client, so it is equivalent to the client closing the
socket, so no CVE for this, but it is good to fix it to prevent a
future issue.
2026-04-16 13:59:52 +10:00
Andrew TridgellandClaude Opus 4.6 15d8e49a64 zlib: convert K&R function definitions to ANSI style
The bundled zlib 1.2.8 used K&R-style function definitions which are
rejected by clang 16+ as hard errors. Convert all 90 functions across
9 files to ANSI-style prototypes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:49:30 +10:00
Andrew TridgellandClaude Opus 4.6 b905ab23af CI: add simd-checksum to expected-skipped on macOS and Cygwin
The new simd-checksum test is skipped on platforms where SIMD
instructions are unavailable (macOS ARM, Cygwin). Add it to the
RSYNC_EXPECT_SKIPPED lists so CI doesn't fail on the mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:52:01 +11:00
Andrew TridgellandClaude Opus 4.6 aa142f08ef fix uninitialized mul_one in AVX2 checksum and add SIMD checksum test
The AVX2 get_checksum1_avx2_64() read mul_one before initializing it,
which is undefined behavior. Replace the cmpeq/abs trick with
_mm256_set1_epi8(1) to match the SSSE3 and SSE2 versions.

Add a TEST_SIMD_CHECKSUM1 test mode that verifies all SIMD paths
(SSE2, SSSE3, AVX2, and the full dispatch chain) produce identical
results to the C reference, across multiple buffer sizes with both
aligned and unaligned buffers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:52:01 +11:00
Andrew Tridgell 236417cf35 acl: fixed ACL ID mapping for non-root
closes issue #618
2026-01-19 11:32:13 +11:00
Andrew Tridgell 2a97d81e99 CI: fixed MacOS test
fixed multiple MacOS issues
2025-12-31 11:37:27 +11:00
Andrew Tridgell 359e539a72 reject negative token values in compressed stream receivers
Validate that token numbers read from compressed streams are
non-negative. A negative token value would cause the return value
of recv_*_token() to become positive, which callers interpret as
literal data length, but no data pointer is set on this code path.

While this only causes the receiver to crash (which is process-isolated
and only affects the attacker's own connection), it's still undefined
behavior.

Reported-by: Will Sergeant <wlsergeant@gmail.com>
2025-12-31 09:31:52 +11:00
Andrew Tridgell 9e0898460d util: fixed issue in clean_fname()
fixes buffer underflow (not exploitable) in clean_fname
2025-12-30 17:49:35 +11:00
Andrew Tridgell 185520a141 testsuite: added clean-fname-underflow test 2025-12-30 17:49:35 +11:00
Andrew Tridgell c98f9d1f68 fix uninitialized buf1 in get_checksum2() MD4 path
The static buf1 pointer was only allocated when len > len1, but on
first call with len == 0, this condition is false (0 > 0), leaving
buf1 NULL when passed to memcpy().

Fixes #673
2025-12-30 16:51:43 +11:00
Nebojša Cvetković 1f9ce2fcbe rsync: Add missing dirs long option 2025-12-30 16:48:34 +11:00
Andrew Tridgell 797e17fc4a fixed an invalid access to files array
this was found by Calum Hutton from Rapid7. It is a real bug, but
analysis shows it can't be leverged into an exploit. Worth fixing
though.

Many thanks to Calum and Rapid7 for finding and reporting this
2025-08-23 17:49:19 +10:00
Ronnie Sahlberg c2db921890 options.c: Fix segv if poptGetContext returns NULL
If poptGetContext returns NULL, perhaps due to OOM,
a NULL pointer is passed into poptReadDefaultConfig()
which in turns SEGVs when trying to dereference it.

This was found using https://github.com/sahlberg/malloc-fail-tester.git
$ ./test_malloc_failure.sh rsync -Pav crash crosh

Signed-off-by: Ronnie Sahlberg <ronniesahlberg@gmail.com>
2025-08-23 17:49:03 +10:00
Silent 77be09aaed syscall: fix a Y2038 bug by replacing Int32x32To64 with multiplication
Int32x32To64 macro internally truncates the arguments to int32,
while time_t is 64-bit on most/all modern platforms.
Therefore, usage of this macro creates a Year 2038 bug.
2025-08-23 17:32:11 +10:00
Jeremy Norris 0d0f615240 Ignore directory has vanished errors. 2025-08-23 17:31:52 +10:00
Max Kellermann b6457bbc83 make lots of global variables const
This way, they can live in `.rodata` and the compiler is allowed to do
certain optimizations.
2025-08-23 17:31:40 +10:00
Peter Eriksson 1807ce485a Fix handling of objects with many xattrs on FreeBSD 2025-08-23 17:31:28 +10:00
Rahul Mehta 9c175ac9ef chore: gitignore MacOS debug symbols 2025-08-23 17:31:12 +10:00
Emily a84b79ea58 Allow ls(1) to fail in test setup
This can happen when the tests are unable to `stat(2)` some files in
`/etc`, `/bin`, or `/`, due to Unix permissions or other sandboxing. We
still guard against serious errors, which use exit code 2.
2025-08-23 17:30:59 +10:00
fbuescher d4c4f6754e fixed remove multiple leading slashes 2025-08-23 17:14:43 +10:00
Michal Ruprich a4b926dcdc bool is a keyword in C23 2025-08-23 17:14:26 +10:00
Eli Schwartz 0973d0e380 configure.ac: check for xattr support both in libc and in -lattr
In 2015, the attr/xattr.h header was fully removed from upstream attr.

In 2020, rsync started preferring the standard header, if it exists:
https://github.com/RsyncProject/rsync/pull/22

But the fix was incomplete. We still looked for the getxattr function in
-lattr, and used it if -lattr exists. This was the case even if the
system libc was sufficient to provide the needed functions. Result:
overlinking to -lattr, if it happened to be installed for any other
reason.

```
checking whether to support extended attributes... Using Linux xattrs
checking for getxattr in -lattr... yes
```

Instead, use a different autoconf macro that first checks if the
function is available for use without any libraries (e.g. it is in
libc).

Result:

```
checking whether to support extended attributes... Using Linux xattrs
checking for library containing getxattr... none required
```

Signed-off-by: Eli Schwartz <eschwartz@gentoo.org>
2025-08-23 17:14:06 +10:00
Ethan Halsall e405cfc073 feat: add compress threads to man page 2025-08-23 17:13:49 +10:00
Ethan Halsall b78a841bb0 feat: validate compress threads option 2025-08-23 17:13:49 +10:00
Ethan Halsall f7a2b8a3fa feat: add threads to zstd compression 2025-08-23 17:13:49 +10:00
Arnaud Rebillout d941807915 Fix flaky hardlinks test
The test was added in dc34990, it turns out that it's flaky. It failed
once on the Debian build infra, cf. [1].

The problem is that the command `rsync -aH '$fromdir/sym' '$todir'`
updates the mod time of `$todir`, so there might be a diff between the
output of `rsync_ls_lR $fromdir` and `rsync_ls_lR $todir`, if ever rsync
runs 1 second (or more) after the directories were created.

To clarify: it's easy to make the test fails 100% of the times with this
change:

```
 makepath "$fromdir/sym" "$todir"
+sleep 5
 checkit "$RSYNC -aH '$fromdir/sym' '$todir'" "$fromdir" "$todir"
```

With the fix proposed here, we don't use `checkit` anymore, instead we
just run the rsync command, then a simple `diff` to compare the two
directories. This is exactly what the other `-H` test just above does.

In case there's some doubts, `diff` fails if `sym` is missing:

```
$ mkdir -p foo/sym bar
$ diff foo bar || echo KO!
Only in foo: sym
KO!
```

I tested that, after this commit, the test still catches the `-H`
regression in rsync 3.4.0.

Fixes: https://github.com/RsyncProject/rsync/issues/735

[1]: https://buildd.debian.org/status/fetch.php?pkg=rsync&arch=ppc64el&ver=3.4.1%2Bds1-1&stamp=1741147156&raw=0
2025-08-23 17:13:28 +10:00
Krzysztof Płocharz 992e10efaf Fix --open-noatime option not working on files
atime of source files could sometimes be overwritten
even though --open-noatime option was used.

To fix that, optional O_NOATIME flag was added
to do_open_nofollow which is also used to open regular
files since fix:
  "fixed symlink race condition in sender"
Previously optional O_NOATIME flag was only in do_open.
2025-08-23 17:13:09 +10:00
Chris Lamb 1c5ebdc4e5 Make the build reproducible
From https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1093201:
Whilst working on the Reproducible Builds effort [0], we noticed that
rsync could not be built reproducibly.

This is because the date in the manual page can vary depending on
whether there is a .git directory and the modification time of version.h
and Mafile, which might get modified when patching via quilt.

A patch is attached that makes this use SOURCE_DATE_EPOCH, which
will always be reliable.
2025-08-23 16:40:34 +10:00
Wayne Davison 9994933c8c Test on ubuntu-latest. 2025-02-11 13:37:12 -08:00
Alan Coopersmith 23d9ead5af popt: remove obsolete findme.c & findme.h
popt 1.14 merged these into popt.c but the import into rsync
missed removing them.

Fixes: https://github.com/RsyncProject/rsync/issues/710

Signed-off-by: Alan Coopersmith <alan.coopersmith@oracle.com>
2025-01-17 08:31:36 +11:00
Wayne Davison fcfdd36054 Update MAINTAINER_TZ_OFFSET on release.
This also fixes a string with \s that wasn't a r'...' string.
2025-01-15 23:27:27 -08:00
Wayne Davison 89b847393f Fix python deprecation warning. 2025-01-15 22:36:29 -08:00
Wayne Davison 788ecbe5ea Don't edit copyright year values anymore. 2025-01-15 22:30:32 -08:00
Wayne Davison 353506bc51 Improve interior dashes in long options.
Improve the backslash-adding code in md-convert to affect dashes in the
interior of long options.  Perhaps fixes #686.
2025-01-15 22:23:30 -08:00
Wayne Davison 7cff121ec8 Start 3.4.2dev going. 2025-01-15 22:01:42 -08:00
Andrew Tridgell 14f33837dc fixed build error on ia64 NonStop
it treats missing prototype as an error, not warning
2025-01-16 15:27:21 +11:00
Andrew Tridgell 3305a7a063 Preparing for release of 3.4.1 [buildall] 2025-01-16 07:49:23 +11:00
Andrew Tridgell 494879b819 update NEWS.md for 3.4.1 2025-01-16 07:47:07 +11:00
Andrew Tridgell 8d6da040e5 popt: remove dependency on alloca 2025-01-16 07:27:46 +11:00
Natanael Copa 68e9add76a Fix build on ancient glibc without openat(AT_FDCWD
Fixes: https://github.com/RsyncProject/rsync/issues/701
2025-01-16 06:43:57 +11:00
Rodrigo OSORIO dc34990b2e Test send a single directory with -H enabled
Ensure this still working after 3.4.0 breakage

https://github.com/RsyncProject/rsync/issues/702
2025-01-16 06:32:17 +11:00
Natanael Copa 81ead9e70c Fix use-after-free in generator
full_fname() will free the return value in the next call so we need to
duplicate it before passing it to rsyserr.

Fixes: https://github.com/RsyncProject/rsync/issues/704
2025-01-16 06:27:26 +11:00
Natanael Copa 996af4a79f Fix FLAG_GOT_DIR_FLIST collission with FLAG_HLINKED
fixes commit 688f5c379a (Refuse a duplicate dirlist.)

Fixes: https://github.com/RsyncProject/rsync/issues/702
Fixes: https://github.com/RsyncProject/rsync/issues/697
2025-01-16 06:21:54 +11:00
Andrew Tridgell dacadd53a9 update maintainer address
use rsync.project@gmail.com
2025-01-15 12:13:41 +11:00
Wayne Davison a6312e60c9 Force rsync group when uploading files. 2025-01-14 13:09:33 -08:00
664 changed files with 69872 additions and 8814 deletions

No files matched your search

+16
View File
@@ -1 +1,17 @@
* text=auto eol=lf
# The rsync-web/ subdirectory holds the project website source content
# (mirrors what gets pushed to https://rsync.samba.org). Exclude it from
# `git archive` output so the release source tarball produced by
# packaging/release.py step_7_tarball does not bloat with HTML the
# tarball doesn't need.
/rsync-web/ export-ignore
# old_versions/ holds static binaries of historical rsync releases, used by the
# version-mixing test suite (.github/workflows/ubuntu-version-mix.yml) to run
# the current code against a real old peer over the daemon / remote-shell.
# Mark the binaries as binary so the `text=auto eol=lf` rule above can't try to
# normalise line endings and corrupt them; export-ignore keeps them out of the
# release source tarball.
/old_versions/rsync_* binary
/old_versions/rsync_* export-ignore
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: RsyncProject
patreon: AndrewTridgell
+46
View File
@@ -0,0 +1,46 @@
name: Lint GitHub Actions workflows
# Static-check the workflow YAML with rhysd/actionlint. Catches missing
# secrets, bad expressions, expression-type errors, unsupported runner
# images, and (via embedded shellcheck) common pitfalls in `run:` scripts.
# Trigger only on changes under .github/workflows/ so the rest of the
# matrix isn't billed when nothing here moves.
on:
push:
branches: [ master ]
paths:
- '.github/workflows/*.yml'
- '.github/actionlint.yaml'
- '.github/actionlint.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths:
- '.github/workflows/*.yml'
- '.github/actionlint.yaml'
- '.github/actionlint.yml'
permissions:
contents: read
jobs:
actionlint:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: actionlint
steps:
- uses: actions/checkout@v4
- name: install actionlint
# Pin a version so this job is reproducible; bump deliberately.
# The download script verifies a SHA256 of the release tarball.
run: |
bash <(curl --proto '=https' --tlsv1.2 -fsSL \
https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) \
1.7.12
echo "$PWD" >>"$GITHUB_PATH"
- name: actionlint --version
run: actionlint -version
- name: actionlint .github/workflows/*.yml
run: actionlint -color
+86
View File
@@ -0,0 +1,86 @@
name: Test rsync on AlmaLinux 8
# Older-LTS coverage on the Fedora/RHEL family to help with backporting
# security fixes. AlmaLinux 8 is the RHEL 8 rebuild and is the oldest
# active LTS in this family (RHEL 8 full support runs to 2029).
# GitHub Actions has no native runner for this family, so the job runs
# inside an almalinux:8 container hosted on ubuntu-latest.
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/almalinux-8-build.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/almalinux-8-build.yml'
schedule:
- cron: '42 8 * * *'
jobs:
test:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
container:
image: almalinux:8
name: Test rsync on AlmaLinux 8
steps:
- name: install git
# actions/checkout needs git in the container before the checkout step.
run: dnf -y install git
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
# PowerTools is needed for libzstd-devel etc; xxhash and lz4 dev
# headers live in EPEL on RHEL 8. The default python3 on RHEL 8
# is 3.6, which is too old for runtests.py (uses capture_output=
# / text= introduced in 3.7), so install python39 and point
# /usr/bin/python3 at it.
run: |
dnf -y install epel-release
dnf config-manager --set-enabled powertools
dnf -y install gcc gcc-c++ make autoconf automake m4 \
python39 python39-pip diffutils \
openssl openssl-devel \
attr libattr-devel acl libacl-devel \
zstd libzstd-devel \
lz4 lz4-devel \
xxhash xxhash-devel
alternatives --set python3 /usr/bin/python3.9
pip3 install commonmark
- name: configure
run: ./configure --with-rrsync
- name: make
run: make
- name: info
run: ./rsync --version
- name: check
# In the container we already run as root, so no sudo. The
# crtimes-not-supported skip matches the other Linux jobs;
# daemon-chroot-acl and proxy-response-line-too-long skip because
# the default (secure) transport opens no listening socket.
run: RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check
- name: check (TCP daemon transport)
# Second run exercising the real loopback-TCP daemon path.
run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8
- name: ssl file list
run: ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true
- name: save artifact
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: almalinux-8-bin
path: |
rsync
rsync-ssl
rsync.1
rsync-ssl.1
rsyncd.conf.5
rrsync.1
rrsync
+124
View File
@@ -0,0 +1,124 @@
name: Build static rsync for Android
# Cross-compiles statically-linked rsync binaries with the Android NDK,
# suitable for dropping onto a phone (adb push / Termux) with no shared
# libraries. arm64-v8a covers all modern phones; armeabi-v7a covers older
# 32-bit devices. The binaries are uploaded as workflow artifacts.
#
# These are cross-compiled, so the test suite can't run here; we sanity
# check that each binary is the right architecture, is static, and that
# it executes (`--version`) under qemu-user.
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/android-static-build.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/android-static-build.yml'
schedule:
- cron: '42 8 * * 1'
workflow_dispatch:
env:
# Minimum supported API level. 24 (Android 7.0) runs on every modern
# phone while keeping broad reach; bump if you need newer Bionic APIs.
ANDROID_API: 24
jobs:
build:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: ${{ matrix.abi }}
strategy:
fail-fast: false
matrix:
include:
- abi: arm64-v8a # modern phones
triple: aarch64-linux-android
qemu: qemu-aarch64-static
- abi: armeabi-v7a # older 32-bit phones
triple: armv7a-linux-androideabi
qemu: qemu-arm-static
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install build prerequisites
run: sudo apt-get update && sudo apt-get install -y autoconf automake gawk qemu-user-static
- name: Configure and build (${{ matrix.abi }})
shell: bash
run: |
set -euo pipefail
NDK="${ANDROID_NDK_LATEST_HOME:-$ANDROID_NDK_ROOT}"
TC="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
export CC="$TC/${{ matrix.triple }}${ANDROID_API}-clang"
export AR="$TC/llvm-ar" RANLIB="$TC/llvm-ranlib" STRIP="$TC/llvm-strip"
export CFLAGS="-O2" LDFLAGS="-static"
# Bionic doesn't declare lchmod()/lutimes() until API 36, but the
# symbols link, so configure mis-detects them -- force them off so
# rsync uses its fallbacks. The other cache vars restore values
# that configure can't probe when cross-compiling (Android runs a
# normal Linux kernel, so these match the native Linux result).
export ac_cv_func_lchmod=no ac_cv_func_lutimes=no \
rsync_cv_HAVE_SOCKETPAIR=yes \
rsync_cv_MKNOD_CREATES_FIFOS=yes \
rsync_cv_MKNOD_CREATES_SOCKETS=yes
# Self-contained build: drop optional external libraries so the
# static binary needs nothing at runtime. rsync keeps md5/md4
# checksums and its bundled zlib.
./configure --host=${{ matrix.triple }} --build=x86_64-pc-linux-gnu \
--enable-ipv6 \
--disable-zstd --disable-lz4 --disable-xxhash --disable-openssl \
--disable-iconv --disable-iconv-open \
--disable-acl-support --disable-xattr-support \
--disable-md2man --disable-roll-simd \
--with-included-popt --with-included-zlib
# Generate the awk-built headers serially first so the parallel
# build can't race on proto.h <- daemon-parm.h.
make proto.h
make -j"$(nproc)" rsync
"$STRIP" rsync
- name: Verify binary
shell: bash
run: |
set -euo pipefail
file rsync
# Gate: must be a statically-linked executable (no interpreter).
file rsync | grep -q "statically linked"
if file rsync | grep -q "dynamically linked"; then
echo "ERROR: binary is not static" >&2; exit 1
fi
# Best-effort: confirm it actually runs under qemu-user.
${{ matrix.qemu }} ./rsync --version | head -3 || \
echo "WARNING: qemu smoke test did not run cleanly (check on a real device)"
- name: Package
shell: bash
run: |
set -euo pipefail
VER=$(sed -n 's/.*RSYNC_VERSION "\([^"]*\)".*/\1/p' version.h)
out="rsync-${VER}-android-${{ matrix.abi }}"
mkdir -p dist
cp rsync "dist/$out"
( cd dist && sha256sum "$out" > "$out.sha256" )
echo "ARTIFACT_NAME=rsync-android-${{ matrix.abi }}" >>"$GITHUB_ENV"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: ${{ env.ARTIFACT_NAME }}
path: dist/
+75
View File
@@ -0,0 +1,75 @@
name: rsync ASan+UBSan (clang)
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/asan-build.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/asan-build.yml'
schedule:
# Weekly (Mon 09:42 UTC): catch breakage from a moving ubuntu-latest/clang
# toolchain (a new clang can add a UBSan check, or change ASan behaviour)
# that no code push would otherwise trigger. Push/PR already gate every
# code change, so daily would just re-run an unchanged tree.
- cron: '42 9 * * 1'
workflow_dispatch:
jobs:
asan:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: rsync ASan+UBSan (clang)
env:
# rsync intentionally leaks small allocations at process exit, so leak
# detection would be all noise; chase only memory-safety errors.
ASAN_OPTIONS: detect_leaks=0:abort_on_error=1
# UBSan is a gate: -fno-sanitize-recover=undefined (below) aborts on the
# first finding and halt_on_error=1 makes that fatal, so any undefined
# behaviour fails the run. This needs the tree to be UBSan-clean: the
# remaining findings are fixed in code (hashtable/mdfour shifts, xattrs,
# and log.c's file_struct, kept aligned via rounding.h); only byteorder.h's
# intentional unaligned accessors are suppressed, with no_sanitize.
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get update
sudo apt-get install -y clang acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev openssl
echo "/usr/local/bin" >>"$GITHUB_PATH"
- name: configure
# -DNDEBUG builds as a shipped release does (assert() compiled out), so
# AddressSanitizer catches the over-reads/over-writes that an "assert()
# instead of a real bounds check" bug would cause in a production build.
# UBSan rides along on the same build; -fno-sanitize-recover=undefined
# makes any undefined behaviour abort (and thus fail the run) instead of
# merely printing it.
run: |
CC=clang \
CFLAGS="-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer -g -O1 -DNDEBUG" \
LDFLAGS="-fsanitize=address,undefined" \
./configure --with-rrsync --disable-md2man --enable-strict-confinement
- name: make
# check-progs builds rsync plus the test helper programs (tls, trimslash,
# t_unsafe, ...) that runtests.py requires; plain "make" builds only rsync
# and runtests aborts on the missing helpers.
run: make check-progs
- name: info
run: ./rsync --version
- name: check (stdio-pipe transport)
# ASan+UBSan-instrumented coverage of the transfer, daemon, sender,
# receiver and metadata paths over the default stdio-pipe transport.
run: ./runtests.py --rsync-bin="$PWD/rsync" -j8
- name: check (TCP daemon transport)
# --use-tcp also exercises the loopback rsyncd listener and the client's
# TCP connection path.
run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j8
+75
View File
@@ -0,0 +1,75 @@
name: Coverage (Ubuntu)
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/coverage.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/coverage.yml'
schedule:
- cron: '42 9 * * 1'
workflow_dispatch:
jobs:
coverage:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: gcov coverage
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get update
sudo apt-get install -y acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl gcovr
echo "/usr/local/bin" >>"$GITHUB_PATH"
- name: configure
run: ./configure --enable-coverage --with-rrsync
- name: make
run: make
- name: info
run: rsync --version
# Two coverage runs: the default pipe transport, then a second pass over a
# real loopback rsyncd (--use-tcp) which also exercises the require_tcp-only
# tests. gcovr's --print-summary line/branch/decision totals go to the step
# log (and the job summary below), so the numbers are visible in CI.
# `make coverage` exits with the suite's status, so a regression fails CI.
- name: coverage (pipe transport)
run: |
set -o pipefail
sudo make coverage 2>&1 | tee cov-pipe.log
- name: coverage (TCP transport)
run: |
set -o pipefail
sudo make coverage-tcp 2>&1 | tee cov-tcp.log
- name: coverage summary
if: always()
run: |
{
echo "## gcov coverage"
echo "### Pipe transport (\`make coverage\`)"
echo '```'
grep -E '^(lines|functions|branches|decisions):' cov-pipe.log || echo '(no summary -- see step log)'
echo '```'
echo "### TCP transport (\`make coverage-tcp\`)"
echo '```'
grep -E '^(lines|functions|branches|decisions):' cov-tcp.log || echo '(no summary -- see step log)'
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: upload HTML reports
if: always()
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: coverage-html
path: |
coverage
coverage-tcp
+15 -2
View File
@@ -7,7 +7,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/cygwin-build.yml'
pull_request:
branches: [ master ]
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/cygwin-build.yml'
@@ -16,6 +16,9 @@ on:
jobs:
test:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: windows-2022
name: Test rsync on Cygwin
steps:
@@ -39,12 +42,22 @@ jobs:
- name: info
run: bash -c '/usr/local/bin/rsync --version'
- name: check
run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls,chown,devices,dir-sgid,protected-regular make check'
# chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on Cygwin
# (rsyncfns.py drives xattrs via getfattr/setfattr from the `attr`
# package installed above), verified on a real Cygwin host. The real
# chown/devices tests still skip (need root/mknod), as do the
# RESOLVE_BENEATH symlink-race tests.
run: bash -c 'RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/cygwin.txt make check'
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd; the default
# 'make check' above uses the secure stdio-pipe transport.
run: bash -c './runtests.py --rsync-bin=`pwd`/rsync.exe --use-tcp -j 8'
- name: ssl file list
run: bash -c 'PATH="/usr/local/bin:$PATH" rsync-ssl --no-motd download.samba.org::rsyncftp/ || true'
- name: save artifact
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: cygwin-bin
path: |
rsync.exe
+73
View File
@@ -0,0 +1,73 @@
name: Test fleettest harness
# Bitrot check for testsuite/fleettest.py (the developer fleet CI harness).
# fleettest is meant to be run by developers on a modern Ubuntu box, so this
# job runs only on ubuntu-latest: it stands up a one-host "fleet" of two
# targets that both ssh to localhost and runs a real fleettest pass against it.
# It does not run on the BSD/Solaris/macOS/Cygwin matrix.
on:
push:
branches: [ master ]
paths:
- 'testsuite/fleettest.py'
- '.github/workflows/fleettest.yml'
- 'runtests.py'
- 'testsuite/skiplist/**'
- 'testsuite/skiplist-spec_test.py'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths:
- 'testsuite/fleettest.py'
- '.github/workflows/fleettest.yml'
- 'runtests.py'
- 'testsuite/skiplist/**'
- 'testsuite/skiplist-spec_test.py'
workflow_dispatch:
schedule:
- cron: '17 7 * * 1'
jobs:
fleettest:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: fleettest against localhost
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get update
sudo apt-get install -y gcc g++ gawk autoconf automake \
acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev \
python3-cmarkgfm openssl rsync openssh-server
- name: set up ssh to localhost
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
sudo systemctl start ssh || sudo service ssh start
# fleettest connects with `ssh -o BatchMode=yes localhost`, which won't
# answer a host-key prompt -- so pre-trust localhost in known_hosts.
ssh-keyscan -H localhost 127.0.0.1 >> ~/.ssh/known_hosts 2>/dev/null
ssh -o BatchMode=yes -o ConnectTimeout=15 localhost 'echo ssh-to-localhost-ok'
- name: write localhost fleet config
run: |
cat > fleettest-ci.json <<'EOF'
{ "targets": [
{ "name": "local-a", "ssh_host": "localhost", "workflow": "none.yml",
"configure_flags": [], "builddir": "rsync-citest-a", "privilege": "sudo" },
{ "name": "local-b", "ssh_host": "localhost", "workflow": "none.yml",
"configure_flags": [], "builddir": "rsync-citest-b", "privilege": "sudo" }
] }
EOF
- name: fleettest --list (config sanity)
run: python3 testsuite/fleettest.py --fleet fleettest-ci.json --list
- name: run fleettest against localhost
# Two targets both on localhost exercise the parallel multi-target path
# and the per-run dir / port isolation; exit 0 iff every cell is OK.
run: python3 testsuite/fleettest.py --fleet fleettest-ci.json --timing
+8 -2
View File
@@ -7,15 +7,18 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/freebsd-build.yml'
pull_request:
branches: [ master ]
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/freebsd-build.yml'
schedule:
- cron: '42 8 * * *'
- cron: '42 8 * * 1'
jobs:
test:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: Test rsync on FreeBSD
steps:
@@ -34,10 +37,13 @@ jobs:
./configure --with-rrsync -disable-zstd --disable-md2man --disable-xxhash --disable-lz4
make
./rsync --version
make check
./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8
./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true
- name: save artifact
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: freebsd-bin
path: |
rsync
+22 -6
View File
@@ -7,7 +7,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/macos-build.yml'
pull_request:
branches: [ master ]
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/macos-build.yml'
@@ -16,6 +16,9 @@ on:
jobs:
test:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: macos-latest
name: Test rsync on macOS
steps:
@@ -25,10 +28,15 @@ jobs:
- name: prep
run: |
brew install automake openssl xxhash zstd lz4
sudo pip3 install commonmark
echo "/usr/local/bin" >>$GITHUB_PATH
pip3 install --user --break-system-packages commonmark
echo "$(brew --prefix)/bin" >>"$GITHUB_PATH"
- name: configure
run: CPPFLAGS=-I/usr/local/opt/openssl/include/ LDFLAGS=-L/usr/local/opt/openssl/lib/ ./configure --with-rrsync
run: |
BREW_PREFIX=$(brew --prefix)
OPENSSL_PREFIX=$(brew --prefix openssl)
CPPFLAGS="-I${BREW_PREFIX}/include -I${OPENSSL_PREFIX}/include" \
LDFLAGS="-L${BREW_PREFIX}/lib -L${OPENSSL_PREFIX}/lib" \
./configure --with-rrsync
- name: make
run: make
- name: install
@@ -36,12 +44,20 @@ jobs:
- name: info
run: rsync --version
- name: check
run: sudo RSYNC_EXPECT_SKIPPED=acls-default,chmod-temp-dir,chown-fake,devices-fake,dir-sgid,protected-regular,xattrs-hlink,xattrs make check
# chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on macOS
# (rsyncfns.py drives xattrs via the `xattr` command), verified on a
# real macOS host, so they're no longer in the skip set.
run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/macos.txt make check
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd; the default
# 'make check' above uses the secure stdio-pipe transport.
run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8
- name: ssl file list
run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true
- name: save artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: macos-bin
path: |
rsync
+56
View File
@@ -0,0 +1,56 @@
name: Test rsync on NetBSD
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/netbsd-build.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/netbsd-build.yml'
schedule:
- cron: '42 8 * * 1'
jobs:
test:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: Test rsync on NetBSD
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Test in NetBSD VM
id: test
uses: vmactions/netbsd-vm@v1
with:
usesh: true
prepare: |
PATH=/usr/sbin:$PATH pkg_add autoconf automake python312
ln -sf /usr/pkg/bin/python3.12 /usr/pkg/bin/python3
run: |
uname -a
./configure --with-rrsync --disable-zstd --disable-md2man --disable-xxhash --disable-lz4
make
./rsync --version
make check
./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8
./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true
- name: save artifact
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: netbsd-bin
path: |
rsync
rsync-ssl
rsync.1
rsync-ssl.1
rsyncd.conf.5
rrsync.1
rrsync
+72
View File
@@ -0,0 +1,72 @@
name: Test rsync on OpenBSD
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/openbsd-build.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/openbsd-build.yml'
schedule:
- cron: '42 8 * * 1'
jobs:
test:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: Test rsync on OpenBSD
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Test in OpenBSD VM
id: test
uses: vmactions/openbsd-vm@v1
with:
usesh: true
prepare: |
pkg_add -I bash autoconf%2.71 automake%1.16
run: |
uname -a
export AUTOCONF_VERSION=2.71
export AUTOMAKE_VERSION=1.16
./configure --with-rrsync --disable-zstd --disable-md2man --disable-xxhash --disable-lz4
make
./rsync --version
# The flipper (symlink-race) tests are excluded on OpenBSD, as on the
# fleet's OpenBSD box: this kernel has a connect()-under-rename-load
# lost-wakeup and an FFS rename-storm corruption that hang them to
# the 300s timeout for non-rsync reasons (see
# dev-notes/openbsd-connect-lost-wakeup-report.txt); the protections
# they exercise are verified on the Linux/BSD boxes.
export RSYNC_EXCLUDE=acl-symlink-race,sender-readlink-atfd,sender-remove-source-secure
make check
# The --use-tcp daemon tests run at -j2 here (vs -j8 elsewhere): this
# job runs inside a nested VM, and at -j8 the many concurrent loopback
# daemons occasionally lose a connection-handshake timing race under
# that resource pressure, hanging one test to the 300s timeout. It is
# an environment artifact, not an rsync bug (the handshake is
# deadlock-free and unreproducible elsewhere, even pinned to 1 CPU at
# -j8); -j2 keeps the VM from over-subscribing. The pipe `make check`
# above stays at the default parallelism.
./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 2
./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true
- name: save artifact
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: openbsd-bin
path: |
rsync
rsync-ssl
rsync.1
rsync-ssl.1
rsyncd.conf.5
rrsync.1
rrsync
+94
View File
@@ -0,0 +1,94 @@
name: rsync scan-build (clang analyzer)
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/scan-build.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/scan-build.yml'
workflow_dispatch:
jobs:
# GATING run: pinned clang-18 on a pinned runner so the checker set -- and
# thus the expected zero -- is deterministic. The tree is kept clean for
# clang-18, so --status-bugs (non-zero exit on any report) fails the build
# when a new finding appears. Pin both the analyzer (clang-18/clang-tools-18)
# and the runner (ubuntu-24.04, whose apt repos carry those packages).
gate-clang18:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-24.04
name: scan-build gate (clang-18, pinned)
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get update
sudo apt-get install -y clang-18 clang-tools-18 acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libpopt-dev openssl
- name: configure (under scan-build)
# Run configure under scan-build so its analyzer compiler-wrapper is baked
# into the Makefile's $(CC); --disable-md2man avoids the doc toolchain.
run: scan-build-18 ./configure --with-rrsync --disable-md2man
- name: scan-build (gating)
# --status-bugs makes scan-build exit non-zero if it finds ANY report.
# pipefail + 'exit $status' propagate that through the tee so the job goes
# red while still printing the summary; the report uploads for triage.
run: |
set -o pipefail
status=0
scan-build-18 --status-bugs -o "$PWD/scan-report" make check-progs -j"$(nproc)" 2>&1 | tee scan-build.out || status=$?
echo '## scan-build gate (clang-18)' >>"$GITHUB_STEP_SUMMARY"
grep -E 'scan-build: .* bugs? found|scan-build: No bugs found' scan-build.out >>"$GITHUB_STEP_SUMMARY" || true
exit $status
- name: upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: scan-build-report-clang18
path: scan-report
if-no-files-found: ignore
# INFORMATIONAL run: whatever clang ubuntu-latest currently ships. Newer
# clang releases enable extra, FP-heavy checkers (e.g. unix.Chroot
# "no chdir after chroot", alpha.unix.Stream) that the gate deliberately
# avoids, so this is NOT a gate (no --status-bugs). It surfaces what the
# newest analyzer sees -- useful for spotting genuine new findings before a
# gate bump -- without blocking merges. continue-on-error keeps a noisy or
# broken run from affecting the workflow's required status.
informational-latest:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: scan-build (latest clang, informational)
continue-on-error: true
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get update
sudo apt-get install -y clang clang-tools acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libpopt-dev openssl
- name: configure (under scan-build)
run: scan-build ./configure --with-rrsync --disable-md2man
- name: scan-build (informational)
run: |
scan-build -o "$PWD/scan-report" make check-progs -j"$(nproc)" 2>&1 | tee scan-build.out
echo '## scan-build informational (latest clang)' >>"$GITHUB_STEP_SUMMARY"
grep -E 'scan-build: .* bugs? found|scan-build: No bugs found' scan-build.out >>"$GITHUB_STEP_SUMMARY" || true
- name: upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: scan-build-report-latest
path: scan-report
if-no-files-found: ignore
+8 -2
View File
@@ -7,15 +7,18 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/solaris-build.yml'
pull_request:
branches: [ master ]
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/solaris-build.yml'
schedule:
- cron: '42 8 * * *'
- cron: '42 8 * * 1'
jobs:
test:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: Test rsync on Solaris
steps:
@@ -34,10 +37,13 @@ jobs:
./configure --with-rrsync -disable-zstd --disable-md2man --disable-xxhash --disable-lz4
make
./rsync --version
make check
./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8
./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true
- name: save artifact
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: solaris-bin
path: |
rsync
+68
View File
@@ -0,0 +1,68 @@
name: Test rsync on Ubuntu 22.04
# Older-LTS coverage to help with backporting security fixes. ubuntu-22.04
# is currently the oldest GitHub Actions runner image (20.04 was retired
# in April 2025).
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/ubuntu-22.04-build.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/ubuntu-22.04-build.yml'
schedule:
- cron: '42 8 * * *'
jobs:
test:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-22.04
name: Test rsync on Ubuntu 22.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl
echo "/usr/local/bin" >>"$GITHUB_PATH"
- name: configure
run: ./configure --with-rrsync
- name: make
run: make
- name: install
run: sudo make install
- name: info
run: rsync --version
- name: check
run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check
- name: check30
run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check30
- name: check29
run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto29.txt make check29
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd; the default
# 'make check' above uses the secure stdio-pipe transport.
run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8
- name: ssl file list
run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true
- name: save artifact
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: ubuntu-22.04-bin
path: |
rsync
rsync-ssl
rsync.1
rsync-ssl.1
rsyncd.conf.5
rrsync.1
rrsync
+32 -6
View File
@@ -7,7 +7,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/ubuntu-build.yml'
pull_request:
branches: [ master ]
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/ubuntu-build.yml'
@@ -16,7 +16,10 @@ on:
jobs:
test:
runs-on: ubuntu-20.04
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: Test rsync on Ubuntu
steps:
- uses: actions/checkout@v4
@@ -25,7 +28,7 @@ jobs:
- name: prep
run: |
sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl
echo "/usr/local/bin" >>$GITHUB_PATH
echo "/usr/local/bin" >>"$GITHUB_PATH"
- name: configure
run: ./configure --with-rrsync
- name: make
@@ -35,16 +38,39 @@ jobs:
- name: info
run: rsync --version
- name: check
run: sudo RSYNC_EXPECT_SKIPPED=crtimes make check
run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check
- name: check30
run: sudo RSYNC_EXPECT_SKIPPED=crtimes make check30
run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check30
- name: check29
run: sudo RSYNC_EXPECT_SKIPPED=crtimes make check29
run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto29.txt make check29
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd. The default
# 'make check' above uses the secure stdio-pipe transport (no listening
# sockets); this run exercises the real TCP accept/auth path. Skip-set
# is env-dependent here (chroot-acl), so leave RSYNC_EXPECT_SKIPPED unset.
run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8
- name: check (non-root, targeted)
# Every run above is root (sudo), so privilege-sensitive tests never hit
# their non-root path. Run those here as the unprivileged 'runner' user
# (NO sudo). Explicit test names make runtests.py full_run False, so
# RSYNC_EXPECT_SKIPPED is bypassed -- no per-platform skip list needed.
# daemon-namecvt-empty-response -- REQUIRES non-root (skips as root by
# design); the only test with no other CI coverage (Benjamin #2).
# ownership-depth -- non-root takes the group-only remap path.
# daemon -- non-root takes the default-config path.
# CONVENTION: a new test that requires/meaningfully exercises a non-root
# path must be added to the list below (kept in sync with the fleet
# harness's nonroot_tests).
run: |
sudo rm -rf testtmp # prior root steps left it root-owned
./runtests.py --rsync-bin="$PWD/rsync" \
daemon-namecvt-empty-response ownership-depth daemon
- name: ssl file list
run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true
- name: save artifact
uses: actions/upload-artifact@v4
with:
retention-days: 45
name: ubuntu-bin
path: |
rsync
+80
View File
@@ -0,0 +1,80 @@
name: Test rsync version mixing on Ubuntu
# Runs the CURRENT test suite with two different rsync binaries: the freshly
# built ./rsync as the client/driver, and a committed OLD static binary
# (old_versions/rsync_<ver>) as the daemon / remote-shell peer. This exercises
# real version mixing over the wire -- more convincing than --protocol forcing,
# which only makes the current binary speak an old protocol.
#
# Direction is fixed: the current binary always drives (only it understands the
# new test scripts); the old binary is only ever the server/daemon side. The
# reverse (old client driving new scripts) is not possible -- but one test,
# reverse-daemon-delta, swaps the roles internally (current build as the daemon,
# old binary as the client) to cover the backward-compat direction: a current
# daemon serving the installed base of old clients.
#
# The per-version manifest testsuite/expect/rsync_<ver>.expect lists exactly
# which tests run and each one's expected outcome (pass/skip/fail/xfail), so an
# old peer's known feature gaps are recorded rather than treated as breakage.
#
# All peers run in a SINGLE job (looped, not a matrix) so the PR shows one check
# line rather than one per version. Each peer/transport is a foldable ::group::
# in the log, and a failure annotates which peer/transport broke.
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/ubuntu-version-mix.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/ubuntu-version-mix.yml'
schedule:
- cron: '52 8 * * 1'
jobs:
version-mix:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
name: rsync version-mix
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl
echo "/usr/local/bin" >>"$GITHUB_PATH"
- name: configure
run: ./configure --with-rrsync
- name: make
# check-progs builds rsync AND the test helper programs (tls, trimslash,
# t_unsafe, ...) that runtests.py requires; plain `make` does not.
run: make check-progs
- name: info
run: ./rsync --version | head -1
- name: version mixing (all peers, pipe + TCP transports)
run: |
rc=0
for peer in old_versions/rsync_*; do
chmod +x "$peer"
name=$(basename "$peer")
expect="testsuite/expect/$name.expect"
for transport in pipe tcp; do
tcp=()
[ "$transport" = tcp ] && tcp=(--use-tcp)
echo "::group::$name ($transport): $("$peer" --version | head -1)"
if ! ./runtests.py --rsync-bin="$PWD/rsync" --rsync-bin2="$PWD/$peer" \
--expect-result "$expect" "${tcp[@]}" -j 8; then
echo "::error::version-mix failed: $name ($transport)"
rc=1
fi
echo "::endgroup::"
done
done
exit $rc
+99
View File
@@ -0,0 +1,99 @@
name: Valgrind memcheck
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/valgrind.yml'
pull_request:
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/valgrind.yml'
schedule:
- cron: '17 4 * * *'
workflow_dispatch:
jobs:
memcheck:
# temporary gate: PR CI runs only for PRs labeled 'run-ci', to save CI
# minutes; labels need triage access, so fork PRs can't self-enable.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
privilege: [ user, root ]
transport: [ pipe, tcp ]
name: memcheck (${{ matrix.privilege }}, ${{ matrix.transport }})
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get update
sudo apt-get install -y valgrind acl libacl1-dev attr libattr1-dev \
liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl
echo "/usr/local/bin" >>"$GITHUB_PATH"
- name: configure
run: ./configure --with-rrsync --enable-debug
- name: make
run: make check-progs # builds rsync + the test helper programs runtests.py needs
- name: info
run: ./rsync --version
# Run the whole suite under valgrind. We gate on memory *errors* (uninit
# reads, invalid read/write, bad frees, uninit syscall params), not leaks:
# rsync deliberately leaves file-list/socket/option memory unfreed at exit
# (short-lived process; the OS reclaims), so --leak-check=no avoids a sea of
# by-design "definitely lost" reports. Functional pass/fail is covered by
# the other workflows, so the suite is allowed to finish regardless of
# per-test results; the scan step below is the gate. --error-exitcode=0
# keeps valgrind from perturbing test exit codes; the bundled
# testsuite/valgrind.supp silences known-benign reports.
- name: run testsuite under valgrind
run: |
SUDO=
[ "${{ matrix.privilege }}" = root ] && SUDO="sudo -E"
TCP=
[ "${{ matrix.transport }}" = tcp ] && TCP="--use-tcp"
$SUDO ./runtests.py --valgrind \
--valgrind-opts="--leak-check=no --error-exitcode=0" \
$TCP -j8 --preserve-scratch || true
- name: scan for unsuppressed valgrind errors
run: |
sudo chown -R "$USER" testtmp 2>/dev/null || true
mapfile -t logs < <(find testtmp -name 'valgrind.*.log' 2>/dev/null)
if [ "${#logs[@]}" -eq 0 ]; then
echo "::error::no valgrind logs were produced -- the suite did not run"
exit 1
fi
echo "scanned ${#logs[@]} valgrind log(s)"
bad=()
for f in "${logs[@]}"; do
grep -qE 'ERROR SUMMARY: [1-9][0-9]* errors' "$f" && bad+=("$f")
done
if [ "${#bad[@]}" -ne 0 ]; then
echo "::error::valgrind reported unsuppressed errors in ${#bad[@]} run(s)"
for f in "${bad[@]}"; do
echo "===== $f ====="
sed 's/==[0-9]*== //' "$f" | grep -A18 \
-E 'depends on uninitialised|points to uninitialised|Invalid (read|write|free)|lost in loss record|Mismatched free' \
| head -60
done
exit 1
fi
echo "valgrind clean: no unsuppressed errors"
- name: upload valgrind logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: valgrind-logs-${{ matrix.privilege }}-${{ matrix.transport }}
path: testtmp/**/valgrind.*.log
if-no-files-found: ignore
retention-days: 7
+14
View File
@@ -43,8 +43,19 @@ aclocal.m4
/testrun
/trimslash
/t_unsafe
/t_acl
/t_chmod_secure
/t_rename_secure
/t_secure_relpath
/t_symlink_secure
/t_hashtable_overflow
/t_iwildmatch
/t_clean_fname
/t_safe_arg
/simdtest
/wildtest
/getfsdev
/t_safe_arg_main
/rounding.h
/doc/rsync.pdf
/doc/rsync.ps
@@ -52,9 +63,12 @@ aclocal.m4
/testsuite/chown-fake.test
/testsuite/devices-fake.test
/testsuite/xattrs-hlink.test
/testsuite/fleettest.json
/fleettest-logs
/patches
/patches.gen
/build
/auto-build-save
.deps
/*.exe
*.dSYM/
+28
View File
@@ -7,6 +7,34 @@ option to use if you want to just skip that feature. What follows are various
support libraries that you may want to install to build rsync with the maximum
features (the impatient can skip down to the package summary):
## Ubuntu users: skip the build, use the PPA
If you are on a currently supported Ubuntu series (jammy 22.04 LTS, noble
24.04 LTS, questing 25.10, resolute 26.04 LTS) and just want the latest
upstream rsync, the rsync project maintains a Launchpad PPA that tracks
stable releases:
> sudo add-apt-repository ppa:rsyncproject/rsync
> sudo apt update && sudo apt install rsync
See [the PPA page][ppa] for current build status across architectures.
[ppa]: https://launchpad.net/~rsyncproject/+archive/ubuntu/rsync
To test the upcoming release instead, there is also a [`rsync-latest`
PPA][ppa-latest] that is rebuilt from the tip of the git master branch. These
are development snapshots whose version numbers (such as
`3.5.0~git20260601...`) deliberately sort below the matching stable release, so
the stable PPA above will never silently move you from a release onto a
snapshot. Use it for testing only -- it may contain unreleased changes:
> sudo add-apt-repository ppa:rsyncproject/rsync-latest
> sudo apt update && sudo apt install rsync
[ppa-latest]: https://launchpad.net/~rsyncproject/+archive/ubuntu/rsync-latest
The rest of this document covers building from source.
## The basic setup
You need to have a C compiler installed and optionally a C++ compiler in order
+280 -30
View File
@@ -18,6 +18,9 @@ CXXFLAGS=@CXXFLAGS@
EXEEXT=@EXEEXT@
LDFLAGS=@LDFLAGS@
LIBOBJDIR=lib/
AR=@AR@
ARFLAGS=cr
RANLIB=@RANLIB@
INSTALLCMD=@INSTALL@
INSTALLMAN=@INSTALL@
@@ -38,31 +41,37 @@ GENFILES=configure.sh aclocal.m4 config.h.in rsync.1 rsync.1.html \
rsync-ssl.1 rsync-ssl.1.html rsyncd.conf.5 rsyncd.conf.5.html \
@GEN_RRSYNC@
HEADERS=byteorder.h config.h errcode.h proto.h rsync.h ifuncs.h itypes.h inums.h \
lib/pool_alloc.h lib/mdigest.h lib/md-defines.h
lib/pool_alloc.h lib/mdigest.h lib/md-defines.h vfs/vfs.h
LIBOBJ=lib/wildmatch.o lib/compat.o lib/snprintf.o lib/mdfour.o lib/md5.o \
lib/permstring.o lib/pool_alloc.o lib/sysacls.o lib/sysxattrs.o @LIBOBJS@
lib/permstring.o lib/pool_alloc.o lib/sysacls.o lib/sysxattrs.o lib/acl.o @LIBOBJS@
zlib_OBJS=zlib/deflate.o zlib/inffast.o zlib/inflate.o zlib/inftrees.o \
zlib/trees.o zlib/zutil.o zlib/adler32.o zlib/compress.o zlib/crc32.o
OBJS1=flist.o rsync.o generator.o receiver.o cleanup.o sender.o exclude.o \
util1.o util2.o main.o checksum.o match.o syscall.o log.o backup.o delete.o
OBJS1_NO_MAIN=flist.o rsync.o generator.o receiver.o cleanup.o sender.o exclude.o \
util1.o util2.o checksum.o match.o log.o backup.o delete.o
OBJS1=$(OBJS1_NO_MAIN) main.o
OBJS2=options.o io.o compat.o hlink.o token.o uidlist.o socket.o hashtable.o \
usage.o fileio.o batch.o clientname.o chmod.o acls.o xattrs.o
OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@
DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o
popt_OBJS=popt/findme.o popt/popt.o popt/poptconfig.o \
popt_OBJS= popt/popt.o popt/poptconfig.o \
popt/popthelp.o popt/poptparse.o popt/poptint.o
OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@
VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o vfs/times.o vfs/fileio.o vfs/make_path.o vfs/copy_file.o vfs/robust.o
OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a
TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@
TLS_OBJ = tls.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a
# Programs we must have to run the test cases
CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \
testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) wildtest$(EXEEXT)
testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \
t_rename_secure$(EXEEXT) t_symlink_secure$(EXEEXT) t_secure_relpath$(EXEEXT) t_acl$(EXEEXT) t_hashtable_overflow$(EXEEXT) t_iwildmatch$(EXEEXT) t_clean_fname$(EXEEXT) t_safe_arg$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT)
CHECK_SYMLINKS = testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test
CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \
testsuite/xattrs-hlink_test.py testsuite/exclude-lsh_test.py
# Objects for CHECK_PROGS to clean
CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o trimslash.o wildtest.o
CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o
# Compile-only feature-shape checks.
CHECK_COMPILE_OBJS=vfs-no-at-fdcwd.o
# note that the -I. is needed to handle config.h when using VPATH
.c.o:
@@ -75,6 +84,21 @@ CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o trimslash.
all: Makefile rsync$(EXEEXT) stunnel-rsyncd.conf @MAKE_RRSYNC@ @MAKE_MAN@
.PHONY: all
# Compile-check the pre-*at() portability tier. syscall.c's *at wrappers were
# split into vfs/, so compile every vfs source with the AT_FDCWD primitives
# undefined (via vfs/vfs_internal.h's RSYNC_TEST_NO_AT_FDCWD block) and confirm
# the fallback arms still build. A shell loop keeps this portable (BSD/Solaris
# make have no pattern rules); the last object compiled is left as the target.
# $(VFS_OBJ:.o=.c) is POSIX suffix substitution, portable across makes.
vfs-no-at-fdcwd.o: $(VFS_OBJ:.o=.c) $(HEADERS) vfs/vfs.h vfs/vfs_internal.h
@rm -f $@ $@.tmp
@for f in $(VFS_OBJ:.o=.c); do \
echo " no-AT_FDCWD compile-check: $$f"; \
$(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) \
-DRSYNC_TEST_NO_AT_FDCWD -c $(srcdir)/$$f -o $@.tmp || exit 1; \
done
@mv $@.tmp $@
.PHONY: install
install: all
-$(MKDIR_P) $(DESTDIR)$(bindir)
@@ -82,12 +106,19 @@ install: all
$(INSTALLCMD) -m 755 $(srcdir)/rsync-ssl $(DESTDIR)$(bindir)
-$(MKDIR_P) $(DESTDIR)$(mandir)/man1
-$(MKDIR_P) $(DESTDIR)$(mandir)/man5
if test -f rsync.1; then $(INSTALLMAN) -m 644 rsync.1 $(DESTDIR)$(mandir)/man1; fi
if test -f rsync-ssl.1; then $(INSTALLMAN) -m 644 rsync-ssl.1 $(DESTDIR)$(mandir)/man1; fi
if test -f rsyncd.conf.5; then $(INSTALLMAN) -m 644 rsyncd.conf.5 $(DESTDIR)$(mandir)/man5; fi
for fn in rsync.1 rsync-ssl.1; do \
if test -f $$fn; then $(INSTALLMAN) -m 644 $$fn $(DESTDIR)$(mandir)/man1; \
elif test -f $(srcdir)/$$fn; then $(INSTALLMAN) -m 644 $(srcdir)/$$fn $(DESTDIR)$(mandir)/man1; fi; \
done
for fn in rsyncd.conf.5; do \
if test -f $$fn; then $(INSTALLMAN) -m 644 $$fn $(DESTDIR)$(mandir)/man5; \
elif test -f $(srcdir)/$$fn; then $(INSTALLMAN) -m 644 $(srcdir)/$$fn $(DESTDIR)$(mandir)/man5; fi; \
done
if test "$(with_rrsync)" = yes; then \
$(INSTALLCMD) -m 755 rrsync $(DESTDIR)$(bindir); \
if test -f rrsync.1; then $(INSTALLMAN) -m 644 rrsync.1 $(DESTDIR)$(mandir)/man1; fi; \
fn=rrsync.1; \
if test -f $$fn; then $(INSTALLMAN) -m 644 $$fn $(DESTDIR)$(mandir)/man1; \
elif test -f $(srcdir)/$$fn; then $(INSTALLMAN) -m 644 $(srcdir)/$$fn $(DESTDIR)$(mandir)/man1; fi; \
fi
install-ssl-daemon: stunnel-rsyncd.conf
@@ -102,6 +133,21 @@ install-all: install install-ssl-daemon
install-strip:
$(MAKE) INSTALL_STRIP='-s' install
.PHONY: uninstall
uninstall:
rm -f $(DESTDIR)$(bindir)/rsync$(EXEEXT) $(DESTDIR)$(bindir)/rsync-ssl
rm -f $(DESTDIR)$(bindir)/rrsync
rm -f $(DESTDIR)$(mandir)/man1/rsync.1 $(DESTDIR)$(mandir)/man1/rsync-ssl.1
rm -f $(DESTDIR)$(mandir)/man1/rrsync.1
rm -f $(DESTDIR)$(mandir)/man5/rsyncd.conf.5
.PHONY: uninstall-ssl-daemon
uninstall-ssl-daemon:
rm -f $(DESTDIR)/etc/stunnel/rsyncd.conf
.PHONY: uninstall-all
uninstall-all: uninstall uninstall-ssl-daemon
rsync$(EXEEXT): $(OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS) $(LIBS)
@@ -110,11 +156,22 @@ rrsync: support/rrsync
$(OBJS): $(HEADERS)
$(CHECK_OBJS): $(HEADERS)
$(VFS_OBJ): $(HEADERS)
$(VFS_OBJ): vfs/vfs_internal.h
tls.o xattrs.o: lib/sysxattrs.h
# The VFS layer is bundled into a static archive linked last on every target so
# that moving a filesystem family between files never breaks a test harness link
# (the linker pulls only the members each program references).
libvfs.a: $(VFS_OBJ)
rm -f $@
$(AR) $(ARFLAGS) $@ $(VFS_OBJ)
$(RANLIB) $@
usage.o: version.h latest-year.h help-rsync.h help-rsyncd.h git-version.h default-cvsignore.h
loadparm.o: default-dont-compress.h daemon-parm.h
flist.o: rounding.h
log.o: rounding.h
default-cvsignore.h default-dont-compress.h: rsync.1.md define-from-md.awk
$(AWK) -f $(srcdir)/define-from-md.awk -v hfile=$@ $(srcdir)/rsync.1.md
@@ -170,14 +227,63 @@ getgroups$(EXEEXT): getgroups.o
getfsdev$(EXEEXT): getfsdev.o
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ getfsdev.o $(LIBS)
TRIMSLASH_OBJ = trimslash.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o
TRIMSLASH_OBJ = trimslash.o util2.o t_stub.o lib/compat.o lib/snprintf.o libvfs.a
trimslash$(EXEEXT): $(TRIMSLASH_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(TRIMSLASH_OBJ) $(LIBS)
T_UNSAFE_OBJ = t_unsafe.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o
T_UNSAFE_OBJ = t_unsafe.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a
t_unsafe$(EXEEXT): $(T_UNSAFE_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_UNSAFE_OBJ) $(LIBS)
T_HASHTABLE_OVERFLOW_OBJ = t_hashtable_overflow.o hashtable.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a
t_hashtable_overflow$(EXEEXT): $(T_HASHTABLE_OVERFLOW_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_HASHTABLE_OVERFLOW_OBJ) $(LIBS)
T_IWILDMATCH_OBJ = t_iwildmatch.o lib/wildmatch.o
t_iwildmatch$(EXEEXT): $(T_IWILDMATCH_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_IWILDMATCH_OBJ) $(LIBS)
T_CLEAN_FNAME_OBJ = t_clean_fname.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a
t_clean_fname$(EXEEXT): $(T_CLEAN_FNAME_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CLEAN_FNAME_OBJ) $(LIBS)
# safe_arg lives in options.c alongside the whole option parser. Rather than
# rely on a non-portable linker --gc-sections to drop the parser (GNU ld only;
# macOS ld64 and the cygwin PE linker do not), link the real rsync objects so
# every dep resolves. t_safe_arg_main.o is main.c with main() renamed out, to
# supply main.c's globals while letting t_safe_arg.o provide the test's main().
# OBJS minus main.o is spelled out via OBJS1_NO_MAIN because $(filter-out) is
# GNU-make-only; BSD and Solaris make expand it to nothing.
t_safe_arg_main.o: main.c $(HEADERS)
$(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) -Dmain=rsync_unused_main -c $(srcdir)/main.c -o t_safe_arg_main.o
T_SAFE_ARG_OBJ = t_safe_arg.o t_safe_arg_main.o $(OBJS1_NO_MAIN) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a
t_safe_arg$(EXEEXT): $(T_SAFE_ARG_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SAFE_ARG_OBJ) $(LIBS)
T_CHMOD_SECURE_OBJ = t_chmod_secure.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a
t_chmod_secure$(EXEEXT): $(T_CHMOD_SECURE_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CHMOD_SECURE_OBJ) $(LIBS)
T_RENAME_SECURE_OBJ = t_rename_secure.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a
t_rename_secure$(EXEEXT): $(T_RENAME_SECURE_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_RENAME_SECURE_OBJ) $(LIBS)
T_SYMLINK_SECURE_OBJ = t_symlink_secure.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a
t_symlink_secure$(EXEEXT): $(T_SYMLINK_SECURE_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SYMLINK_SECURE_OBJ) $(LIBS)
T_SECURE_RELPATH_OBJ = t_secure_relpath.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a
t_secure_relpath$(EXEEXT): $(T_SECURE_RELPATH_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SECURE_RELPATH_OBJ) $(LIBS)
# Unit test for lib/acl.c: compares our fd/at ACL ops against the system libacl
# (linked via $(LIBS), which carries -lacl). lib/acl.o references no rsync
# globals, so this links with no stubs. Self-skips (exit 77) when built
# without SUPPORT_ACL_FD.
T_ACL_OBJ = t_acl.o lib/acl.o
t_acl$(EXEEXT): $(T_ACL_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_ACL_OBJ) $(LIBS)
.PHONY: conf
conf: configure.sh config.h.in
@@ -267,9 +373,11 @@ rrsync.1: support/rrsync.1.md md-convert Makefile
.PHONY: clean
clean: cleantests
rm -f *~ $(OBJS) $(CHECK_PROGS) $(CHECK_OBJS) $(CHECK_SYMLINKS) @MAKE_RRSYNC@ \
rm -f *~ $(OBJS) $(VFS_OBJ) libvfs.a $(CHECK_PROGS) $(CHECK_OBJS) $(CHECK_COMPILE_OBJS) $(CHECK_COMPILE_OBJS:.o=.o.tmp) $(CHECK_SYMLINKS) @MAKE_RRSYNC@ \
git-version.h rounding rounding.h *.old rsync*.1 rsync*.5 @MAKE_RRSYNC_1@ \
*.html daemon-parm.h help-*.h default-*.h proto.h proto.h-tstamp
rm -f *.gcno *.gcda lib/*.gcno lib/*.gcda zlib/*.gcno zlib/*.gcda popt/*.gcno popt/*.gcda vfs/*.gcno vfs/*.gcda
rm -rf coverage coverage-tcp coverage-all coverage-fallback
.PHONY: cleantests
cleantests:
@@ -310,30 +418,172 @@ test: check
# catch Bash-isms earlier even if we're running on GNU. Of course, we
# might lose in the future where POSIX diverges from old sh.
# `make check` runs tests in parallel by default. Override with
# `make check CHECK_J=1` (serial) or any other value.
CHECK_J = 8
# Parallelism for `make coverage`. Defaults to the same as CHECK_J: the
# coverage build sets -fprofile-update=atomic (atomic in-memory counters) and
# gcc's libgcov serializes the per-source .gcda read-modify-write merge with a
# file lock, so concurrent rsync processes (incl. the forked sender/generator/
# receiver) accumulate exactly -- verified by a count-linearity check (a hot
# line accumulates identically at -j1 and -P16). Override with
# `make coverage COVERAGE_J=1` if your libgcov does not lock .gcda merges.
COVERAGE_J = $(CHECK_J)
# Output directory and extra runtests.py flags for `make coverage`. The
# `coverage-tcp` target reuses the coverage recipe with --use-tcp (real
# loopback rsyncd, which exercises the TCP accept/auth path and the
# require_tcp-only tests) and a separate output directory.
COVERAGE_DIR = coverage
COVERAGE_RUNFLAGS =
# Excluded from the coverage report so the percentages reflect rsync's own
# runtime source. Three buckets:
# (1) Bundled third-party code rsync ships but does not own: zlib/, popt/, and
# the named lib/ imports (PostgreSQL getaddrinfo, ISC inet_ntop/inet_pton,
# standalone getpass). The other lib/*.c are rsync's own and stay in.
# (2) Test-helper / build-time programs that link against rsync objects but are
# not the rsync runtime: t_*.c, tls.c, wildtest.c, testrun.c, getgroups.c,
# getfsdev.c, trimslash.c, rounding.c. These have their own main() and are
# either driven directly by a test (counted there) or are configure-time
# probes; counting them as "rsync uncovered" is noise.
# (3) Compile-time-dead fallbacks under this build's config.h: lib/md5.c (the
# reference md5 -- openssl's EVP path is used when HAVE_OPENSSL) and
# lib/snprintf.c (only the #include line survives under
# HAVE_C99_VSNPRINTF). Covering these would mean a separate non-openssl /
# non-C99 build, which is out of scope for this report.
COVERAGE_EXCLUDE = -e '(^|/)zlib/' -e '(^|/)popt/' \
-e '(^|/)lib/(getaddrinfo|getpass|inet_ntop|inet_pton)\.' \
-e '(^|/)(t_[a-z_]+|tls|wildtest|testrun|getgroups|getfsdev|trimslash|rounding)\.c$$' \
-e '(^|/)lib/(md5|snprintf)\.c$$'
# Build everything the test suite needs (rsync + helper programs + symlinks)
# WITHOUT running it. Used by CI jobs that invoke runtests.py directly with
# custom options (e.g. the version-mix workflow's --rsync-bin2/--expect-result).
.PHONY: check-progs
check-progs: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS)
.PHONY: check
check: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
rsync_bin=`pwd`/rsync$(EXEEXT) $(srcdir)/runtests.sh
check: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS)
"$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J)
.PHONY: check29
check29: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
rsync_bin=`pwd`/rsync$(EXEEXT) $(srcdir)/runtests.sh --protocol=29
check29: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS)
"$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=29
.PHONY: check30
check30: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
rsync_bin=`pwd`/rsync$(EXEEXT) $(srcdir)/runtests.sh --protocol=30
check30: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS)
"$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=30
# Whole-suite gcov coverage report (HTML, with branch + decision coverage).
# Requires a build configured with --enable-coverage and the `gcovr` tool
# (pip install gcovr). Runs the suite in parallel (COVERAGE_J, default CHECK_J):
# this is safe because the coverage build uses -fprofile-update=atomic and
# libgcov locks the per-source .gcda during its merge, so concurrent rsync
# processes accumulate exactly (see COVERAGE_J above). Use COVERAGE_J=1 if your
# toolchain's libgcov does not lock .gcda merges.
.PHONY: coverage
coverage: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
@case '$(CFLAGS)' in *--coverage*) ;; \
*) echo "*** not a coverage build; reconfigure with --enable-coverage"; exit 1 ;; esac
@command -v gcovr >/dev/null 2>&1 || { echo "*** gcovr not found (pip install gcovr)"; exit 1; }
find . -name '*.gcda' -delete
@# Daemon modules with `uid = <non-root>` setuid the per-connection child
@# (and so the forked generator/receiver), which then cannot create or
@# merge .gcda files in a root-owned build dir -- silently dropping ALL
@# coverage from those processes. Make every .gcno's directory
@# world-writable so any uid can create the sibling .gcda, and set a
@# default ACL of o::rw so the .gcda are world-mergeable regardless of
@# the creator's umask (every test process resets umask to 022 via
@# rsyncfns.py, so a Makefile-level `umask 0` would not survive).
@find . -name '*.gcno' -printf '%h\n' 2>/dev/null | sort -u | \
while read d; do \
chmod a+rwx "$$d"; \
setfacl -m 'd:u::rwx,d:g::rwx,d:o::rwx' "$$d" 2>/dev/null || true; \
done
@rc=0; "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $(COVERAGE_RUNFLAGS) || rc=$$?; \
rm -rf $(COVERAGE_DIR) && mkdir -p $(COVERAGE_DIR); \
gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \
--gcov-ignore-parse-errors=negative_hits.warn_once_per_file \
--html-details -o $(COVERAGE_DIR)/index.html . || exit $$?; \
echo "Coverage report written to $(COVERAGE_DIR)/index.html"; \
if test $$rc != 0; then \
echo "*** test suite FAILED (status $$rc) -- coverage report still written above"; \
fi; \
exit $$rc
# Same as `make coverage` but with the daemon tests run over a real loopback
# rsyncd (--use-tcp), into a separate report directory.
.PHONY: coverage-tcp
coverage-tcp:
$(MAKE) coverage COVERAGE_RUNFLAGS=--use-tcp COVERAGE_DIR=coverage-tcp
# Comprehensive single report: run the suite under several configurations,
# accumulating into the shared .gcda counters (NOT cleared between runs), then
# emit one merged, rsync-scoped report. Covers the default (pipe) transport, the
# protocol-29/30 compat branches, and the real-TCP daemon path (which also runs
# the require_tcp-only tests). Run under sudo to additionally cover root-only
# paths (devices, chown, use-chroot, protected-regular). Local target -- CI uses
# the plain `coverage`/`coverage-tcp` targets.
.PHONY: coverage-all
coverage-all: all $(CHECK_PROGS) $(CHECK_SYMLINKS)
@case '$(CFLAGS)' in *--coverage*) ;; \
*) echo "*** not a coverage build; reconfigure with --enable-coverage"; exit 1 ;; esac
@command -v gcovr >/dev/null 2>&1 || { echo "*** gcovr not found (pip install gcovr)"; exit 1; }
find . -name '*.gcda' -delete
@# See the `coverage` target above for why: setuid'd daemon children must
@# be able to create/merge .gcda owned by a different uid.
@find . -name '*.gcno' -printf '%h\n' 2>/dev/null | sort -u | \
while read d; do \
chmod a+rwx "$$d"; \
setfacl -m 'd:u::rwx,d:g::rwx,d:o::rwx' "$$d" 2>/dev/null || true; \
done
@rc=0; \
for cfg in '' '--protocol=30' '--protocol=29' '--use-tcp'; do \
echo "===== coverage-all: runtests.py $$cfg ====="; \
"$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $$cfg || rc=$$?; \
done; \
rm -rf coverage-all && mkdir -p coverage-all; \
gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \
--gcov-ignore-parse-errors=negative_hits.warn_once_per_file \
--html-details -o coverage-all/index.html . || exit $$?; \
echo "Merged coverage report written to coverage-all/index.html"; \
if test $$rc != 0; then \
echo "*** some suite runs FAILED (status $$rc) -- report still written above"; \
fi; \
exit $$rc
# Coverage for the portable (non-openat2) resolver tier. Requires a SEPARATE
# build configured with --enable-coverage --disable-openat2: its .gcno differ
# from the openat2 build, so this report cannot be merged with the others.
.PHONY: coverage-fallback
coverage-fallback:
$(MAKE) coverage COVERAGE_DIR=coverage-fallback
wildtest.o: wildtest.c t_stub.o lib/wildmatch.c rsync.h config.h
wildtest$(EXEEXT): wildtest.o lib/compat.o lib/snprintf.o @BUILD_POPT@
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ wildtest.o lib/compat.o lib/snprintf.o @BUILD_POPT@ $(LIBS)
testsuite/chown-fake.test:
ln -s chown.test $(srcdir)/testsuite/chown-fake.test
simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS)
@if test x"@ROLL_SIMD@" != x; then \
$(CXX) -I. $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) -DTEST_SIMD_CHECKSUM1 \
-o $@ $(srcdir)/simd-checksum-x86_64.cpp @ROLL_ASM@ $(LIBS); \
else \
touch $@; \
fi
testsuite/devices-fake.test:
ln -s devices.test $(srcdir)/testsuite/devices-fake.test
testsuite/chown-fake_test.py:
ln -s chown_test.py $(srcdir)/testsuite/chown-fake_test.py
testsuite/xattrs-hlink.test:
ln -s xattrs.test $(srcdir)/testsuite/xattrs-hlink.test
testsuite/devices-fake_test.py:
ln -s devices_test.py $(srcdir)/testsuite/devices-fake_test.py
testsuite/xattrs-hlink_test.py:
ln -s xattrs_test.py $(srcdir)/testsuite/xattrs-hlink_test.py
testsuite/exclude-lsh_test.py:
ln -s exclude_test.py $(srcdir)/testsuite/exclude-lsh_test.py
# This does *not* depend on building or installing: you can use it to
# check a version installed from a binary or some other source tree,
@@ -341,7 +591,7 @@ testsuite/xattrs-hlink.test:
.PHONY: installcheck
installcheck: $(CHECK_PROGS) $(CHECK_SYMLINKS)
POSIXLY_CORRECT=1 TOOLDIR=`pwd` rsync_bin="$(bindir)/rsync$(EXEEXT)" srcdir="$(srcdir)" $(srcdir)/runtests.sh
"$(srcdir)/runtests.py" --rsync-bin="$(bindir)/rsync$(EXEEXT)" --srcdir="$(srcdir)" --tooldir="`pwd`" -j $(CHECK_J)
# TODO: Add 'dist' target; need to know which files will be included
+910 -1
View File
@@ -1,3 +1,906 @@
# NEWS for rsync 3.5.0 (13 Aug 2026)
## Changes in this version:
### Thanks!
This has been an extraordinary release developed over several months
and I'd like to thank everyone who has helped make it possible. The
volume of security issues we had to deal with would have been quite
overwhelming without the help that I've received.
I'm particularly grateful to Zen Dodd (Tao), Omar Elsayed (seks99x),
Will Sargeant, Paul Mackerras, Aleksa Sarai and Leonid Bugaev (buger)
who joined the rsync admins group helping to triage all the issues,
develop new tests, review PRs and helped develop the guidelines we used
for where to draw the line between a security issue and expected
behaviour (a surprisingly difficult thing to do in some cases). You've
all been a huge help and rsync is much better off for your assistance.
A big thank you also to Filipe Casal from Trail of Bits who worked with
us on the "Patch the Planet" program. Filipe provided a huge trove of
valuable tests and security reports.
Also a big thank you to Greg Kroah-Hartman for invaluable advice and
security reports and to Stuart Inglis for particularly high quality
bug reports and testing.
Many thanks to everyone who submitted bug reports, credits are listed
below against individual items.
Finally, thank you to everyone who joined in the discussion and
testing on the rsync-security mailing list, and to the rsync user
community for your patience in waiting for this release.
### SECURITY FIXES:
This release fixes 33 security issues found during a focused audit of rsync's
path handling and daemon protocol, a companion daemon-protocol fuzzing pass, and
reports from external researchers -- plus several robustness hardenings. CVE
IDs were assigned by VulnCheck (CNA); the precise "introduced in" version ranges
accompany each advisory, and many are much narrower than "everything before
3.5.0". Every fix ships with a regression test in the test suite that fails on
the unfixed tree. Many thanks to the external researchers credited below.
Link following (CWE-59/61) -- a local user who controls a path component plants
a symlink that a privileged rsync then follows:
- CVE-2026-53802 (HIGH): Arbitrary file read / transfer-shaping via symlinked
operator-supplied input files. rsync followed attacker-planted symlinks in
`--filter` merge files (including per-directory merges and `-C` `.cvsignore`),
`--files-from` / `--include-from` / `--exclude-from`, and the client
`--password-file` / daemon secrets file -- reading an arbitrary file as filter
rules, or sending a victim file's contents as the daemon authentication
response. Operator-supplied paths are now resolved component-by-component with
`openat(O_PATH|O_NOFOLLOW)`, allowing a symlink component only when it is owned
by uid 0 or the effective uid.
- CVE-2026-53803 (HIGH): Arbitrary file write / privilege escalation via
symlinked operator-supplied output paths -- `--log-file`,
`--write-batch`/`--read-batch`, and the daemon's motd / lock / early-input /
`--config` opens. A planted symlink (or parent component) could redirect the
write, e.g. append the log to `authorized_keys`; `--read-batch` could also feed
chosen bytes to the protocol parser. Same trusted-owner path walk, plus an
`S_ISREG` check on the `--read-batch` file.
- CVE-2026-53785 (HIGH): Under `--relative`, the receiver's implied-parent
creation (`make_path()`) built the parent chain with a plain `mkdir()` on the
full path, so a planted parent symlink placed the new directories and file
outside the destination tree. `make_path()` now creates each component through
the held-directory-fd primitive. Reported by Omar Elsayed (seks99x).
- CVE-2026-53784 (HIGH): Daemon module-root chdir escape under `use chroot =
no`: a plain `chdir()` followed a planted parent-component symlink, serving
files from outside the module. The module-root chdir now goes through the
secure resolver.
- CVE-2026-53793 (HIGH): Chroot `/./` inner-module escape -- a symlinked
parent component inside the inner module reached a sibling outside it (the
generator basis stat, the receiver write/finish path, the module chdir, and the
receiver's delta-basis open). The secure resolver is now engaged for all of
those paths.
- CVE-2026-53795 (HIGH): An absolute `--temp-dir` or `--link-dest` disabled
the receiver's rename/link confinement. `do_rename_at()`/`do_link_at()` bailed
to the unconfined path-based call whenever *either* path was absolute, so an
absolute source (the temp file, or the link-dest basis) let `finish_transfer()`'s
tmp->final rename -- or a hard-link create -- follow a destination parent
component an attacker flipped to a symlink mid-transfer, writing the file outside
the tree. Each side is now confined independently. Reported by Omar Elsayed
(seks99x).
- CVE-2026-53796 (MEDIUM): A non-daemon receiver's one-time `chdir()` into the
operator-named destination was not fully confined (a relative destination took a
plain `chdir()`), so an attacker who raced the named destination from a directory
to a symlink moved the receiver's CWD -- and every file it then created --
outside the tree. The destination chdir now uses the same ownership-checked
`O_NOFOLLOW` walk as the daemon module chdir (see BEHAVIOR CHANGES). Reported by
Omar Elsayed (seks99x).
- CVE-2026-53797 (MEDIUM): A non-daemon sender opened each transferred file's
content by path (leaf `O_NOFOLLOW` only), so a source parent component an
unprivileged user raced to a symlink after the file-list scan was followed --
reading a file from outside the source tree into an attacker-readable
destination. The content open is now anchored at the transfer root with
`secure_relative_open()`; `-L` / `--copy-unsafe-links` / `-k` still follow, and
`--insecure-links` restores the legacy open.
- CVE-2026-53799 (MEDIUM): Receiver ACL/xattr metadata application followed a
symlink race -> arbitrary ACL set (local privilege escalation). When preserving
metadata (`-A`/`--acls`, `-X`/`--xattrs`, or fake-super ACL-as-xattr), the
receiver applied each entry's ACL/xattrs by path via `acl_set_file()` /
`setxattr()`. A local user who raced a just-received entry (or a parent) into a
symlink before the apply could redirect an attacker-chosen ACL -- the bytes are
carried in the source entry -- onto a victim inode outside the destination tree,
granting rwx on a root-owned file. The apply now pins each entry's inode with an
`O_RDONLY|O_NOFOLLOW` fd and sets all metadata on the held inode (Linux 6.13+
`*xattrat` syscalls, or a patched libacl's `*_at` bindings, else the
`/proc/self/fd` compat path). Where neither primitive exists (the BSDs, Solaris,
macOS, or a `/proc`-less Linux container) it falls back to the path-based apply to
keep `--acls` functional -- a documented residual, refusable via `refuse options =
acls`.
- CVE-2026-53800 (MEDIUM): Sender `--remove-source-files` unlink followed a
parent-component symlink race -> arbitrary file deletion outside the source tree.
The post-send unlink and its same-file safety re-stat resolved by path relative to
the process CWD, so an unprivileged user who raced a source parent into a symlink
after the file was sent could make a higher-authority sender (a root
`--remove-source-files` run, or a daemon module not refusing the option) delete a
file outside the served tree. The removal is now resolved through the secure
held-dirfd walk anchored at the served module root (daemon) or transfer-root CWD
(local sender), the safety re-stat is confined likewise, and the per-file dev/ino
is only computed when `--remove-source-files` is in effect.
- CVE-2026-53801 (MEDIUM): Sender/daemon directory-scan enumeration escaped the
transfer root / module -> out-of-tree disclosure. The sender enumerated each
source directory with a plain `opendir()` on the accumulated path, not through the
secure resolver (the enumeration sibling of the previous item, which confined only
the content open). A parent component raced to a symlink between the file-list
scan and the recursive `opendir()` -- or, in daemon following mode
(`-L`/`--copy-dirlinks`/`--copy-unsafe-links`), an in-module symlinked directory
pointing outside the module -- let a higher-authority sender enumerate an
out-of-tree directory and copy its entry names, metadata and symlink targets. The
directory scan is now confined through a held `opendir` fd anchored at the transfer
root / module.
`support/rrsync` (the restricted SSH wrapper):
- CVE-2026-53783 (HIGH): rrsync restricted-directory escape. It validated each
argument with `realpath()` and then exec'd rsync against the same name (a
TOCTOU window), and left dangerous options enabled in a restricted subdir.
rrsync now inode-pins the validated path and roots the argument it hands rsync
at that pinned fd, denies `--copy-unsafe-links`, forces `--no-D`, and refuses a
symlinked `--log-file`. The pin relies on Linux's `/proc/self/fd` magic links
being bound to the open inode, so it is Linux-only; on the BSDs, macOS, Solaris
and Cygwin rrsync passes the `realpath()`-validated name as it always did.
Two limits are worth stating: under `--relative` only the anchor the
transmitted name starts from is pinned, so a component below it can still be
raced, and the final component of an ordinary sender argument is not pinned
either (rsync does not follow a symlink there, and the options that would
change that are refused in a restricted dir).
- A filter rule that failed to parse was echoed back verbatim, including when
the rule came from a merge file's contents. A per-directory merge rule names
a file the peer chooses and travels over the protocol rather than in an
argument, so this let a peer read back any line of any file the server process
could open that is not valid filter syntax -- through an `rrsync` restricted
account as well as a daemon module, since neither confined a merge open that
the wrapper never sees. A syntax error in a rule read from a file now reports
the file and line rather than the text; a rule given as an argument is still
shown. The `--debug=FILTER` traces print the same file-derived text, so
`rrsync` now refuses a peer-selected `--debug` (a stock client never sends
one). An operator who turns debugging on for their own server still sees the
rule text.
- Redacting those diagnostics did not close the merge route on its own, because
the worst shape produces no diagnostic at all: an exclude-only merge (the `-`
modifier) makes every line of the file a pattern, so nothing fails to parse
and the peer reads the contents off which of its own names went missing from
the file list. Through an `rrsync` restricted account that needs no
`--delete` and no verbosity on a pull. The open is now confined rather than
the disclosure suppressed: rsync gained `--confine-root=DIR`, which refuses an
operator- or peer-supplied path that resolves outside DIR, and `rrsync` passes
its restricted directory. A merge file inside that directory keeps working.
A daemon already had this through its module root and is unaffected.
Daemon protocol / identity:
- CVE-2026-53786 (MEDIUM): A client-supplied `--filter` merge file bypassed
the module filter list (it was checked against the module-prefixed path, which
never matched a module rule). The module-dir prefix is now stripped before the
check. Reported by Mitchell Benjamin (Revamp Studio).
- CVE-2026-53798 (MEDIUM): The daemon name converter mapped an unknown name to
uid/gid 0 (an empty response was read as `atol("") == 0`); with `fake super =
yes` the stored metadata became root-owned. An empty/non-numeric response is
now treated as a lookup failure. Reported by Mitchell Benjamin (Revamp
Studio).
- CVE-2026-53788 (MEDIUM): A peer-controlled name containing a newline/CR was
written verbatim into the name-converter line protocol, allowing request
injection. Converter tokens containing control characters are now rejected.
Reported by Mitchell Benjamin (Revamp Studio).
- CVE-2026-53789 (MEDIUM): A malicious daemon-sender could widen `--delete`
scope by omitting the "no content dir" flag on an implied parent, making the
receiver run `delete_in_dir()` on it. Implied-parent directories are now
forced non-content on the receiver. Reported by Mitchell Benjamin (Revamp
Studio).
- CVE-2026-53791 (CRITICAL): With `proxy protocol = true`, a client connecting
directly (not via the trusted proxy) could send a PROXY header to spoof its
source address and bypass host-based access control. A forwarded address is
now honoured only from a configured trusted-proxy peer.
Injection and memory safety:
- CVE-2026-53790 (HIGH): Command / argument injection via unquoted peer- or
host-controlled values -- the `RSYNC_CONNECT_PROG` `%H` host substitution, the
daemon exec-hook `%RSYNC_*%` expansions, rsync-ssl hostspecs, and a missing
newline/CR in remote-shell argument quoting. Each sink is now quoted or
validated (the hook escaping is confined to the shell-executed hooks, so
ordinary daemon string parameters such as `path` are unaffected).
- CVE-2026-53792 (MEDIUM): A malicious receiver sending a checksum header with a
block count > 0 but block length == 0 drove the sender's rolling-match
arithmetic negative. A zero block length is now rejected.
- CVE-2026-53794 (MEDIUM): `--max-alloc=0` disabled the per-allocation size
cap (the defense behind CVE-2024-12084) and could be forwarded on the wire to
an unpatched daemon. A zero max-alloc is now rejected at both the client and
the daemon. Reported by Azizcan Dastan (Milenium Security).
Peer-triggerable memory corruption in the daemon protocol, found by a
daemon-protocol fuzzing pass and reported by Greg Kroah-Hartman. Each is a
WRITE reachable from the wire, which is why these were split out from the
crash-only findings in the same pass:
- CVE-2026-70461 (HIGH): a one-byte heap out-of-bounds write in
`add_implied_include()`, driven by a peer-supplied filter rule whose trailing
backslash was not counted when sizing the copy.
- CVE-2026-70458 (HIGH): an out-of-bounds write from a file entry marked
`FLAG_HLINKED` that the receiver accepted even though `-H` was not in effect,
so the hard-link extra slots it then wrote were never allocated.
- CVE-2026-70456 (HIGH): an out-of-bounds heap write in `read_args()` when the
peer's argument count lands exactly on `maxargs` -- the trailing NULL went one
past the end of the array.
- CVE-2026-70457 (MEDIUM): an attacker-chosen-offset write in
`parse_size_arg()`'s error formatting, reachable through an over-large
`--max-size` / `--min-size` / `--max-alloc` forwarded to a daemon.
- CVE-2026-70459 (MEDIUM): a wild-pointer read crashing the per-connection
daemon child, from a crafted first incremental file list whose transfer root
is "." with a non-directory mode -- `parent_ndx` stayed 0 while `dir_flist`
was still empty, so the generator dereferenced a never-written slot.
Companion to CVE-2026-43620; reproduced on released 3.2.7, 3.4.0 and 3.4.1.
Daemon availability and access control:
- CVE-2026-70464 (HIGH): an unauthenticated peer could complete the `@RSYNCD`
greeting and then stall forever -- sending a line with no terminator, or
trickling NUL-terminated arguments into `read_args()` one byte at a time --
holding a per-connection child open past the module's `max connections`
limit. The `timeout` parameter did not cover it, because `set_io_timeout()`
ran after the `read_args()` calls that needed covering. A separate deadline
now spans both, and the early-protocol argument count is bounded. Reported
independently by Chamal De Silva and by Michal Ruprich (Red Hat QE).
- CVE-2026-70455 (HIGH): a daemon client could request an arbitrary Zstandard
worker count via `--compress-threads`; 256 was measured as 257 threads in a
single connection. Now capped at 8 on a daemon, while local and
remote-shell invocations keep the operator's value. Reported, fixed and
tested by Filipe Casal of Trail of Bits, in collaboration with OpenAI.
- CVE-2026-70453 (HIGH): quadratic CPU exhaustion in `hash_search()` from a
crafted chain of equal weak checksums. The chain walk is now bounded. First
reported as a performance problem in public rsync issue #217 by heyciao
(2021); recognised as a security issue, bounded and regression-tested by
Stuart Inglis. This one was already public and was not embargoed.
- CVE-2026-70452 (HIGH): `hosts deny` failed OPEN when a configured hostname
could not be resolved -- with `forward lookup` enabled, which is the default,
an unresolvable deny token admitted the host it was meant to block. It now
fails closed. Sibling of CVE-2026-43617. Reported by Leonid Bugaev.
- CVE-2026-70463 (HIGH): `auth users` ignored its documented comma-only
parsing. With a leading comma the split should be on commas alone, so that a
group name containing a space can be written; it split on whitespace too, so
a `deny` or `:ro` rule naming such a group was broken into two meaningless
tokens and never fired. Reported by Andres Berbescu.
- CVE-2026-70460 (HIGH): a peer-supplied `--partial-dir` or `--backup-dir` was
resolved by pathname, so an in-module symlink could redirect it and place
files outside the daemon's module root. Those paths are now confined.
Reported by Omar Elsayed (seks99x).
Client-side:
- CVE-2026-70462 (MEDIUM): a peer-supplied `MSG_IO_TIMEOUT` defeated the
client's own I/O timeout -- a large value overflowed signed arithmetic, and a
non-positive value disabled the timeout outright. The value is now capped on
receipt and the arithmetic made overflow-safe. Reported by Z3R0S! (z3r0s6);
the non-positive case was reported by Leonid Bugaev.
- CVE-2026-70454 (MEDIUM): `rsync-ssl` established an unauthenticated TLS
connection. In stunnel mode it neither required CA verification nor bound
the certificate to the requested hostname, so an active network attacker
could impersonate the server; the openssl backend had a matching hostname
gap in 3.2.0 through 3.2.3 (found and fixed in 2020 by Matt McCutchen).
stunnel mode now requires certificate verification and hostname binding
unless an explicit insecure opt-out is set, and the GnuTLS backend is
refused conservatively rather than used unverified (Greg Kroah-Hartman).
Robustness hardening (no CVE assigned): the `RSYNC_PROXY` CONNECT request and
proxy response headers are length-bounded, and peer-requested xattr expansion is
capped.
A second-pass source audit (reported by Leonid Bugaev) hardened several memory-
safety and robustness paths: the hashtable and file-list size computations are
guarded against a 32-bit integer overflow that a peer's entry count could
otherwise wrap into an under-allocation, and the
`SIGUSR2` handler is now async-signal-safe (it only sets a flag, deferring the
summary/close-out work to safe poll points). Separately, the xattr/ACL metadata
copy now reads the *source* through a held no-follow fd as well as writing the
destination through one -- closing a parent-symlink race on the `--copy-dest` and
backup source -- and the cross-tree operator-path metadata apply is now fd-pinned
under `--fake-super` too (previously it fell back to a path-based set for a
`fake super = yes` daemon staging through an absolute `--temp-dir`/`--backup-dir`).
### SECURITY RELATED:
- Mask a peer-supplied I/O-error value to the defined `IOERR_*` bits, both the
incoming `MSG_IO_ERROR` message (`io.c`) and the file-list trailer (`flist.c`),
so a malicious peer cannot set arbitrary (undefined) error flags that would be
stored in the local `io_error` and re-forwarded upstream. (Undefined bits
never reached the exit code, which maps only the defined bits.) Reported by
Leonid Bugaev.
- Escape control characters in filenames written to the log file (CWE-117 log
injection): a transferred name containing control bytes -- C0 (tab excepted)
and C1 `0x80`-`0x9f`, including CSI `0x9b` -- could otherwise inject terminal
escape sequences into an administrator's terminal when the log is viewed.
Reported by Leonid Bugaev.
- Stop `safe_arg()` leaking an uninitialized byte into a quoted filename. In
filename mode the writer suppresses the escaping backslash before a wildcard,
but the counter that sized the buffer reserved a slot for every backslash, so
the two disagreed and left an uninitialized heap byte in the returned string
-- which is handed to the remote shell when `--protect-args` is off. The
counter now mirrors the writer, and guarding the wildcard test with `f[1]`
also fixes a trailing backslash (previously `strchr()` matched the string
terminator, so the backslash was not doubled). Reported by Leonid Bugaev.
- Close a `--safe-links` bypass in `--backup`: when symlinks can be hard-linked,
`make_backup()`'s link/rename fast path hard-linked an unsafe (out-of-tree)
symlink into the backup area and skipped the `safe_symlinks` check the copy
path applies, silently preserving a link `--safe-links` was meant to drop. The
safe-links check now runs before the fast path, and a symlink whose target is
unreadable is failed closed rather than backed up unchecked. Reported by
Leonid Bugaev.
- Extend the operator-directory ownership walk to the backup leaf sinks:
`do_symlink_at()` (backing a symlink up into an operator `--backup-dir`) and
`do_rmdir_at()` (removing a pre-existing backup directory) now resolve their
parent through the same ownership walk, so a foreign-owned parent symlink no
longer redirects the backup symlink-create or directory-removal outside the
backup tree. `--insecure-links` (or a module's `insecure links = yes`) restores
the legacy follow. Reported by Omar Elsayed (seks99x).
- Confine an absolute operator source/destination through the ownership walk in
`robust_rename()`'s cross-filesystem (EXDEV) copy fallback, so a raced parent
symlink cannot redirect the fallback copy or its source unlink out of the tree.
Reported by Leonid Bugaev.
- Bound the number of equal-weak-checksum blocks examined per offset in
`hash_search()` (issue #217), so a crafted or degenerate checksum set with a
very long equal-checksum chain cannot drive the sender's per-offset
match-verify into a quadratic blow-up (CPU DoS). Fix by Stuart Inglis.
### BUG FIXES:
- Fix an off-by-one in `clean_fname()`'s `..`-collapse path normalization.
Reported by Leonid Bugaev.
- The AVX2 rolling-checksum assembly (`--enable-roll-asm`) read up to 64 bytes
past the end of the buffer it was given. The loop is software-pipelined and
preloaded the 64 bytes after the ones it was folding in, so its last iteration
always reached beyond the data -- the remainder is by construction under 64
bytes. It normally landed in slack inside rsync's map window and went
unnoticed; where the buffer ended at a page boundary it was a SIGSEGV mid
transfer, reported on macOS x86-64 by Roland Kletzing. Reported checksums are
unchanged.
- `--link-dest` no longer fails the transfer when the destination refuses to
hard-link a symlink, device node, FIFO or socket. Whether rsync hard-links
those at all was decided at build time, on whatever filesystem the source tree
happened to sit on, and one host can hold both answers -- macOS builds on
APFS, which can, and backs up to HFS+, which returns ENOTSUP. Such an entry
is now copied, exactly as it already is in a build that cannot link them and
as a regular file in the same position already was; the run used to exit 23
even though the entry was then created correctly. The fallback covers any
refusal, since the error does not identify one on its own: link(2) documents
EPERM both for a filesystem without hard links and for a permission refusal.
Still outstanding: under `-H`, a group of such entries hard-linked to each
other also needs a link within the destination, and where the destination
cannot hard-link the type at all, the members after the first are still lost.
- `--out-format` / `--log-file-format` now emit a literal `%` for `%%` instead of
mis-parsing the following character (added by Leonid Bugaev); a follow-up bounds
`log_format_has()`'s width-digit scan to match `log_formatted()`, closing a `%C`
read past the checksum field.
- A CVS `.cvsignore` (or `-C`) file containing a `!` clear-list token no longer
aborts with a spurious "rule has trailing characters" error. Reported by
Leonid Bugaev.
- `--chmod=a+s` now sets both the setuid and setgid bits, matching `chmod(1)`
(it previously set setuid only). Reported by Leonid Bugaev.
- Case-insensitive wildcard matching (used by daemon `hosts allow`/`hosts deny`
rules) now folds characters inside a `[...]` bracket expression, not just
literal pattern characters. Reported by Leonid Bugaev.
### BEHAVIOR CHANGES:
- A non-daemon receiver follows an operator-named symlinked destination directory
only when the symlink is owned by root or the running user (e.g. `rsync -a src/
/backup/` where `/backup -> /mnt/disk`); a destination symlinked by another uid
is now refused, closing a chdir TOCTOU where an attacker raced the named
destination into a symlink. `--insecure-links` restores the unconditional
follow.
- On platforms without a race-safe way to create a unix socket in a subdirectory
(the BSDs, macOS, Solaris, which lack `bindat()`), a nested socket transferred
under `--specials` is skipped with a warning instead of failing the whole
transfer. Top-level sockets are unaffected.
- `proxy protocol = true` with no `proxy protocol hosts` rejects all connections
(fail-closed); the daemon now warns about this at startup.
- `support/rrsync` in a restricted subdirectory forces `--no-D` (device/special
semantics are stripped, so a plain `rsync -a` still works) and denies
`--copy-unsafe-links`.
- The path resolver now follows in-tree directory symlinks uniformly on every
platform via a single race-free per-component `O_NOFOLLOW` walk, so `-K` /
`-L` / `-k` and `-R` through an in-tree symlinked parent behave the same
everywhere.
# NEWS for rsync 3.4.4 (8 Jun 2026)
## Changes in this version:
This is a conservative point release that backports regression fixes
on top of 3.4.3. No new features are included.
### BUG FIXES:
- Honour a relative alt-basis directory (e.g. `--link-dest=../sibling`,
`--copy-dest`, `--compare-dest`) on a daemon receiver running with
`use chroot = no`. Such a path is re-anchored at the module root but
was then rejected by the receiver's secure open; it now works where
kernel-enforced confinement is available. See the PORTABILITY note
below for the platform limitation. Fixes #915.
- sender: open a module-root-absolute path for a `path = /` module so a
daemon serving the filesystem root can satisfy absolute request
paths again. Fixes #897.
- flist: accept the missing-args mode-0 entry in recv_file_entry.
Fixes #910.
- receiver: fix a false "failed verification -- update discarded" when
resuming a delta transfer with an absolute `--partial-dir`.
- receiver: fix a NULL dereference on the delta discard path.
- generator: cap the block s2length at the negotiated checksum length.
- main: fix `--mkpath` with `--dry-run` for a file-to-file copy.
Fixes #880.
- daemon: un-backslash escaped option args. Fixes #829.
- token: drain the matched-block insert deflate. Fixes #951.
- Fix the "update skips a file of a different type" case and the
daemon upload delete stats.
- alloc: revert "zero all new memory from allocations". Fixes #959.
- Always clear the stat buffer and validate nanoseconds before use.
### PORTABILITY / BUILD:
- The relative alt-basis fix for daemon receivers (#915) relies on
kernel "stay below dirfd" path resolution -- `openat2(RESOLVE_BENEATH)`
on Linux 5.6+, or `openat()` with `O_RESOLVE_BENEATH` on FreeBSD 13+
and macOS 15+. On platforms that lack it (Solaris, OpenBSD, NetBSD,
Cygwin and older Linux) `secure_relative_open()` deliberately rejects
any path with a `..` component, so relative alt-basis directories
remain unavailable there -- function traded for safety, matching the
trade-off already documented for the #715 fix. Absolute alt-basis
paths are unaffected on every platform.
- openat2 is now autodetected at configure time (HAVE_OPENAT2): the
`openat2(RESOLVE_BENEATH)` resolver is compiled in only when both
`<linux/openat2.h>` and the `SYS_openat2` syscall number are present,
fixing the build on older kernels/headers. Fixes #924, #905, #900,
#904.
- Fall back to do_mknod() when mknodat() / mkfifoat() are unavailable.
Fixes #896.
- Install generated manpages correctly in an out-of-tree build.
### DEVELOPER RELATED:
- Added a CI workflow that builds this stable branch and runs the
`v34-stable-testsuite` regression suite against the built binary,
giving regression coverage without importing the full master test
suite into the stable branch.
- Added a check-progs target for fleettest and extended the build
workflows to run on `*-stable` release branches.
### CREDITS:
Thanks to everyone who helped with this release:
- Code contributions from Zen Dodd (steadytao), Mike-Goutokuji,
pterror, and Stiliyan Tonev (Bark).
- Zen Dodd (steadytao) also reviewed the 3.4.4 backport set (PR #980).
- Bug reports from @mmayer (#924), @fda77 (#905), @darkshram (#900),
@ketas (#904), @pkzc (#880), @brabalan (#951), @elcamlost (#829),
@debohman (#896), @guilherme-puida (#959), @fufu65 (#915),
@JetAppsClark (#928), @moonlitbugs (#897), @mgkeeley (#910), and
@sylvain-ilm (#724, #725).
# NEWS for rsync 3.4.3 (20 May 2026)
## Changes in this version:
### SECURITY FIXES:
Six CVEs are fixed in this release. All six are assigned by
VulnCheck as CNA. Affected versions are 3.4.2 and earlier in every
case. Three of the six (CVE-2026-29518, CVE-2026-43617,
CVE-2026-43619) require non-default daemon configuration to reach:
the first and third need `use chroot = no` for a module, the second
needs `daemon chroot = ...` set in rsyncd.conf. Two (CVE-2026-43618,
CVE-2026-43620) are reachable from a normal pull or a normal
authenticated daemon connection. The sixth (CVE-2026-45232) is
reachable only when `RSYNC_PROXY` is set and the proxy (or a MITM)
returns a pathological response. Many thanks to the external
researchers who reported these issues.
- CVE-2026-29518 (CVSS v4.0 7.3, HIGH): TOCTOU symlink race condition
allowing local privilege escalation in daemon mode without chroot.
An rsync daemon configured with "use chroot = no" was exposed to a
time-of-check / time-of-use race on parent path components: a local
attacker with write access to a module could replace a parent
directory component with a symlink between the receiver's check and
its open(), redirecting reads (basis-file disclosure) and writes
(file overwrite) outside the module. Default "use chroot = yes" is
not exposed. `secure_relative_open()` (added in 3.4.0 for
CVE-2024-12086) was previously unused in the daemon-no-chroot
case; the fix enables it there and reroutes the sender's
read-path opens through it. Reported by Nullx3D (Batuhan Sancak),
Damien Neil and Michael Stapelberg.
- CVE-2026-43617 (CVSS v3.1 4.8, MEDIUM): Hostname/ACL bypass on an
rsync daemon configured with `daemon chroot = /X` in rsyncd.conf
when the chroot tree lacks DNS resolution support. The
reverse-DNS lookup of the connecting client was performed *after*
the daemon chroot had been entered; if /X did not contain the
libc resolver fixtures (`/etc/resolv.conf`, `/etc/nsswitch.conf`,
`/etc/hosts`, NSS service modules) the lookup failed and the
connecting hostname was set to "UNKNOWN", causing hostname-based
deny rules to silently fail open. IP-based ACLs are unaffected.
The per-module `use chroot` setting is unrelated to this issue.
The fix performs the lookup before entering the daemon chroot.
Reported by MegaManSec.
- CVE-2026-43618 (CVSS v3.1 8.1, HIGH): Integer overflow in the
compressed-token decoder enabling remote memory disclosure to an
authenticated daemon peer. The receiver accumulated a 32-bit
signed counter without overflow checking; a malicious sender could
trigger an overflow that, with careful manipulation, leaked process
memory contents to the attacker -- environment variables,
passwords, heap and library pointers -- significantly weakening
ASLR. The fix bounds the counter and adds wire-input validation in
several adjacent places (defence-in-depth). Workaround for older
releases: `refuse options = compress` in rsyncd.conf. Reported by
Omar Elsayed.
- CVE-2026-43619 (CVSS v3.1 6.3, MEDIUM): Symlink races on path-based
system calls in "use chroot = no" daemon mode (generalisation of
CVE-2026-29518). Earlier fixes for symlink races on the receiver's
open() call missed the same race class on every other path-based
system call: chmod, lchown, utimes, rename, unlink, mkdir, symlink,
mknod, link, rmdir and lstat. The fix routes each affected
path-based syscall through a parent dirfd opened under
RESOLVE_BENEATH-equivalent kernel-enforced confinement (openat2 on
Linux 5.6+, O_RESOLVE_BENEATH on FreeBSD 13+ and macOS 15+,
per-component O_NOFOLLOW walk elsewhere). Default "use chroot =
yes" is not exposed. Reported by Andrew Tridgell as a follow-on
audit of CVE-2026-29518.
- CVE-2026-43620 (CVSS v3.1 6.5, MEDIUM): Out-of-bounds read in the
receiver's recv_files() enabling remote denial-of-service of any
client pulling from a malicious server (incomplete fix of commit
797e17f). The earlier parent_ndx<0 guard added to send_files() was
not applied to the visually-identical block in recv_files(). A
malicious rsync server can drive any connecting client into a
deterministic SIGSEGV by setting CF_INC_RECURSE in the
compatibility flags and sending a crafted file list and transfer
record. inc_recurse is the protocol-30+ default, so no special
options are required on the victim. Workaround for older
releases: `--no-inc-recursive` on the client. Reported by Pratham
Gupta.
- CVE-2026-45232 (CVSS v3.1 3.1, LOW): Off-by-one out-of-bounds stack
write in the rsync client's HTTP CONNECT proxy handler
(`establish_proxy_connection()` in `socket.c`). After issuing the
CONNECT request, rsync read the proxy's first response line one
byte at a time into a 1024-byte stack buffer with the bound
`cp < &buffer[sizeof buffer - 1]`. If the proxy (or a MITM in
front of it) returned 1023+ bytes on that first line without a
newline terminator, `cp` exited the loop pointing at a buffer slot
the loop never wrote, leaving `*cp` holding stale stack data from
the earlier `snprintf()` of the outgoing CONNECT request. The
post-loop logic then wrote a single `\0` one byte past the end of
the buffer on the stack. Reach is client-side only, and only when
`RSYNC_PROXY` is set so rsync tunnels an `rsync://` connection
through an HTTP CONNECT proxy. The written byte is always `\0`
and the offset is fixed by the buffer size, not attacker-chosen,
so this is not an arbitrary-write primitive: practical impact is
corruption of one adjacent stack byte and possible later
misbehaviour or crash. The fix detects the "buffer filled without
finding `\n`" case explicitly by position and refuses the response
with "proxy response line too long". Reported by Aisle Research
via Michal Ruprich (rsync-3.4.1-2.el10 QE).
In addition to the six CVE fixes, this release adds defence-in-depth
hardening on several adjacent paths: bounded wire-supplied counts and
lengths in flist/io/acls/xattrs, a guard against length underflow in
cumulative `snprintf()` callers, a parent block-index bounds check on
the receiver, a NULL check in `read_delay_line()`, a lower ceiling on
`MAX_WIRE_DEL_STAT` to avoid signed-int overflow in the
`read_del_stats()` accumulator, rejection of hyphen-prefixed
remote-shell hostnames (defence-in-depth against argv-injection in
tooling that forwards untrusted input into the hostspec position;
reported by Aisle Research via Michal Ruprich), and a NULL-check on
`localtime_r()` in `timestring()` to keep a malicious server from
crashing the client by advertising a file with an out-of-range
modtime.
### BUG FIXES:
- Fixed a bypass of `--safe-links` when `--backup` is also used on a system that supports hard-linking symlinks (Linux, macOS). An escaping symlink that should have been skipped was silently preserved in the backup area.
- Fixed a spurious abort when using `-C` (cvs-exclude) mode with a `.cvsignore` file that contained a `!` (clear-list) token.
- Updated the `--max-alloc` documentation to reflect that 0 is now rejected (CVE-2026-53794).
- Fixed the EXIT VALUES table: removed nonexistent code 6, added missing codes 15/16/19, corrected SIGUSR1 classification.
- Fixed a regression introduced by the 3.4.0 secure_relative_open()
CVE fix where legitimate directory symlinks on the receiver side
(e.g. when using `-K` / `--copy-dirlinks`) caused "failed
verification -- update discarded" errors on delta transfers. The
old code rejected every symlink in the path with a per-component
`O_NOFOLLOW` walk; the receiver now uses kernel-enforced "stay
below dirfd" path resolution where available. Fixes #715.
### PORTABILITY / BUILD:
- secure_relative_open() now uses `openat2(RESOLVE_BENEATH |
RESOLVE_NO_MAGICLINKS)` on Linux 5.6+, and `openat()` with
`O_RESOLVE_BENEATH` on FreeBSD 13+ and macOS 15+ (Sequoia) /
iOS 18+. The kernel rejects ".." escapes, absolute symlinks, and
symlinks whose target lies outside the starting directory, while
still following symlinks that resolve within it -- the same
trade-off that fixes the issue #715 regression without weakening
the original CVE protection. Other platforms (Solaris, OpenBSD,
NetBSD, Cygwin) retain the previous per-component `O_NOFOLLOW`
walk; on those platforms the issue #715 regression remains
visible.
- testsuite/xattrs: ignore `SUNWattr_*` in the Solaris `xls`
helper.
### DEVELOPER RELATED:
- Added testsuite/symlink-dirlink-basis.test (taken from PR #864
by Samuel Henrique) covering the issue #715 regression and
several edge cases (`--backup`, `--inplace`, `--partial-dir`
with protocol < 29, top-level files). The test skips on
platforms without a RESOLVE_BENEATH equivalent.
- Added regression tests for the new security fixes:
`chmod-symlink-race.test`, `chdir-symlink-race.test`,
`bare-do-open-symlink-race.test`, `alt-dest-symlink-race.test`,
`copy-dest-source-symlink.test`, `sender-flist-symlink-leak.test`,
`secure-relpath-validation.test`, `daemon-chroot-acl.test` and
`daemon-refuse-compress.test`. The symlink-race tests skip on
Cygwin, Solaris, OpenBSD and NetBSD (no RESOLVE_BENEATH
equivalent on those platforms).
- runtests.py now errors early with a clear message when any of
the test helper programs (`tls`, `trimslash`, `t_unsafe`,
`t_chmod_secure`, `t_secure_relpath`, `wildtest`, `getgroups`,
`getfsdev`) are missing, instead of letting many tests fail with
confusing "not found" errors.
- Added OpenBSD and NetBSD CI jobs that run `make check` on those
platforms.
- Added Ubuntu 22.04 and AlmaLinux 8 CI workflows so future
backports to the two mainstream LTS families build and test on
the same CI surface as trunk.
- testsuite/protected-regular.test now runs unprivileged via
`unshare` with user-namespace UID mapping, falling back to skip
if `unshare`/`uidmap` is not available; previously it required
real root.
- Added `symlink-dirlink-basis` to the Cygwin CI's expected-skipped
list.
- Removed the old release system (replaced by the new release
script in 3.4.2).
------------------------------------------------------------------------------
# NEWS for rsync 3.4.2 (28 Apr 2026)
## Changes in this version:
### SECURITY RELATED:
Several security-relevant defects were reported and fixed since 3.4.1.
None were assigned a CVE — rsync's fork-per-connection design scopes
the impact of each of these to the attacker's own connection, which is
equivalent to the client closing the socket itself — but they are
fixed here as a matter of hygiene and to reduce the chances of a
future exploitable combination. Many thanks to the external
researchers who reported these issues.
- Fixed a signed integer overflow in the PROXY protocol v2 header
parser: a negative `len` field could bypass the size check and cause
a stack buffer overflow in `read_buf()`. Reported by John Walker of
ZeroPath.
- Fixed an invalid access to the files array. Reported by Calum
Hutton of Rapid7.
- Reject negative token values in the compressed-stream token
decoder; a negative value could cause callers to misinterpret a
missing data pointer as literal data. Reported by Will Sergeant.
- Fixed the element count passed to the xattr `qsort()` (see
https://www.openwall.com/lists/oss-security/2026/04/16/2).
- Fixed a buffer underflow in `clean_fname()`, and added a regression
test.
- Fixed an uninitialized `mul_one` in the AVX2 get_checksum1 path
(undefined behaviour), and added a SIMD-checksum self-test that
cross-checks SSE2, SSSE3 and AVX2 against the C reference on both
aligned and unaligned buffers.
- Fixed an uninitialized `buf1` on the first call to
`get_checksum2()` in the MD4 path (fixes #673).
- Zero all new memory from internal allocations: `my_alloc()` now uses
`calloc`, and `expand_item_list()` zeros the expanded portion after
`realloc`. This gives more predictable behaviour if stale or
uninitialised memory is ever accidentally read.
### BUG FIXES:
- Call `tzset()` before chroot so that log timestamps continue to
reflect the configured local timezone after the daemon chroots
(glibc needs `/etc/localtime`, which is unreachable post-chroot).
- Use the correct time when writing to the log file.
- Do not clear `DISPLAY` unconditionally.
- Fixed a Y2038 bug in `syscall.c` by replacing the `Int32x32To64`
macro (which truncates its arguments to 32 bits) with a plain
64-bit multiplication.
- Fixed ACL ID mapping for non-root users (closes #618).
- Fixed handling of objects with many xattrs on FreeBSD.
- Fixed `--open-noatime` not taking effect when opening regular
files: `O_NOATIME` is now also passed to `do_open_nofollow()`, which
has been used for regular files since the CVE fix "fixed symlink
race condition in sender".
- Ignore "directory has vanished" errors.
- Fixed the removal of multiple leading slashes.
- Added the missing `--dirs` long option.
- Fixed a segfault if `poptGetContext()` returns NULL (e.g. under
OOM) by not passing NULL to `poptReadDefaultConfig()`. Reported by
Ronnie Sahlberg; found with `malloc-fail-tester`.
- Fixed a build error on ia64 NonStop (which treats missing
prototypes as an error, not a warning).
- Fixed a flaky hardlinks test (fixes #735).
### ENHANCEMENTS:
- Added multi-threaded `zstd` compression, gated by a new
`--compress-threads=N` option, with validation and man-page
coverage.
- Documented the `temp dir` parameter in the rsyncd.conf man page
(fixes #820).
- Improved rendering of interior dashes in long-option names in
`md-convert` (perhaps fixes #686).
### PORTABILITY / BUILD:
- Fixed glibc 2.43 const-preserving overloads of `strtok()`,
`strchr()` etc. by declaring the affected locals with the right
constness. Contributed by Holger Hoffstätte.
- Converted the bundled zlib 1.2.8 from K&R-style function
definitions to ANSI prototypes, so it builds with clang 16+.
- Avoid using `bool` as an identifier; it is a keyword in C23.
- `configure.ac`: check for xattr functions in libc first and only
fall back to `-lattr`, avoiding spurious overlinking when `-lattr`
happens to be installed. Contributed by Eli Schwartz.
- Made the build reproducible by honouring `SOURCE_DATE_EPOCH` for
the manpage date.
- Removed obsolete `popt/findme.c` and `popt/findme.h` that upstream
popt 1.14 folded into `popt.c` (fixes #710). Contributed by Alan
Coopersmith.
### INTERNAL:
- Made many module-global variables `const` so they can live in
`.rodata` and enable additional compiler optimization.
### DEVELOPER RELATED:
- Replaced `runtests.sh` with `runtests.py`, a Python test runner
that supports `--valgrind` (with per-process log files so valgrind
output no longer interferes with output comparisons) and
`-j/--parallel` execution for roughly a 7× speed-up on typical
hardware.
- Added a SIMD checksum self-test and a `clean-fname-underflow`
regression test.
- Various CI fixes for macOS and Cygwin (including adding
`simd-checksum` to the expected-skipped lists on platforms without
SIMD), and tests now run on `ubuntu-latest`.
- removed support for the unmaintained rsync-patches archive
------------------------------------------------------------------------------
# NEWS for rsync 3.4.1 (16 Jan 2025)
Release 3.4.1 is a fix for regressions introduced in 3.4.0
## Changes in this version:
### BUG FIXES:
- fixed handling of -H flag with conflict in internal flag values
- fixed a user after free in logging of failed rename
- fixed build on systems without openat()
- removed dependency on alloca() in bundled popt
### DEVELOPER RELATED:
- fix to permissions handling in the developer release script
------------------------------------------------------------------------------
# NEWS for rsync 3.4.0 (15 Jan 2025)
Release 3.4.0 is a security release that fixes a number of important vulnerabilities.
@@ -52,6 +955,7 @@ to develop and test fixes.
- added FreeBSD and Solaris CI builds
------------------------------------------------------------------------------
# NEWS for rsync 3.3.0 (6 Apr 2024)
## Changes in this version:
@@ -4816,7 +5720,12 @@ to develop and test fixes.
| RELEASE DATE | VER. | DATE OF COMMIT\* | PROTOCOL |
|--------------|--------|------------------|-------------|
| 15 Jan 2025 | 3.4.0 | | 32 |
| 13 Aug 2026 | 3.5.0 | | 32 |
| 08 Jun 2026 | 3.4.4 | | 32 |
| 20 May 2026 | 3.4.3 | | 32 |
| 28 Apr 2026 | 3.4.2 | | 32 |
| 16 Jan 2025 | 3.4.1 | | 32 |
| 15 Jan 2025 | 3.4.0 | 15 Jan 2025 | 32 |
| 06 Apr 2024 | 3.3.0 | | 31 |
| 20 Oct 2022 | 3.2.7 | | 31 |
| 09 Sep 2022 | 3.2.6 | | 31 |
+11
View File
@@ -93,6 +93,15 @@ details.
[3]: https://rsync.samba.org/lists.html
DISCORD
-------
There is also an rsync [Discord server][d] for real-time chat about rsync
and its development.
[d]: https://discord.gg/Avfvy9zhdp
BUG REPORTS
-----------
@@ -136,6 +145,8 @@ COPYRIGHT
Rsync was originally written by Andrew Tridgell and Paul Mackerras. Many
people from around the world have helped to maintain and improve it.
Special thanks go to Wayne Davison, who maintained rsync from 2004 to 2024.
Rsync may be used, modified and redistributed only under the terms of
the GNU General Public License, found in the file [COPYING][9] in this
distribution, or at [the Free Software Foundation][10].
+531 -1
View File
@@ -9,4 +9,534 @@ help backporting fixes into an older release, feel free to ask.
Email your vulnerability information to rsync's maintainer:
Wayne Davison <wayne@opencoder.net>
Rsync Project <rsync.project@gmail.com>
## Approach to platform residuals
rsync hardens its security-sensitive operations — path resolution, metadata
application, file/socket creation — against local attacks such as parent-symlink
TOCTOU races. Some of these operations can only be made race-safe with a
primitive the underlying OS provides (an `*at()` syscall on a held directory fd,
an fdescfs-style `/proc/self/fd` magic symlink, `mknodat()`, the `*xattrat`
syscalls, and so on), and that primitive is not available on every supported
platform.
The guiding rule for those cases is:
> **On a modern Linux system every issue described in this document is fully
> addressed.** Where an operation *can* be secured on some platforms but *cannot*
> be secured on others, and the residual risk is a *local* privilege-escalation
> or data-disclosure class (an attacker who already has write access inside the
> transferred tree), rsync prefers keeping the operation functional on the
> platforms that lack the primitive over disabling a long-standing feature for
> everyone on those platforms.
So a hardened operation takes the race-safe path wherever the platform offers one
and falls back to the historical (path-based, unconfined) behaviour only where it
does not — rather than refusing the operation outright. Each such fallback is an
accepted residual, documented under "Known residuals" below, and on the daemon it
can be turned off per feature with `refuse options = ...`. The residuals are
therefore confined to non-Linux platforms (the BSDs, macOS, Solaris/illumos),
Cygwin, and — for a few features — pre-6.13 Linux kernels; a current, normally
configured Linux deployment carries none of them. (The `/proc/self/fd`-based
fallbacks assume a mounted `/proc`, which every standard Linux provides; a
deliberately `/proc`-less container is the one Linux case that can still hit a
residual.)
The one deliberate exception is an operation whose unconfined fallback would
*create a new filesystem object at an attacker-influenceable path* rather than set
metadata on the object rsync already transferred: the nested-socket `bind()` on
platforms without a race-safe socket-create (no `bindat()`). There the unsafe path
is an out-of-tree write/create primitive, not a same-object metadata race, and a
transferred socket inode is a worthless placeholder, so rsync refuses (skips) it
rather than keeping it functional. A leaf permission change is likewise failed
closed rather than applied through a raced symlink, but only as a rare backstop:
the common file/dir/FIFO case is secured on every platform via `fchmod` on a held
fd, so no real functionality is lost.
This trade-off applies only to these local-attacker residual classes. Remotely
reachable defects — memory safety, authentication bypass, protocol parsing, input
bounds — are fixed unconditionally on all platforms, never left as a residual.
## Robustness against malicious peers
rsync treats everything the peer sends — the file list, checksum headers,
multiplexed messages, forwarded daemon arguments, filter rules — as untrusted,
and bounds-checks it before use. A peer-triggerable crash of a connection's
worker process is treated as a defect to be fixed, even though the daemon's
fork-per-connection model confines such a fault to that one connection rather
than the whole service.
Alongside the issues enumerated elsewhere in this document, the code is hardened
continuously through protocol fuzzing (driving the daemon protocol against a
writable module) and static analysis, with a CI gate. This release closes a
batch of peer-triggerable faults found that way: NULL-dereference and
reachable-assert crashes from crafted file lists or indices, reads past a
file-list allocation (mostly bounded over-reads of an entry's extra slots),
unbounded merge-file and suffix-list recursion, and several bounded
out-of-bounds writes driven by peer-supplied lengths or option arguments. Each
is fixed at the root with a bounds or validity check plus a defence-in-depth
guard at the use site, and carries a regression test.
Two further peer-input hardenings in this release: a peer-supplied I/O-error
value (the `MSG_IO_ERROR` message and the file-list trailer) is masked to the
defined `IOERR_*` bits, so a peer cannot set arbitrary error flags in the local
`io_error` that would be stored and re-forwarded upstream; and control
characters in a (peer-controlled) filename written to the log file are escaped,
so a name carrying C0/C1 terminal-escape bytes cannot inject sequences into an
administrator's terminal when the log is viewed (CWE-117). The number of
equal-weak-checksum blocks `hash_search()` examines per offset is also bounded
(issue #217), so a crafted or degenerate checksum set with a very long
equal-checksum chain cannot drive the sender's per-offset match-verify into a
quadratic walk and pin one connection's CPU.
Contributors adding code that consumes peer input should validate it at the
point of receipt rather than relying on a downstream check.
## Symlink-race-safe path resolution
This section documents how rsync defends against parent-directory symlink races
(a TOCTOU / confused-deputy class) and the per-platform approach it takes, so
that contributors and automated agents extend the code consistently rather than
reintroducing the weakness.
### The threat
Many rsync operations resolve pathnames that an unprivileged party can partially
control: a receiver writing into a destination tree, a sender reading a source
tree, and temp and partial files, and so on. (The operator-chosen directory
paths — `--link-dest`/`--compare-dest`/`--copy-dest`/`--backup-dir`/`--temp-dir`/
`--partial-dir` — may legitimately point outside the tree, so they are resolved
by the ownership walk described under *Symlink defense for operator-supplied
paths* below rather than the strict transfer-path resolver here.) If someone who
can write inside that tree races a
parent directory component between a real directory and a symlink ("symlink
flipping"), a path-based syscall — `open`, `stat`, `chmod`, `chown`, `utimes`,
`rename`, `unlink`, `mkdir`, `mknod`, `symlink`, hard-link creation — can be
redirected to a target *outside* the intended tree. When rsync resolves that
path with more authority than the component's controller and without a
confinement boundary, this is a confused-deputy bug (e.g. a root nightly backup
capturing `/etc/shadow`, or a root receiver chmod/chown/unlink-ing a system
file).
`O_NOFOLLOW` on the final component is **not** sufficient: the *parent*
components must be resolved safely.
The boundary that matters is **authority plus confinement**, not "daemon vs
non-daemon". A non-chroot daemon module, a root-run local transfer, and a
two-user transfer are all unconfined privileged path resolvers. Where a real
confinement boundary already exists (e.g. a per-module `chroot`) that is the
strongest protection; otherwise rsync must resolve paths defensively.
A `chroot` is only a boundary for the *outer* path it confines. A daemon module
written as `path = /outer/./inner` (`use chroot = yes`) chroots to `/outer` but
treats `/inner` as the module root, so a symlink inside the module that points to
a sibling of `/inner` is still inside the chroot yet outside the module — the
inner module therefore needs the same defensive resolution as a non-chroot
module. The single gate that decides when hardened resolution applies is
"unconfined privileged resolver": `am_daemon && (!am_chrooted || module_dirlen)`
for the daemon (any non-chroot module, plus a `/./` inner-module chroot), and any
non-chroot receiver. The local sender's content open is confined the same way for
default symlink handling; only the symlink-following modes (`-L`/`--copy-links`/
`--copy-unsafe-links`/`-k`) and `--insecure-links` are excluded, so those keep
following symlinks by design.
### The mechanism
Resolution of attacker-influenceable paths goes through `secure_relative_open()`
and the `do_*_at()` wrappers in `syscall.c`, never a raw `open()`/`rename()`/
`chmod()` on a full path string. The principle is: **trust the operator-named
transfer root, and confine all resolution beneath it**, rejecting escapes via
`..` above the anchor, absolute symlinks, or out-of-tree symlinks.
`secure_relative_open()` resolves the parent directory by walking it one
component at a time on a stack of held directory fds, then operates on the final
component with an at-style call on the resulting directory fd.
For per-entry work the receiver and generator go one step further and hold the
parent directory open: `open_dir_secure()` resolves an entry's directory once
(via `secure_relative_open()`), `held_dfd_for()` caches that descriptor for the
duration of the entry, and every operation on the entry — `lstat`, the temp-file
`mkstemp`, the temp->final `rename`, `chmod`/`chown`/`utimes`, `mkdir`, special-
file and symlink creation, the delta-basis open, and the recursive delete — runs
through that one held fd via an `*at()` call (`do_*_atfd()`). Because the
descriptor is pinned to the directory inode, a parent component flipped to a
symlink *after* the open cannot redirect any of those operations. The alternate-
destination lookups are confined the same way (`basis_link_stat()` in
`generator.c` and `secure_basis_open()` in `receiver.c`), so a peer-chosen
`--link-dest`/`--compare-dest`/`--copy-dest` basis index cannot reach an
out-of-module file through a symlinked parent.
The sender's source-directory *enumeration* is confined the same way as its
content open. `send_directory()` opens each scanned directory through
`secure_opendir()` — which resolves it via `secure_relative_open()` /
`secure_relative_open_at()` and turns the held fd into the `DIR*` with
`fdopendir()` — so a parent component raced into a symlink, or (for a daemon
following mode) an in-module symlink pointing outside the module, cannot redirect
the scan to enumerate an out-of-tree directory and leak its entry names, metadata
and symlink targets. For a daemon, both the enumeration and the content open
anchor at the served module root **pinned by identity**: `module_dirfd` is opened
(`open(".")`) the moment the daemon `chdir`s into the module, while still
privileged, and module-relative paths resolve beneath that fd via
`secure_relative_open_at()`. Anchoring at the held fd rather than re-resolving the
absolute module path keeps the confinement working after the daemon drops to the
module uid even when the module sits under a directory that uid cannot traverse
(e.g. a `0700` home — re-resolving the absolute path would `EACCES`), and is
immune to the logical-path-versus-real-cwd skew a followed in-tree directory
symlink would otherwise introduce.
### Path resolution
`secure_relative_open()` resolves a path with a single portable mechanism on
every platform: a per-component walk on a stack of held directory fds. Each
component is opened relative to the held parent with `openat(parent_fd,
"component", O_NOFOLLOW)`; descending into a real subdirectory pushes its fd, a
`..` pops back to the already-held parent (a pop at the anchor is refused), and an
in-tree directory symlink is followed by reading its target and walking that off
the same stack (absolute targets refused, symlink hops bounded). The final
component is opened `O_NOFOLLOW`.
Because every component is opened relative to a *pinned* fd under `O_NOFOLLOW`,
and `..` is resolved by the held-fd stack rather than by the kernel, the walk is
race-free by construction: no rename or symlink swap of any path name can redirect
resolution outside the anchor subtree, and no kernel "beneath" primitive
(`openat2(RESOLVE_BENEATH)` / `openat(O_RESOLVE_BENEATH)`) is required. The
confinement is therefore uniform across Linux, the BSDs, macOS and
Solaris/illumos, on old and new kernels alike, with nothing to probe or fall back
to at runtime (and so no `openat2`/seccomp interaction to worry about in sandboxed
environments). Cygwin is the exception, because its directory descriptors and
symlink emulation do not give the held-fd walk the same inode pinning — see the
Cygwin residual below.
Legitimate *in-tree* directory symlinks are followed, so `--keep-dirlinks` /
`--copy-links` and a symlinked module path keep working. A relative alternate-dest
such as `--compare-dest=../01` may legitimately climb to a sibling still inside the
module; such a `..` path is re-anchored at the module root and its in-module climb
adjudicated by the walk (the `..` pops to the held parent), while escapes above the
anchor are still rejected.
### Leaf operations
The final operation is hardened as well, following `cp`: reads use `O_NOFOLLOW`
so a flipped leaf symlink is not followed, and new or destination files are
created with `O_CREAT|O_EXCL` (rsync's temporary files use `mkstemp`) so a
planted symlink at the target cannot be written through. A leaf `chmod` is the
one operation with no portable no-follow form: it is closed by opening the leaf
`O_RDONLY|O_NOFOLLOW` and `fchmod`-ing the held fd (refusing a symlink leaf with
`ELOOP`), falling back to `fchmodat(AT_SYMLINK_NOFOLLOW)` and then the
`fchmodat2()` syscall, and failing closed with a warning rather than ever
chmod-ing through a raced leaf symlink.
### Guidance for contributors
* When adding code that performs a path-based syscall on a path that can be
influenced by the remote peer or by another local user, use a `do_*_at()`
wrapper (or `secure_relative_open()`), not a raw full-path syscall.
* When introducing a new operation, add a matching `do_<op>_at()` wrapper that
resolves the parent with `secure_relative_open()` and acts via an at-style call
on the returned dirfd.
* Do not assume a non-daemon transfer is safe; the question is whether rsync has
more authority than whoever controls the path components.
* On platforms whose API lacks an at-style equivalent (e.g. `setattrlist()`),
follow the residuals policy at the top of this document: for a metadata
operation on the already-transferred object (ACLs, xattrs, crtimes, permissions)
fall back to the path-based call to keep the feature functional and document the
residual; but where the unsafe fallback would *create a new object on an
unconfined path* (the nested-socket `bind()` case), refuse it instead — that is
an out-of-tree write/create primitive, not a same-object metadata race, and the
lost functionality is negligible.
## Symlink defense for operator-supplied paths
rsync opens several operator-supplied paths during normal operation. These fall
into two groups, both governed by the same ownership-walk policy below:
* operator **files**: `--log-file`, `--password-file`, `--early-input` (a client
read whose contents are forwarded to the daemon's early-exec), `--files-from`,
`--include-from`, `--exclude-from`, `--filter=. file`, `--write-batch`,
`--read-batch`, per-directory filter merge files (`-C` / `-F` / `dir-merge`),
and on the daemon side `motd file =`, `secrets file =`, `lock file =`, and
`rsyncd.conf` itself.
* operator **directories**: `--backup-dir`, `--temp-dir`/`-T`, `--partial-dir`,
and the `--link-dest`/`--compare-dest`/`--copy-dest` basis lookup. These take
a directory the operator chose, which may legitimately point outside the
transfer tree (`--backup-dir=/var/backups`), so they are resolved with the
ownership walk rather than the strict transfer-path resolver.
The daemon module-root `chdir()` under
`use chroot = no` and the non-daemon receiver's `chdir()` into the
operator-named destination directory are in the same class: both follow
the operator's/root's own symlinked target (the `/backup -> /mnt/disk`
admin pattern) but refuse one an attacker raced in from another uid,
unless `--insecure-links` restores the legacy plain `chdir()`.
Each of these reads or writes a path the operator or sender chose, which
may transit attacker-influenceable parent directories (the `/tmp/somedir/`
class) or be planted directly (the `/home/$user/.cvsignore` class when
root runs `rsync -a /home /backup`).
rsync's defense, applied uniformly to all of the above, is a
component-by-component path walk (`open_no_attacker_symlinks` in
`util1.c`) that allows symlinks **only** when the symlink itself is owned
by uid 0 or the running process's effective uid. Symlinks owned by any
other uid are refused with `ELOOP` at any path component (parent or leaf).
Plain `O_NOFOLLOW` would be leaf-only and would not defend the
`/tmp/somedir/log` parent-component plant; this walk does.
The trust model preserves legitimate setups such as `/var/log -> /data/log`
(root-owned dir-symlink) and a non-root user's own `~/log -> /data/me`
symlink; it refuses an attacker's `/tmp/somedir -> /attack/path` plant.
For `--read-batch` an additional `fstat()` check refuses non-regular
files (FIFOs, devices) at the batch path, since the batch content drives
the receiver's protocol parser.
**Policy.** A symlink at **any** path component (parent or leaf) is **followed
iff it is owned by uid 0 or the process's effective uid, and refused (`ELOOP`)
otherwise**, identically for **absolute and relative** operator paths. The trust
signal is **authority (ownership)**, not **location**: an operator path may
legitimately point outside the transfer tree, so it cannot be confined by
location the way a transfer path is. This is deliberately distinct from the
transfer-path resolver `secure_relative_open()` (see *Symlink-race-safe path
resolution* above), which refuses **all** symlinks and anchors **beneath the
transfer root** — correct for peer-named paths, which never legitimately escape.
For the operator directory paths, a refused symlink simply makes the target look
absent (no backup/temp/basis is taken through it) and the transfer proceeds
normally; the operator's own symlinked target keeps working.
**The daemon `exclude`/`filter` chain is not a symlink boundary.** The daemon
filter chain (`exclude`, `exclude from`, `filter`, …) matches the *logical*
module-relative **name** of each item, not the physical file it resolves to. It
is a visibility/tamper filter — a peer cannot *name* a daemon-excluded path to
pull, push to, or delete it — but it is **not** a security boundary against
symlinks: an in-module symlink whose own name is not excluded can be followed to
an excluded target (the name the filter sees, e.g. `link`, is not the excluded
name, e.g. `secret`). This is by design and is the long-standing behaviour of
stock rsync; the defense for a writable module against symlink trickery is
`munge symlinks` (enabled by default for a writable, non-chrooted module), **not**
the filter. Do not rely on `exclude`/`filter` to confine a peer who can introduce
or traverse a symlink; see `rsyncd.conf(5)` ("filter" and "munge symlinks").
What *is* enforced for a *peer-supplied* operator path (`--partial-dir`,
`--backup-dir`, the alt-dest basis) is confinement to the **module root**: the
ownership walk refuses a foreign-uid symlink (the symlink-race defense) and
refuses a resolved target *outside* the module. That module-boundary confinement
is independent of `exclude`/`filter` — it holds whether or not the module sets an
exclude — and is what the operator-path tests cover.
**`--insecure-links`.** This flag is a **local** opt-out that restores the legacy
follow-any-symlink behaviour for the paths above. It is **not forwarded** to the
remote (a remote-shell peer that wants the opt-out must set it on its own side,
e.g. via `--rsync-path`), and a **daemon never honors it**: the opt-out predicate
reads the client-controllable flag only off a daemon, so a peer-forwarded or
`-M`-injected `--insecure-links` cannot weaken a daemon's confinement — the daemon
additionally hard-refuses it (drops the connection) via the refused-options path.
A daemon admin who wants the legacy behaviour for one isolated/trusted module
sets `insecure links = yes` in that module's `rsyncd.conf` stanza (see
`rsyncd.conf(5)`); this is admin-only and re-opens the symlink-escape
vulnerabilities for that module on purpose. The `operator-path-*` and
`insecure-links-*` tests enforce this consistency across every path-taking
option and across absolute/relative, leaf/parent, and same-uid/cross-uid plants.
For `support/rrsync` (the SSH-restricted-rsync wrapper), the same TOCTOU
class is closed in Python by opening each validated path component with
`O_RDONLY|O_NOFOLLOW`, verifying via `readlink('/proc/self/fd/N')` that the
pinned inode is still in-tree, and passing `/proc/self/fd/N` as the exec'd
rsync's argument (so the kernel routes the child's open through the pinned
inode rather than re-resolving the path). A receiver-side new destination
has no inode of its own yet, so its existing parent directory is pinned the
same way and the leaf is created at `/proc/self/fd/<parent>/<leaf>`. This pin
relies on an fdescfs-style magic symlink and is not available on every
platform -- see the rrsync residual below.
### Known residuals
The following are documented as out of scope for this release:
* The source-directory *enumeration* confinement needs `fdopendir()` (to form a
`DIR*` from the securely-resolved held fd) and `dirfd()`; on a platform lacking
either, `send_directory()` falls back to the legacy `opendir()` on the path, so
the scan is unconfined there — the same resolver-fallback shape as the other
`*at()`-less residuals. Every current target provides both; the per-entry
operations and the content open remain confined regardless.
* On **Cygwin**, the per-component held-fd walk does not provide the same
inode-pinning guarantee as on a POSIX kernel: Cygwin tracks a process's
current directory and resolves directory descriptors by path name rather than
by a pinned inode, and emulates symlinks as special files. Static out-of-tree
symlinks are still refused (the walk sees and rejects them), and a daemon
module path anchored at an absolute `module_dir` is confined; but an entry
whose parent component is *raced* from a directory to a symlink mid-resolution
can still slip past confinement that is anchored at the process CWD (e.g. the
sender's content open), because the descriptor is not bound to the original
inode. The parent-component symlink-race tests are therefore not enforced on
Cygwin (see `RSYNC_EXPECT_SKIPPED` in `.github/workflows/cygwin-build.yml` and
the Cygwin-only xfail in `symlink-race-source_test.py`). Cygwin is a
development/interoperability target, not a privilege boundary host, so this is
accepted for this release.
* On a platform with no `mknodat()` at all -- macOS before 13 is the
supported example, where `mknod()` and `mkfifo()` exist but neither
`mknodat()` nor `mkfifoat()` does -- creating a device node or FIFO
falls back to plain `do_mknod()`, which resolves the whole path by name.
What is lost is the *pinned parent*: the directory components are
re-resolved by the kernel at create time, so an attacker who can swap a
parent component races the create and can place the node outside the
transfer. The final component is not at risk -- `mknod()` and
`mkfifo()` do not follow a symlink at the leaf, they fail `EEXIST`.
Where `AT_FDCWD` exists -- which is every platform rsync 3.5.0 supports,
macOS 10.13 included -- fake-super placeholders still return through
`openat(..., O_NOFOLLOW)`, reached before either `*at` primitive is
tested, so ordinary in-tree placeholder creation stays confined;
fake-super loses parent confinement and the `O_NOFOLLOW` leaf only on the
paths that reach plain `do_mknod()` (the cache-declined/cross-tree
wrapper and the backup paths). On a build with no `AT_FDCWD` at all
there is no fd-relative primitive of any kind, so nothing above applies
and every special-file create, fake-super included, is unconfined. Transferring specials there (`--devices`, `--specials`) carries
the parent-component race. `symlink-mknod-fakesuper-symlink-race` skips
itself on such a build, since the property it asserts is one the build
deliberately does not have.
* On platforms where `mknod()`/`mknodat()` cannot create a socket inode
(the BSDs, macOS, Solaris), a transferred socket is recreated with
`socket()` + `unlink` + `bind(path)`, which cannot be confined (there is
no portable `bindat()`). Linux creates it race-safely with `mknodat()`
on a held dirfd; on the others a *nested* socket is skipped with a
warning rather than bound on an unconfined path, leaving only a
top-level, operator-named socket binding by path.
* `support/rrsync`'s race-free inode-pin -- of both existing path
components and a new destination's parent -- depends on materialising a
held fd as a path that the exec'd rsync re-resolves to the same inode.
rrsync validates and pins in its own process, but it then *exec*s a
separate rsync that re-resolves the paths from `argv`, so the confining
reference must be expressible as an argument. A held dirfd is not: it is
usable as a path only through an fdescfs-style magic symlink. rrsync
implements this for Linux only, via `/proc/self/fd/N`; it does not use the
`/dev/fd/N` equivalent that macOS/FreeBSD expose with `fdescfs` mounted. So on
every non-Linux platform (the BSDs, macOS, Solaris -- whose `/proc/self/fd`
entries are not magic symlinks -- and Cygwin), and on a `/proc`-less Linux
namespace, rrsync falls through to the realpath-validated path unpinned,
so a parent-component or between-pin-and-exec flip remains possible there;
a deeper `-R` new path whose parent does not exist yet is likewise
unpinned. The portable closure is an rsync-side fd-passing API -- rrsync
hands rsync the confined dirfd (inherited across `exec`) and rsync
resolves that argument relative to it with the same `secure_relative_open`
resolver the daemon uses, needing no magic-symlink filesystem -- a
protocol/CLI addition under discussion on the rsync-security list.
* The operator-directory ownership walk refuses a foreign-owned symlink on a
`--backup-dir`/`--temp-dir`/`--partial-dir`/`--link/compare/copy-dest` path, so
a *statically planted* symlink is rejected and the dependent operation does not
escape. Both the data writes and the *source-metadata reads* of those
operations are now confined to held no-follow fds: the `--copy-dest`
`copy_file()`/`copy_xattrs()` source read goes through the held basis content fd
(`sys_fgetxattr`), and `make_backup()` reads the backed-up file's ACL/xattrs
through a `backup_source_fd()`-pinned fd -- so a parent-component flip can no
longer redirect them to disclose an out-of-module value. The cross-tree
metadata *apply* on those leaves (the `%stat`/ACL/xattr write on a
`--temp-dir`/`--backup-dir` staging file) is fd-pinned the same way, now
including under `--fake-super`: the `set_file_attrs()` no-follow leaf fd was
previously opened only when `am_root >= 0`, so a `fake super = yes` daemon fell
back to a path-based `sys_lsetxattr()`/chmod a raced parent could redirect; the
pin is now opened for fake-super too (a raced leaf is refused, not redirected).
Two narrow follow-ons
re-resolve the (now-validated) operator path by name and remain a
*post-validation* parent-component race:
* the in-place backup (`--inplace --backup`) writes the backup file's data
through a confined create, but its `set_file_attrs()` metadata set
(chmod/chown/times) re-resolves the `--backup-dir` path by name afterwards
(it is not placed under operator mode, which would force the shared
`set_file_attrs()` path off its held-O_NOFOLLOW-fd xattr write and re-open
the very parent-symlink xattr race `copy-xattrs-symlink-race` pins closed); and
* the abbreviated-xattr optimisation reuses a basis xattr value for the
destination only when its checksum matches the digest the sender sent; that
basis read (`rsync_xal_set()`) re-resolves the basis path by name. This is a
*constrained checksum-oracle*, not a disclosure: it confirms that some raced
out-of-module xattr hashes to a value the sender already chose, rather than
copying an unknown value onto a readable file, and needs a colluding sender
plus a local racer.
An attacker who flips a parent component in the window *after* the confined data
write/stat can thus still affect those narrow metadata/oracle operations. This
is the same local-attacker post-confinement TOCTOU class as the ACL/crtimes
residuals below; the data-write and direct source-read escapes are closed, and
`--insecure-links` (or a module's `insecure links = yes`) is orthogonal to it.
* POSIX ACL application (`-A`/`--acls`) is race-safe on every Linux kernel —
6.13+ via the `*xattrat` syscalls (or a patched libacl's `*_at` bindings), and
older kernels via the `/proc/self/fd` compat that pins the same inode, provided
`procfs` is mounted — and a transferred file/dir/FIFO has its xattrs (`-X`)
applied through the held no-follow fd, so the apply cannot be redirected by a
raced parent component. Where neither primitive is available — the BSDs,
Solaris and macOS (no `*xattrat` syscalls and no `/proc/self/fd` magic
symlinks), plus the edge case of a Linux instance with no usable `/proc` (a
`/proc`-less container/namespace) — the ACL apply falls back to the path-based
`acl_set_file()` /
`sys_acl_*file()` calls — the long-standing 3.4.x behaviour — to keep `--acls`
functional rather than silently skipping it, so a parent-component flip can
have the received ACL written onto an object outside the module/destination
boundary (and, because the attacker controls the ACL bytes, granted to a chosen
uid). As with the macOS crtime tier below, this is an accepted residual under
the functionality-over-refusal policy; a daemon operator who does not want it
can disable the feature with `refuse options = acls`.
* macOS creation-time (`--crtimes`) preservation uses the path-based
`setattrlist()`/`getattrlist()` with `FSOPT_NOFOLLOW`, which protects only
the final component; there is no `setattrlistat()` targeting
`ATTR_CMN_CRTIME`. As with POSIX ACLs where the OS offers no race-safe
primitive, `--crtimes` is kept functional (daemon and non-daemon) and the
parent-component symlink race is an accepted residual: an attacker who
flips a parent component can have a crtime read/write target an object
outside the module/destination boundary. The mtime/atime path is *not*
affected -- `set_times()` resolves it race-safely through `utimensat()` on a
held dirfd in hardened mode. A daemon operator who does not want the crtime
residual can disable the feature with `refuse options = crtimes` in
`rsyncd.conf`.
* Pulling with `-o`/`-g` (or `-a`) **as root from an untrusted sender** is by
design a trust relationship, not a confinement boundary: the sender dictates
each received file's owner/group, including uid/gid 0. rsync maps the
sender's id/name pairs through the local id database; an empty or unknown
sender name falls back to the sender's numeric id (the value `--numeric-ids`
would use), and a sender can equally request root via the literal name
`root`. A root receiver must therefore only pull with `-o`/`-g` from a
trusted source (or use a non-root receiver / a uid-gid policy). The daemon
*name-converter* path is guarded separately — an unknown name there maps to
the sender's numeric id rather than 0 (see `clientserver.c`).
## Daemon authentication digest
Daemon authentication is a secret-prefix challenge-response: the client returns
`base64(H(secret || challenge))`, where `H` is a digest the two sides negotiate.
The negotiation is unauthenticated and ordered by the connecting side, and the
`md5`/`md4` digests remain available for backward compatibility, so a peer that
sends no digest list (any rsync before 3.2.0, including the openrsync that ships
with macOS) falls back to `md5` (or `md4` below protocol 30), and an on-path
attacker can rewrite the negotiation to force `md5`/`md4` even between two modern
peers. This is **not** an authentication bypass — `md4`/`md5` have no practical
preimage break — but a weak digest makes a *captured* `(challenge, response)`
pair far cheaper to brute-force offline, recovering a guessable shared secret.
The challenge itself is seeded from the kernel CSPRNG (`/dev/urandom`), so it is
an unpredictable per-connection nonce. An earlier time/pid-based challenge was
low-entropy enough (~35 bits) that recovering the `(sec, usec, pid)` tuple from
one observed challenge let an on-path observer predict every subsequent challenge
from that daemon process and pre-compute a dictionary against a captured
response. (If `/dev/urandom` is unavailable the daemon logs a warning and falls
back to the legacy time-based challenge rather than a constant.)
A daemon operator whose clients are all modern (rsync 3.2.7+ built with openssl,
when the SHA digests were added) can require a strong digest with the `auth
digest` module parameter, e.g. `auth digest = sha256`, which refuses any
connection that negotiates — or falls back to — a weaker digest (see
`rsyncd.conf`).
Residual: there is **no default floor**, because requiring one would break every
pre-3.2.0 client (notably the macOS-bundled openrsync, which authenticates only
with `md4`). An operator who cannot raise the floor should run the daemon behind
a verified TLS transport (`rsync-ssl`/stunnel) or over ssh — which removes the
on-path capture/downgrade vector at the transport layer — and should use a
high-entropy shared secret, which is infeasible to brute-force regardless of the
digest.
-11
View File
@@ -15,7 +15,6 @@ Create more granular verbosity 2003/05/15
DOCUMENTATION --------------------------------------------------------
Keep list of open issues and todos on the web site
Perhaps redo manual as SGML
LOGGING --------------------------------------------------------------
Memory accounting
@@ -213,16 +212,6 @@ DOCUMENTATION --------------------------------------------------------
Keep list of open issues and todos on the web site
-- --
Perhaps redo manual as SGML
The man page is getting rather large, and there is more information
that ought to be added.
TexInfo source is probably a dying format.
Linuxdoc looks like the most likely contender. I know DocBook is
favoured by some people, but it's so bloody verbose, even with emacs
support.
+22 -8
View File
@@ -28,7 +28,7 @@ static int allow_forward_dns;
extern const char undetermined_hostname[];
static int match_hostname(const char **host_ptr, const char *addr, const char *tok)
static int match_hostname(const char **host_ptr, const char *addr, const char *tok, int deny)
{
struct hostent *hp;
unsigned int i;
@@ -54,8 +54,14 @@ static int match_hostname(const char **host_ptr, const char *addr, const char *t
return 0;
/* Now try forward-DNS on the token (config-specified hostname) and see if the IP matches. */
if (!(hp = gethostbyname(tok)))
return 0;
if (!(hp = gethostbyname(tok))) {
/* A deny-list hostname token we cannot resolve must fail CLOSED:
* we can't prove the peer isn't the denied host, so treat the
* unresolvable token as a match (deny). Allow-list tokens keep
* failing as a non-match. Sibling of CVE-2026-43617, which fixed
* only the reverse-lookup path. */
return deny;
}
for (i = 0; hp->h_addr_list[i] != NULL; i++) {
if (strcmp(addr, inet_ntoa(*(struct in_addr*)(hp->h_addr_list[i]))) == 0) {
@@ -99,7 +105,7 @@ static void make_mask(char *mask, int plen, int addrlen)
return;
}
static int match_address(const char *addr, const char *tok)
static int match_address(const char *addr, char *tok)
{
char *p;
struct addrinfo hints, *resa, *rest;
@@ -243,7 +249,7 @@ static int match_address(const char *addr, const char *tok)
return ret;
}
static int access_match(const char *list, const char *addr, const char **host_ptr)
static int access_match(const char *list, const char *addr, const char **host_ptr, int deny)
{
char *tok;
char *list2 = strdup(list);
@@ -251,7 +257,7 @@ static int access_match(const char *list, const char *addr, const char **host_pt
strlower(list2);
for (tok = strtok(list2, " ,\t"); tok; tok = strtok(NULL, " ,\t")) {
if (match_hostname(host_ptr, addr, tok) || match_address(addr, tok)) {
if (match_hostname(host_ptr, addr, tok, deny) || match_address(addr, tok)) {
free(list2);
return 1;
}
@@ -275,7 +281,7 @@ int allow_access(const char *addr, const char **host_ptr, int i)
/* If we match an allow-list item, we always allow access. */
if (allow_list) {
if (access_match(allow_list, addr, host_ptr))
if (access_match(allow_list, addr, host_ptr, 0))
return 1;
/* For an allow-list w/o a deny-list, disallow non-matches. */
if (!deny_list)
@@ -284,9 +290,17 @@ int allow_access(const char *addr, const char **host_ptr, int i)
/* If we match a deny-list item (and got past any allow-list
* items), we always disallow access. */
if (deny_list && access_match(deny_list, addr, host_ptr))
if (deny_list && access_match(deny_list, addr, host_ptr, 1))
return 0;
/* Allow all other access. */
return 1;
}
int allow_proxy_protocol_peer(const char *list, const char *addr, const char **host_ptr)
{
if (!list || !*list)
return 0;
allow_forward_dns = 0;
return access_match(list, addr, host_ptr, 0);
}
+385 -16
View File
@@ -21,6 +21,10 @@
#include "rsync.h"
#include "lib/sysacls.h"
#include "lib/acl.h"
#ifdef HAVE_LIBACL_AT
#include <fcntl.h> /* AT_EMPTY_PATH / AT_SYMLINK_NOFOLLOW */
#endif
#ifdef SUPPORT_ACLS
@@ -469,11 +473,129 @@ static int find_matching_rsync_acl(const rsync_acl *racl, SMB_ACL_TYPE_T type,
return *match;
}
static int get_rsync_acl(const char *fname, rsync_acl *racl,
SMB_ACL_TYPE_T type, mode_t mode)
/* These two bridge lib/acl.c's neutral (tag,perm,id) entry array; with
* HAVE_LIBACL_AT the libacl *_at path uses unpack_smb_acl/pack_smb_acl directly,
* so they are unused there. */
#if defined(SUPPORT_ACL_FD) && !defined(HAVE_LIBACL_AT)
/* Convert a packed system ACL into the neutral (tag,perm,id) entry array that
* lib/acl.c serializes. Reuses pack_smb_acl()+change_sacl_perms() output so
* the bytes we write match exactly what acl_set_file() would have written.
* Returns the entry count and a malloc'd array in *ents_p, or -1 on error. */
static int sacl_to_entries(SMB_ACL_T sacl, rsync_acl_ent **ents_p)
{
static item_list ent_list = EMPTY_ITEM_LIST;
SMB_ACL_ENTRY_T entry;
rsync_acl_ent *out;
int rc;
ent_list.count = 0;
for (rc = sys_acl_get_entry(sacl, SMB_ACL_FIRST_ENTRY, &entry); rc == 1;
rc = sys_acl_get_entry(sacl, SMB_ACL_NEXT_ENTRY, &entry)) {
SMB_ACL_TAG_T tag_type;
uint32 access;
id_t g_u_id;
rsync_acl_ent *e;
uint16_t tag;
if ((rc = sys_acl_get_info(entry, &tag_type, &access, &g_u_id)) != 0)
break;
switch (tag_type) {
case SMB_ACL_USER_OBJ: tag = RACL_USER_OBJ; break;
case SMB_ACL_USER: tag = RACL_USER; break;
case SMB_ACL_GROUP_OBJ: tag = RACL_GROUP_OBJ; break;
case SMB_ACL_GROUP: tag = RACL_GROUP; break;
case SMB_ACL_MASK: tag = RACL_MASK; break;
case SMB_ACL_OTHER: tag = RACL_OTHER; break;
default: continue; /* skip an unrecognized tag */
}
e = EXPAND_ITEM_LIST(&ent_list, rsync_acl_ent, -10);
e->tag = tag;
e->perm = access & 7;
e->id = (tag == RACL_USER || tag == RACL_GROUP) ? (uint32_t)g_u_id : RACL_UNDEFINED_ID;
}
if (rc) {
rsyserr(FERROR_XFER, errno, "sacl_to_entries: sys_acl_get_entry/info()");
return -1;
}
out = new_array(rsync_acl_ent, ent_list.count ? ent_list.count : 1);
if (ent_list.count)
memcpy(out, ent_list.items, ent_list.count * sizeof (rsync_acl_ent));
*ents_p = out;
return ent_list.count;
}
/* Unpack a neutral entry array (from lib/acl.c) into an rsync_acl, mirroring
* unpack_smb_acl()'s tag handling. */
static BOOL unpack_acl_entries(const rsync_acl_ent *ents, int n, rsync_acl *racl)
{
static item_list temp_ida_list = EMPTY_ITEM_LIST;
int i;
temp_ida_list.count = 0;
for (i = 0; i < n; i++) {
uint32 access = ents[i].perm & 7;
id_access *ida;
switch (ents[i].tag) {
case RACL_USER_OBJ:
if (racl->user_obj == NO_ENTRY)
racl->user_obj = access;
continue;
case RACL_GROUP_OBJ:
if (racl->group_obj == NO_ENTRY)
racl->group_obj = access;
continue;
case RACL_MASK:
if (racl->mask_obj == NO_ENTRY)
racl->mask_obj = access;
continue;
case RACL_OTHER:
if (racl->other_obj == NO_ENTRY)
racl->other_obj = access;
continue;
case RACL_USER:
access |= NAME_IS_USER;
break;
case RACL_GROUP:
break;
default:
continue;
}
ida = EXPAND_ITEM_LIST(&temp_ida_list, id_access, -10);
ida->id = ents[i].id;
ida->access = access;
}
if (temp_ida_list.count) {
#ifdef SMB_ACL_NEED_SORT
if (temp_ida_list.count > 1)
qsort(temp_ida_list.items, temp_ida_list.count, sizeof (id_access), id_access_sorter);
#endif
racl->names.idas = new_array(id_access, temp_ida_list.count);
memcpy(racl->names.idas, temp_ida_list.items, temp_ida_list.count * sizeof (id_access));
} else
racl->names.idas = NULL;
racl->names.count = temp_ida_list.count;
temp_ida_list.count = 0;
return True;
}
#endif /* SUPPORT_ACL_FD */
static int get_rsync_acl(int fd, int dirfd, const char *leaf, const char *fname,
rsync_acl *racl, SMB_ACL_TYPE_T type, mode_t mode)
{
SMB_ACL_T sacl;
#ifndef SUPPORT_ACL_FD
#ifndef HAVE_SOLARIS_ACLS
(void)fd; /* Solaris drives the ACL via facl(2) on fd but has no SUPPORT_ACL_FD. */
#endif
(void)dirfd;
(void)leaf;
#endif
#ifdef SUPPORT_XATTRS
/* --fake-super support: load ACLs from an xattr. */
if (am_root < 0) {
@@ -481,7 +603,7 @@ static int get_rsync_acl(const char *fname, rsync_acl *racl,
size_t len;
int cnt;
if ((buf = get_xattr_acl(fname, type == SMB_ACL_TYPE_ACCESS, &len)) == NULL)
if ((buf = get_xattr_acl(fname, fd, type == SMB_ACL_TYPE_ACCESS, &len)) == NULL)
return 0;
cnt = (len - 4*4) / (4+4);
if (len < 4*4 || len != (size_t)cnt*(4+4) + 4*4) {
@@ -514,6 +636,107 @@ static int get_rsync_acl(const char *fname, rsync_acl *racl,
}
#endif
#ifdef HAVE_SOLARIS_ACLS
/* Solaris has no libacl *_at; read the ACL through the held fd via facl(2)
* when we have one. With no held fd this branch is skipped and the path-based
* call below reads the ACL (acceptable: a read can't redirect a write out of
* the tree). */
if (fd >= 0) {
if ((sacl = sys_acl_get_fd_type(fd, type)) != 0) {
BOOL ok = unpack_smb_acl(sacl, racl);
sys_acl_free_acl(sacl);
if (!ok) {
rsyserr(FERROR_XFER, errno, "get_acl: unpack_smb_acl(%s)", fname);
return -1;
}
return 0;
}
if (no_acl_syscall_error(errno)) {
if (type == SMB_ACL_TYPE_ACCESS)
rsync_acl_fake_perms(racl, mode);
return 0;
}
rsyserr(FERROR_XFER, errno, "get_acl: sys_acl_get_fd_type(%s, %s)",
fname, str_acl_type(type));
return -1;
}
#endif
#ifdef SUPPORT_ACL_FD
#ifdef HAVE_LIBACL_AT
/* Read the ACL via the new libacl *_at calls; fd<0 && dirfd<0
* (e.g. a synthetic dir) falls through to the path-based call below. */
if (fd >= 0 || dirfd >= 0) {
if (fd >= 0)
sacl = sys_acl_get_file_at(fd, "", AT_EMPTY_PATH, type);
else
sacl = sys_acl_get_file_at(dirfd, leaf, AT_SYMLINK_NOFOLLOW, type);
if (sacl != 0) {
BOOL ok = unpack_smb_acl(sacl, racl);
sys_acl_free_acl(sacl);
if (!ok) {
rsyserr(FERROR_XFER, errno, "get_acl: unpack_smb_acl(%s)", fname);
return -1;
}
return 0;
}
if (no_acl_syscall_error(errno)) {
if (type == SMB_ACL_TYPE_ACCESS)
rsync_acl_fake_perms(racl, mode);
return 0;
}
rsyserr(FERROR_XFER, errno, "get_acl: acl_get_file_at(%s, %s)",
fname, str_acl_type(type));
return -1;
}
#else
/* Race-safe path: read the ACL through the held O_NOFOLLOW fd, or via
* setxattrat(AT_SYMLINK_NOFOLLOW) on dirfd+leaf, instead of re-resolving
* fname. Only for real-root ACLs (am_root >= 0; the fake-super branch
* above already returned). */
if (fd >= 0 || (dirfd >= 0 && xacl_at_available())) {
int is_def = type == SMB_ACL_TYPE_DEFAULT;
rsync_acl_ent *ents = NULL;
int n = 0, rc;
if (fd >= 0)
rc = xacl_get_fd(fd, is_def, &ents, &n);
else
rc = xacl_get_at(dirfd, leaf, is_def, &ents, &n);
if (rc < 0) {
if (no_acl_syscall_error(errno)) {
if (type == SMB_ACL_TYPE_ACCESS)
rsync_acl_fake_perms(racl, mode);
return 0;
}
rsyserr(FERROR_XFER, errno, "get_acl: xacl_get(%s, %s)",
fname, str_acl_type(type));
return -1;
}
if (n == 0) {
/* No explicit ACL: mirror libacl's mode-derived access ACL
* (an absent default ACL stays empty). */
if (type == SMB_ACL_TYPE_ACCESS)
rsync_acl_fake_perms(racl, mode);
} else if (!unpack_acl_entries(ents, n, racl)) {
if (ents)
free(ents);
rsyserr(FERROR_XFER, errno, "get_acl: unpack_acl_entries(%s)", fname);
return -1;
}
if (ents)
free(ents);
return 0;
}
/* Neither a held fd nor a usable dirfd path (xacl_at_available() covers the
* *xattrat syscalls AND the pre-6.13 /proc/self/fd compat, so this is the
* BSDs / a /proc-less namespace / an un-pinnable entry): read the real
* destination ACL via the path-based call rather than a mode-only fake, so
* --acls stays functional where the race-safe primitive is unavailable. */
#endif /* HAVE_LIBACL_AT */
#endif
if ((sacl = sys_acl_get_file(fname, type)) != 0) {
BOOL ok = unpack_smb_acl(sacl, racl);
@@ -535,8 +758,10 @@ static int get_rsync_acl(const char *fname, rsync_acl *racl,
return 0;
}
/* Return the Access Control List for the given filename. */
int get_acl(const char *fname, stat_x *sxp)
/* Return the Access Control List for the given filename. When a held
* O_NOFOLLOW fd (or a dirfd+leaf) is available, the ACL is read race-safely
* through it; otherwise (fd < 0 && dirfd < 0) the path-based fallback is used. */
int get_acl_fdat(int fd, int dirfd, const char *leaf, const char *fname, stat_x *sxp)
{
sxp->acc_acl = create_racl();
@@ -557,7 +782,7 @@ int get_acl(const char *fname, stat_x *sxp)
} else if (IS_MISSING_FILE(sxp->st))
return 0;
if (get_rsync_acl(fname, sxp->acc_acl, SMB_ACL_TYPE_ACCESS,
if (get_rsync_acl(fd, dirfd, leaf, fname, sxp->acc_acl, SMB_ACL_TYPE_ACCESS,
sxp->st.st_mode) < 0) {
free_acl(sxp);
return -1;
@@ -565,7 +790,7 @@ int get_acl(const char *fname, stat_x *sxp)
if (S_ISDIR(sxp->st.st_mode)) {
sxp->def_acl = create_racl();
if (get_rsync_acl(fname, sxp->def_acl, SMB_ACL_TYPE_DEFAULT,
if (get_rsync_acl(fd, dirfd, leaf, fname, sxp->def_acl, SMB_ACL_TYPE_DEFAULT,
sxp->st.st_mode) < 0) {
free_acl(sxp);
return -1;
@@ -575,6 +800,11 @@ int get_acl(const char *fname, stat_x *sxp)
return 0;
}
int get_acl(const char *fname, stat_x *sxp)
{
return get_acl_fdat(-1, -1, NULL, fname, sxp);
}
/* === Send functions === */
/* Send the ida list over the file descriptor. */
@@ -697,7 +927,7 @@ static uint32 recv_acl_access(int f, uchar *name_follows_ptr)
static uchar recv_ida_entries(int f, ida_entries *ent)
{
uchar computed_mask_bits = 0;
int i, count = read_varint(f);
int i, count = read_varint_bounded(f, 0, MAX_WIRE_ACL_COUNT, "ACL count");
ent->idas = count ? new_array(id_access, count) : NULL;
ent->count = count;
@@ -713,7 +943,7 @@ static uchar recv_ida_entries(int f, ida_entries *ent)
else
id = recv_group_name(f, id, NULL);
} else if (access & NAME_IS_USER) {
if (inc_recurse && am_root && !numeric_ids)
if (inc_recurse && !numeric_ids)
id = match_uid(id);
} else {
if (inc_recurse && (!am_root || !numeric_ids))
@@ -933,17 +1163,60 @@ static mode_t change_sacl_perms(SMB_ACL_T sacl, rsync_acl *racl, mode_t old_mode
}
#endif
static int set_rsync_acl(const char *fname, acl_duo *duo_item,
SMB_ACL_TYPE_T type, stat_x *sxp, mode_t mode)
static int set_rsync_acl(int fd, int dirfd, const char *leaf, const char *fname,
acl_duo *duo_item, SMB_ACL_TYPE_T type, stat_x *sxp, mode_t mode)
{
#ifndef SUPPORT_ACL_FD
#ifndef HAVE_SOLARIS_ACLS
(void)fd; /* Solaris drives the ACL via facl(2) on fd but has no SUPPORT_ACL_FD. */
#endif
(void)dirfd;
(void)leaf;
#endif
if (type == SMB_ACL_TYPE_DEFAULT
&& duo_item->racl.user_obj == NO_ENTRY) {
int rc;
#ifdef SUPPORT_XATTRS
/* --fake-super support: delete default ACL from xattrs. */
if (am_root < 0)
rc = del_def_xattr_acl(fname);
rc = del_def_xattr_acl(fd, fname);
else
#endif
#ifdef SUPPORT_ACL_FD
#ifdef HAVE_LIBACL_AT
/* Race-safe default-ACL delete via the new libacl *_at
* calls (held fd via AT_EMPTY_PATH, dirfd+leaf via AT_SYMLINK_NOFOLLOW)
* -- race-safe on every Linux kernel. fd<0 && dirfd<0 falls to path. */
if (fd >= 0)
rc = sys_acl_delete_def_file_at(fd, "", AT_EMPTY_PATH);
else if (dirfd >= 0)
rc = sys_acl_delete_def_file_at(dirfd, leaf, AT_SYMLINK_NOFOLLOW);
else
#else
/* Race-safe default-ACL delete via the held fd or dirfd+leaf. Where
* neither is available (xacl_at_available() is false -- the BSDs, a
* /proc-less namespace, an un-pinnable entry; every Linux with procfs
* takes the dirfd path via *xattrat or the /proc/self/fd compat) -- fall
* back to the path-based call, preferring the documented --acls behaviour
* over refusing it where the race-safe primitive is unavailable. */
if (fd >= 0)
rc = xacl_del_default_fd(fd);
else if (dirfd >= 0 && xacl_at_available())
rc = xacl_del_default_at(dirfd, leaf);
else
#endif /* HAVE_LIBACL_AT */
#endif
#ifdef HAVE_SOLARIS_ACLS
/* Solaris: delete the default ACL through the held fd via facl(2). For a
* root receiver a missing held fd means the leaf was raced, so refuse rather
* than let the path-based delete follow it; a plain non-root receiver keeps
* the legacy path fallback (op_pin am_root != 0 rule). */
if (fd >= 0)
rc = sys_acl_delete_def_fd(fd);
else if (vfs_relpath_active() && am_root) {
errno = ELOOP;
rc = -1;
} else
#endif
rc = sys_acl_delete_def_file(fname);
if (rc < 0) {
@@ -972,7 +1245,7 @@ static int set_rsync_acl(const char *fname, acl_duo *duo_item,
SIVAL(bp, 4, ida->access);
}
}
rc = set_xattr_acl(fname, type == SMB_ACL_TYPE_ACCESS, buf, len);
rc = set_xattr_acl(fd, fname, type == SMB_ACL_TYPE_ACCESS, buf, len);
free(buf);
return rc;
#endif
@@ -989,6 +1262,92 @@ static int set_rsync_acl(const char *fname, acl_duo *duo_item,
if (cur_mode == (mode_t)-1)
return 0;
}
#endif
#ifdef SUPPORT_ACL_FD
#ifdef HAVE_LIBACL_AT
/* Apply the packed/perm-reconciled ACL (duo_item->sacl)
* through the new libacl *_at calls -- held fd via AT_EMPTY_PATH,
* dirfd+leaf via AT_SYMLINK_NOFOLLOW -- race-safe on every Linux kernel,
* and byte-identical to the path-based sys_acl_set_file() below. */
if (fd >= 0 || dirfd >= 0) {
int rc;
if (fd >= 0)
rc = sys_acl_set_file_at(fd, "", AT_EMPTY_PATH, type, duo_item->sacl);
else
rc = sys_acl_set_file_at(dirfd, leaf, AT_SYMLINK_NOFOLLOW, type, duo_item->sacl);
if (rc < 0) {
rsyserr(FERROR_XFER, errno, "set_acl: acl_set_file_at(%s, %s)",
fname, str_acl_type(type));
return -1;
}
if (type == SMB_ACL_TYPE_ACCESS)
sxp->st.st_mode = cur_mode;
return 0;
}
#else
/* Race-safe write: serialize the packed (and perm-reconciled)
* system ACL to the kernel xattr format and apply it through the
* held fd or dirfd+leaf -- never re-resolving fname. This matches
* exactly what sys_acl_set_file() would have written. */
if (fd >= 0 || (dirfd >= 0 && xacl_at_available())) {
int is_def = type == SMB_ACL_TYPE_DEFAULT;
rsync_acl_ent *ents;
int n = sacl_to_entries(duo_item->sacl, &ents);
int rc;
if (n < 0)
return -1;
if (fd >= 0)
rc = xacl_set_fd(fd, is_def, ents, n);
else
rc = xacl_set_at(dirfd, leaf, is_def, ents, n);
free(ents);
if (rc < 0) {
rsyserr(FERROR_XFER, errno, "set_acl: xacl_set(%s, %s)",
fname, str_acl_type(type));
return -1;
}
if (type == SMB_ACL_TYPE_ACCESS)
sxp->st.st_mode = cur_mode;
return 0;
}
/* No held fd and no usable dirfd path (xacl_at_available() is false --
* the BSDs, a /proc-less namespace, an un-pinnable entry; every Linux
* with procfs took xacl_set_at() above via *xattrat or the /proc/self/fd
* compat): prefer the documented --acls behaviour over refusing it and
* fall back to the path-based set. This re-resolves fname, so it still
* carries the parent-symlink-race exposure on those remaining platforms;
* it is the only way to honour --acls where no race-safe primitive
* exists. */
#endif /* HAVE_LIBACL_AT */
#endif
#ifdef HAVE_SOLARIS_ACLS
/* Solaris: apply the ACL through the held fd via facl(2). */
if (fd >= 0) {
if (sys_acl_set_fd_type(fd, type, duo_item->sacl) < 0) {
rsyserr(FERROR_XFER, errno, "set_acl: sys_acl_set_fd_type(%s, %s)",
fname, str_acl_type(type));
return -1;
}
if (type == SMB_ACL_TYPE_ACCESS)
sxp->st.st_mode = cur_mode;
return 0;
}
if (vfs_relpath_active() && am_root) {
/* Real root always can open its own freshly-staged reg/dir/fifo leaf,
* so a missing held fd on a confined receiver means the leaf was raced
* to a symlink; sys_acl_set_file() follows the leaf, so refuse rather
* than write the attacker-supplied ACL onto a redirected inode (covers
* the top-level no-slash entry the caller's slashed-path xattr_refuse
* gate misses). A plain non-root receiver keeps the path-based fallback
* for a legitimately un-pinnable owned leaf (e.g. a 0300 dir), matching
* the operator-path op_pin rule (am_root != 0). */
errno = ELOOP;
rsyserr(FERROR_XFER, errno, "set_acl: refusing path-based ACL on %s (no held fd)",
fname);
return -1;
}
#endif
if (sys_acl_set_file(fname, type, duo_item->sacl) < 0) {
rsyserr(FERROR_XFER, errno, "set_acl: sys_acl_set_file(%s, %s)",
@@ -1006,11 +1365,16 @@ static int set_rsync_acl(const char *fname, acl_duo *duo_item,
* dir), and the regular mode bits on the file. Call this with fname set to
* NULL to just check if the ACL is different.
*
* When a held O_NOFOLLOW fd (or a dirfd+leaf) is supplied, the ACL is applied
* race-safely through it; otherwise (fd < 0 && dirfd < 0) the path-based
* fallback is used.
*
* If the ACL operation has a side-effect of changing the file's mode, the
* sxp->st.st_mode value will be changed to match.
*
* Returns 0 for an unchanged ACL, 1 for changed, -1 for failed. */
int set_acl(const char *fname, const struct file_struct *file, stat_x *sxp, mode_t new_mode)
int set_acl_fdat(int fd, int dirfd, const char *leaf, const char *fname,
const struct file_struct *file, stat_x *sxp, mode_t new_mode)
{
int changed = 0;
int32 ndx;
@@ -1030,7 +1394,7 @@ int set_acl(const char *fname, const struct file_struct *file, stat_x *sxp, mode
if (!eq) {
changed = 1;
if (!dry_run && fname
&& set_rsync_acl(fname, duo_item, SMB_ACL_TYPE_ACCESS,
&& set_rsync_acl(fd, dirfd, leaf, fname, duo_item, SMB_ACL_TYPE_ACCESS,
sxp, new_mode) < 0)
return -1;
}
@@ -1047,7 +1411,7 @@ int set_acl(const char *fname, const struct file_struct *file, stat_x *sxp, mode
if (!eq) {
changed = 1;
if (!dry_run && fname
&& set_rsync_acl(fname, duo_item, SMB_ACL_TYPE_DEFAULT,
&& set_rsync_acl(fd, dirfd, leaf, fname, duo_item, SMB_ACL_TYPE_DEFAULT,
sxp, new_mode) < 0)
return -1;
}
@@ -1056,6 +1420,11 @@ int set_acl(const char *fname, const struct file_struct *file, stat_x *sxp, mode
return changed;
}
int set_acl(const char *fname, const struct file_struct *file, stat_x *sxp, mode_t new_mode)
{
return set_acl_fdat(-1, -1, NULL, fname, file, sxp, new_mode);
}
/* Non-incremental recursion needs to convert all the received IDs.
* This is done in a single pass after receiving the whole file-list. */
static void match_racl_ids(const item_list *racl_list)
+104 -6
View File
@@ -22,6 +22,13 @@
#include "itypes.h"
#include "ifuncs.h"
/* O_CLOEXEC is absent on some still-supported targets. The random-source fd
* is read and closed synchronously, so the established zero-value fallback is
* sufficient without adding a configure dependency. */
#ifndef O_CLOEXEC
#define O_CLOEXEC 0
#endif
extern int read_only;
extern char *password_file;
extern struct name_num_obj valid_auth_checksums;
@@ -57,10 +64,31 @@ void base64_encode(const char *buf, int len, char *out, int pad)
out[i] = '\0';
}
/* Fill buf with len bytes from the kernel CSPRNG. Returns 1 on success.
* We read /dev/urandom directly rather than depending on getrandom()/
* arc4random_buf() availability so this works on every platform rsync
* targets without new configure probes. */
static int get_random_bytes(char *buf, int len)
{
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
int got = 0;
if (fd < 0)
return 0;
while (got < len) {
int n = read(fd, buf + got, len - got);
if (n <= 0)
break;
got += n;
}
close(fd);
return got == len;
}
/* Generate a challenge buffer and return it base64-encoded. */
static void gen_challenge(const char *addr, char *challenge)
{
char input[32];
char rnd[32];
char digest[MAX_DIGEST_LEN];
struct timeval tv;
int len;
@@ -74,6 +102,16 @@ static void gen_challenge(const char *addr, char *challenge)
SIVAL(input, 24, getpid());
len = sum_init(valid_auth_checksums.negotiated_nni, 0);
/* The challenge must be unpredictable to a network observer; addr+time
* +pid alone is ~35 bits and lets an attacker enumerate the preimage
* offline. Hash 32 bytes from the kernel RNG first so the digest
* carries full entropy, keeping the legacy inputs as a mix-in so a
* urandom failure degrades to (never below) the old behaviour. */
if (get_random_bytes(rnd, sizeof rnd))
sum_update(rnd, sizeof rnd);
else
rprintf(FWARNING, "gen_challenge: /dev/urandom unavailable, "
"falling back to time-based challenge\n");
sum_update(input, sizeof input);
sum_end(digest);
@@ -110,10 +148,25 @@ static const char *check_secret(int module, const char *user, const char *group,
char *err;
FILE *fh;
if (!fname || !*fname || (fh = fopen(fname, "r")) == NULL)
/* Daemon 'secrets file = PATH' open. A planted symlink would be
* followed and the strict-modes fstat() check below runs on the target
* inode, so a symlink to /etc/shadow (0640 root:shadow) would pass and
* the daemon would auth against shadow hashes. Refuse symlinks not
* owned by uid 0 or our euid. */
if (!fname || !*fname)
return "no secrets file";
{
int fd = vfs_open_owner_walk(fname, O_RDONLY, 0, 0);
if (fd < 0)
return "no secrets file";
fh = fdopen(fd, "r");
if (!fh) {
close(fd);
return "no secrets file";
}
}
if (do_fstat(fileno(fh), &st) == -1) {
if (vfs_fstat(fileno(fh), &st) == -1) {
rsyserr(FLOG, errno, "fstat(%s)", fname);
ok = 0;
} else if (lp_strict_modes(module)) {
@@ -184,13 +237,23 @@ static const char *getpassf(const char *filename)
} else {
int fd;
if ((fd = open(filename,O_RDONLY)) < 0) {
/* --password-file=PATH client open. Its first line is sent as the
* auth response, so a planted symlink leaks the target's content
* (e.g. shadow hashes) to a malicious daemon; the vfs_stat()
* other-access check runs on the target mode and passes 0640
* root:shadow. Refuse symlinks not owned by uid 0 or our euid. */
if ((fd = vfs_open_owner_walk(filename, O_RDONLY, 0, 0)) < 0) {
rsyserr(FERROR, errno, "could not open password file %s", filename);
exit_cleanup(RERR_SYNTAX);
}
if (do_stat(filename, &st) == -1) {
rsyserr(FERROR, errno, "stat(%s)", filename);
/* fstat the opened fd, not the pathname: a same-object check
* (matching check_secret() above) so an attacker who swaps the
* path between open and check can't make the owner/mode test
* validate a different inode than the one we read the password
* from. */
if (vfs_fstat(fd, &st) == -1) {
rsyserr(FERROR, errno, "fstat(%s)", filename);
exit_cleanup(RERR_SYNTAX);
}
if ((st.st_mode & 06) != 0) {
@@ -240,6 +303,35 @@ char *auth_server(int f_in, int f_out, int module, const char *host,
return "";
negotiate_daemon_auth(f_out, 0);
/* Enforce a configured minimum auth digest (default: none). This refuses
* a peer that negotiated -- or, via an omitted digest list / old protocol,
* fell back to -- a digest weaker than the operator-required floor, e.g. a
* client downgraded to md5/md4. Lower rank == stronger (the auth list is
* ordered strongest-first), so a higher rank than the floor is too weak. */
{
const char *min_digest = lp_auth_digest(module);
if (min_digest && *min_digest) {
int floor_rank = auth_digest_rank(min_digest);
int got_rank = auth_digest_rank(valid_auth_checksums.negotiated_nni->name);
if (floor_rank < 0) {
rprintf(FLOG, "auth failed on module %s from %s (%s): the "
"configured 'auth digest = %s' is not a supported digest "
"on this build\n",
lp_name(module), host, addr, min_digest);
return NULL;
}
if (got_rank < 0 || got_rank > floor_rank) {
rprintf(FLOG, "auth failed on module %s from %s (%s): negotiated "
"auth digest %s is weaker than the required "
"'auth digest = %s'\n",
lp_name(module), host, addr,
valid_auth_checksums.negotiated_nni->name, min_digest);
return NULL;
}
}
}
gen_challenge(addr, challenge);
io_printf(f_out, "%s%s\n", leader, challenge);
@@ -255,7 +347,13 @@ char *auth_server(int f_in, int f_out, int module, const char *host,
users = strdup(users);
for (tok = strtok(users, " ,\t"); tok; tok = strtok(NULL, " ,\t")) {
/* conf_strtok() honours the documented leading-comma form: a value that
* starts with a comma splits on commas ALONE, so an entry may contain
* spaces -- which is how a group name with a space is written. Splitting
* on whitespace here tore such an entry apart, so the rule the admin wrote
* never matched and a rule they never wrote appeared from its tail. The
* daemon's gid field already uses this parser (clientserver.c). */
for (tok = conf_strtok(users); tok; tok = conf_strtok(NULL)) {
char *opts;
/* See if the user appended :deny, :ro, or :rw. */
if ((opts = strchr(tok, ':')) != NULL) {
+133 -42
View File
@@ -34,12 +34,33 @@ extern char backup_dir_buf[MAXPATHLEN];
extern char *backup_suffix;
extern char *backup_dir;
/* Pin a backup SOURCE leaf with a confined O_NOFOLLOW fd (via the operator
* owner-walk resolver, like set_file_attrs's op_leaf_fd) so the ACL/xattr the
* backup caches off it are read through the held fd -- a parent-symlink race
* can't redirect the read out of the module. Returns -1 for a non-hardened
* receiver (caller path-reads) or for a raced/absent leaf on a hardened one
* (caller skips the cache rather than read through a flippable path; use
* backup_metadata_hardened() to tell the two -1 cases apart). */
int backup_metadata_hardened(void)
{
return vfs_relpath_active() && !vfs_symlink_optout_allowed();
}
int backup_source_fd(const char *path)
{
#if defined AT_FDCWD && defined O_NOFOLLOW
if (backup_metadata_hardened() && path && *path)
return vfs_open_at(path, O_RDONLY | O_NONBLOCK | O_NOCTTY | O_CLOEXEC, 0, VFS_OPERATOR_PATH);
#endif
return -1;
}
/* Returns -1 on error, 0 on missing dir, and 1 on present dir. */
static int validate_backup_dir(void)
{
STRUCT_STAT st;
if (do_lstat(backup_dir_buf, &st) < 0) {
if (vfs_lstat(VFS_AT_FDCWD, backup_dir_buf, &st, VFS_OPERATOR_PATH) < 0) {
if (errno == ENOENT)
return 0;
rsyserr(FERROR, errno, "backup lstat %s failed", backup_dir_buf);
@@ -98,7 +119,7 @@ static BOOL copy_valid_path(const char *fname)
for ( ; b; name = b + 1, b = strchr(name, '/')) {
*b = '\0';
while (do_mkdir(backup_dir_buf, ACCESSPERMS) < 0) {
while (vfs_mkdir(VFS_AT_FDCWD, backup_dir_buf, ACCESSPERMS, VFS_OPERATOR_PATH) < 0) {
if (errno == EEXIST) {
val = validate_backup_dir();
if (val > 0)
@@ -114,27 +135,36 @@ static BOOL copy_valid_path(const char *fname)
/* Try to transfer the directory settings of the actual dir
* that the files are coming from. */
if (x_stat(rel, &sx.st, NULL) < 0)
if (x_stat(rel, &sx.st, NULL, VFS_OPERATOR_PATH) < 0)
rsyserr(FERROR, errno, "backup stat %s failed", full_fname(rel));
else {
struct file_struct *file;
if (!(file = make_file(rel, NULL, NULL, 0, NO_FILTERS)))
continue;
#ifdef SUPPORT_ACLS
if (preserve_acls && !S_ISLNK(file->mode)) {
get_acl(rel, &sx);
cache_tmp_acl(file, &sx);
free_acl(&sx);
#if defined SUPPORT_ACLS || defined SUPPORT_XATTRS
{ /* read the source dir's ACL/xattr through a confined fd */
int bfd = backup_source_fd(rel);
if (!backup_metadata_hardened() || bfd >= 0) {
# ifdef SUPPORT_ACLS
if (preserve_acls && !S_ISLNK(file->mode)) {
get_acl_fdat(bfd, -1, NULL, rel, &sx);
cache_tmp_acl(file, &sx);
free_acl(&sx);
}
# endif
# ifdef SUPPORT_XATTRS
if (preserve_xattrs) {
get_xattr(rel, bfd, &sx);
cache_tmp_xattr(file, &sx);
free_xattr(&sx);
}
# endif
}
if (bfd >= 0)
close(bfd);
}
#endif
#ifdef SUPPORT_XATTRS
if (preserve_xattrs) {
get_xattr(rel, &sx);
cache_tmp_xattr(file, &sx);
free_xattr(&sx);
}
#endif
set_file_attrs(backup_dir_buf, file, NULL, NULL, 0);
set_file_attrs(backup_dir_buf, file, NULL, NULL, ATTRS_OPERATOR_PATH);
unmake_file(file);
}
@@ -159,12 +189,15 @@ char *get_backup_name(const char *fname)
if (backup_dir) {
static int initialized = 0;
if (!initialized) {
char dirbuf[MAXPATHLEN];
int ret;
if (strlcpy(dirbuf, backup_dir_buf, sizeof dirbuf) >= sizeof dirbuf) {
errno = ENAMETOOLONG;
return NULL;
}
if (backup_dir_len > 1)
backup_dir_buf[backup_dir_len-1] = '\0';
ret = make_path(backup_dir_buf, 0);
if (backup_dir_len > 1)
backup_dir_buf[backup_dir_len-1] = '/';
dirbuf[backup_dir_len-1] = '\0';
ret = vfs_make_path(dirbuf, 0, VFS_OPERATOR_PATH);
if (ret < 0)
return NULL;
initialized = 1;
@@ -197,7 +230,11 @@ static inline int link_or_rename(const char *from, const char *to,
if (IS_SPECIAL(stp->st_mode) || IS_DEVICE(stp->st_mode))
return 0; /* Use copy code. */
#endif
if (do_link(from, to) == 0) {
/* from = the live dest file being backed up (a transfer path); to = the
* --backup-dir path (operator). Per-operand policy keeps the transfer
* source under the secure receiver resolve and only owner-walks the
* operator backup parent. */
if (vfs_link_at(from, to, 0, VFS_OPERATOR_PATH) == 0) {
if (DEBUG_GTE(BACKUP, 1))
rprintf(FINFO, "make_backup: HLINK %s successful.\n", from);
return 2;
@@ -207,11 +244,12 @@ static inline int link_or_rename(const char *from, const char *to,
return 0;
}
#endif
if (do_rename(from, to) == 0) {
if (vfs_rename_at(from, to, 0, VFS_OPERATOR_PATH) == 0) {
if (stp->st_nlink > 1 && !S_ISDIR(stp->st_mode)) {
/* If someone has hard-linked the file into the backup
* dir, rename() might return success but do nothing! */
robust_unlink(from); /* Just in case... */
* dir, rename() might return success but do nothing! from is the
* transfer-side source, so unlink it under the secure resolve (0). */
robust_unlink(from, 0); /* Just in case... */
}
if (DEBUG_GTE(BACKUP, 1))
rprintf(FINFO, "make_backup: RENAME %s successful.\n", from);
@@ -223,7 +261,7 @@ static inline int link_or_rename(const char *from, const char *to,
/* Hard-link, rename, or copy an item to the backup name. Returns 0 for
* failure, 1 if item was moved, 2 if item was duplicated or hard linked
* into backup area, or 3 if item doesn't exist or isn't a regular file. */
int make_backup(const char *fname, BOOL prefer_rename)
static int make_backup_inner(const char *fname, BOOL prefer_rename)
{
stat_x sx;
struct file_struct *file;
@@ -233,12 +271,44 @@ int make_backup(const char *fname, BOOL prefer_rename)
init_stat_x(&sx);
/* Return success if no file to keep. */
if (x_lstat(fname, &sx.st, NULL) < 0)
if (x_lstat(fname, &sx.st, NULL, VFS_OPERATOR_PATH) < 0)
return 3;
if (!(buf = get_backup_name(fname)))
return 0;
#ifdef SUPPORT_LINKS
/* Honor --safe-links BEFORE the hard-link / rename fast path. When
* CAN_HARDLINK_SYMLINK is defined, link_or_rename() would otherwise
* hard-link an escaping symlink (e.g. ../../etc/passwd) into the backup
* area and "goto success", skipping the safe_symlinks check in the
* copy-fallback path below -- silently preserving an unsafe link that
* --safe-links was meant to drop. Match the copy path: don't back up an
* unsafe symlink. */
if (preserve_links && S_ISLNK(sx.st.st_mode) && safe_symlinks) {
char lnkbuf[MAXPATHLEN];
int llen = vfs_readlink(fname, lnkbuf, MAXPATHLEN - 1);
/* A failed readlink means we can't verify the target, so fail
* closed: skip the backup rather than let the hard-link fast path
* preserve a possibly-unsafe symlink unchecked. */
if (llen <= 0) {
if (INFO_GTE(SYMSAFE, 1))
rprintf(FINFO, "not backing up symlink with unreadable target \"%s\"\n", fname);
ret = 2;
goto success;
}
lnkbuf[llen] = '\0';
if (unsafe_symlink(lnkbuf, fname)) {
if (INFO_GTE(SYMSAFE, 1)) {
rprintf(FINFO, "not backing up unsafe symlink \"%s\" -> \"%s\"\n",
fname, lnkbuf);
}
ret = 2;
goto success;
}
}
#endif
/* Try a hard-link or a rename first. Using rename is not atomic, but
* is more efficient than forcing a copy for larger files when no hard-
* linking is possible. */
@@ -246,7 +316,7 @@ int make_backup(const char *fname, BOOL prefer_rename)
goto success;
if (errno == EEXIST || errno == EISDIR) {
STRUCT_STAT bakst;
if (do_lstat(buf, &bakst) == 0) {
if (vfs_lstat(VFS_AT_FDCWD, buf, &bakst, VFS_OPERATOR_PATH) == 0) {
int flags = get_del_for_flag(bakst.st_mode) | DEL_FOR_BACKUP | DEL_RECURSE;
if (delete_item(buf, bakst.st_mode, flags) != 0)
return 0;
@@ -259,25 +329,34 @@ int make_backup(const char *fname, BOOL prefer_rename)
if (!(file = make_file(fname, NULL, &sx.st, 0, NO_FILTERS)))
return 3; /* the file could have disappeared */
#ifdef SUPPORT_ACLS
if (preserve_acls && !S_ISLNK(file->mode)) {
get_acl(fname, &sx);
cache_tmp_acl(file, &sx);
free_acl(&sx);
#if defined SUPPORT_ACLS || defined SUPPORT_XATTRS
{ /* read the source file's ACL/xattr through a confined fd */
int bfd = backup_source_fd(fname);
if (!backup_metadata_hardened() || bfd >= 0) {
# ifdef SUPPORT_ACLS
if (preserve_acls && !S_ISLNK(file->mode)) {
get_acl_fdat(bfd, -1, NULL, fname, &sx);
cache_tmp_acl(file, &sx);
free_acl(&sx);
}
# endif
# ifdef SUPPORT_XATTRS
if (preserve_xattrs) {
get_xattr(fname, bfd, &sx);
cache_tmp_xattr(file, &sx);
free_xattr(&sx);
}
# endif
}
#endif
#ifdef SUPPORT_XATTRS
if (preserve_xattrs) {
get_xattr(fname, &sx);
cache_tmp_xattr(file, &sx);
free_xattr(&sx);
if (bfd >= 0)
close(bfd);
}
#endif
/* Check to see if this is a device file, or link */
if ((am_root && preserve_devices && IS_DEVICE(file->mode))
|| (preserve_specials && IS_SPECIAL(file->mode))) {
if (do_mknod(buf, file->mode, sx.st.st_rdev) < 0)
if (vfs_mknod(VFS_AT_FDCWD, buf, file->mode, sx.st.st_rdev, VFS_OPERATOR_PATH) < 0)
rsyserr(FERROR, errno, "mknod %s failed", full_fname(buf));
else if (DEBUG_GTE(BACKUP, 1))
rprintf(FINFO, "make_backup: DEVICE %s successful.\n", fname);
@@ -294,7 +373,7 @@ int make_backup(const char *fname, BOOL prefer_rename)
}
ret = 2;
} else {
if (do_symlink(sl, buf) < 0)
if (vfs_symlink(sl, VFS_AT_FDCWD, buf, VFS_OPERATOR_PATH) < 0)
rsyserr(FERROR, errno, "link %s -> \"%s\"", full_fname(buf), sl);
else if (DEBUG_GTE(BACKUP, 1))
rprintf(FINFO, "make_backup: SYMLINK %s successful.\n", fname);
@@ -318,7 +397,7 @@ int make_backup(const char *fname, BOOL prefer_rename)
/* Copy to backup tree if a file. */
if (!ret) {
if (copy_file(fname, buf, -1, file->mode) < 0) {
if (copy_file(fname, buf, -1, file->mode, VFS_OPERATOR_PATH) < 0) {
rsyserr(FERROR, errno, "keep_backup failed: %s -> \"%s\"",
full_fname(fname), buf);
unmake_file(file);
@@ -337,7 +416,7 @@ int make_backup(const char *fname, BOOL prefer_rename)
save_preserve_xattrs = preserve_xattrs;
preserve_xattrs = 0;
set_file_attrs(buf, file, NULL, fname, ATTRS_ACCURATE_TIME);
set_file_attrs(buf, file, NULL, fname, ATTRS_OPERATOR_PATH | ATTRS_ACCURATE_TIME);
preserve_xattrs = save_preserve_xattrs;
unmake_file(file);
@@ -353,3 +432,15 @@ int make_backup(const char *fname, BOOL prefer_rename)
rprintf(FINFO, "backed up %s to %s\n", fname, buf);
return ret;
}
int make_backup(const char *fname, BOOL prefer_rename)
{
int ret;
/* The --backup-dir is an operator-supplied path: resolve it (and the
* tail/rename beneath it) with the ownership walk so a foreign-owned
* symlink component is refused while the operator's own is followed --
* absolute and relative alike. --insecure-links / "insecure links ="
* restores legacy following. */
ret = make_backup_inner(fname, prefer_rename);
return ret;
}
+56 -20
View File
@@ -75,7 +75,7 @@ static int *flag_ptr[] = {
NULL
};
static char *flag_name[] = {
static const char *const flag_name[] = {
"--recurse (-r)",
"--owner (-o)",
"--group (-g)",
@@ -166,25 +166,33 @@ static int write_arg(const char *arg)
const char *x, *s;
int len, err = 0;
/* Emit a "--opt=" prefix unquoted only when it is a plain option token;
* a metacharacter before '=' (an attacker-shaped arg) must be quoted
* along with the rest, or it would run raw in the replay script. */
if (*arg == '-' && (x = strchr(arg, '=')) != NULL) {
err |= write(batch_sh_fd, arg, x - arg + 1) != x - arg + 1;
arg += x - arg + 1;
}
if (strpbrk(arg, " \"'&;|[]()$#!*?^\\") != NULL) {
err |= write(batch_sh_fd, "'", 1) != 1;
for (s = arg; (x = strchr(s, '\'')) != NULL; s = x + 1) {
err |= write(batch_sh_fd, s, x - s + 1) != x - s + 1;
err |= write(batch_sh_fd, "'", 1) != 1;
const char *p = arg;
while (p < x && (*p == '-' || *p == '_'
|| (*p >= '0' && *p <= '9')
|| (*p >= 'A' && *p <= 'Z')
|| (*p >= 'a' && *p <= 'z')))
p++;
if (p == x) {
err |= write(batch_sh_fd, arg, x - arg + 1) != x - arg + 1;
arg += x - arg + 1;
}
len = strlen(s);
err |= write(batch_sh_fd, s, len) != len;
err |= write(batch_sh_fd, "'", 1) != 1;
return err;
}
len = strlen(arg);
err |= write(batch_sh_fd, arg, len) != len;
/* Single-quote unconditionally so every shell metacharacter (backtick,
* newline, redirection, ...) stays literal in the replay script. An
* embedded ' is emitted as the '\'' close/escape/reopen sequence. */
err |= write(batch_sh_fd, "'", 1) != 1;
for (s = arg; (x = strchr(s, '\'')) != NULL; s = x + 1) {
err |= write(batch_sh_fd, s, x - s) != x - s;
err |= write(batch_sh_fd, "'\\''", 4) != 4;
}
len = strlen(s);
err |= write(batch_sh_fd, s, len) != len;
err |= write(batch_sh_fd, "'", 1) != 1;
return err;
}
@@ -194,7 +202,7 @@ static int write_opt(const char *opt, const char *arg)
{
int len = strlen(opt);
int err = write(batch_sh_fd, " ", 1) != 1;
err = write(batch_sh_fd, opt, len) != len ? 1 : 0;
err |= write(batch_sh_fd, opt, len) != len;
if (arg) {
err |= write(batch_sh_fd, "=", 1) != 1;
err |= write_arg(arg);
@@ -210,6 +218,16 @@ static void write_filter_rules(int fd)
for (ent = filter_list.head; ent; ent = ent->next) {
unsigned int plen;
char *p = get_rule_prefix(ent, "- ", 0, &plen);
/* A filter pattern is one here-doc line; an embedded newline would let
* a crafted pattern (e.g. from a dir-merge/--exclude-from file in an
* untrusted tree) forge the "#E#" terminator on its own line and inject
* shell commands into the generated replay script. Such a pattern also
* can't round-trip the line-delimited here-doc, so refuse it fail-closed
* rather than emit an injectable script. */
if (ent->pattern && strchr(ent->pattern, '\n')) {
rprintf(FERROR, "cannot write a filter rule containing a newline to the batch replay script\n");
exit_cleanup(RERR_SYNTAX);
}
write_buf(fd, p, plen);
write_sbuf(fd, ent->pattern);
if (ent->rflags & FILTRULE_DIRECTORY)
@@ -224,27 +242,45 @@ static void write_filter_rules(int fd)
/* This sets batch_fd and (for --write-batch) batch_sh_fd. */
void open_batch_files(void)
{
/* --write-batch/--read-batch are operator-supplied; a planted symlink
* could truncate+overwrite an arbitrary file (write side) or stream
* attacker bytes into the protocol parser (read side). Refuse symlinks
* not owned by uid 0 or our euid anywhere in the path. */
if (write_batch) {
char filename[MAXPATHLEN];
stringjoin(filename, sizeof filename, batch_name, ".sh", NULL);
batch_sh_fd = do_open(filename, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR | S_IXUSR);
batch_sh_fd = vfs_open_owner_walk(filename, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR | S_IXUSR, 0);
if (batch_sh_fd < 0) {
rsyserr(FERROR, errno, "Batch file %s open error", full_fname(filename));
exit_cleanup(RERR_FILESELECT);
}
batch_fd = do_open(batch_name, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);
/* O_BINARY: the batch stream is binary protocol data; without it
* Cygwin et al apply CRLF translation and corrupt it. Unlike
* vfs_open(), vfs_open_owner_walk passes flags verbatim. */
batch_fd = vfs_open_owner_walk(batch_name, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR, 0);
} else if (strcmp(batch_name, "-") == 0)
batch_fd = STDIN_FILENO;
else
batch_fd = do_open(batch_name, O_RDONLY, S_IRUSR | S_IWUSR);
batch_fd = vfs_open_owner_walk(batch_name, O_RDONLY | O_BINARY, S_IRUSR | S_IWUSR, 0);
if (batch_fd < 0) {
rsyserr(FERROR, errno, "Batch file %s open error", full_fname(batch_name));
exit_cleanup(RERR_FILEIO);
}
/* --read-batch: the file's bytes drive the protocol parser, so refuse
* non-regular files (FIFO, device, socket) at the batch path. */
if (!write_batch && batch_fd != STDIN_FILENO) {
STRUCT_STAT st;
if (vfs_fstat(batch_fd, &st) == 0 && !S_ISREG(st.st_mode)) {
rprintf(FERROR, "Batch file %s is not a regular file\n",
full_fname(batch_name));
exit_cleanup(RERR_FILEIO);
}
}
}
/* This routine tries to write out an equivalent --read-batch command
+31
View File
@@ -68,10 +68,26 @@ SIVAL64(char *buf, int pos, int64 val)
#else /* !CAREFUL_ALIGNMENT */
/* We don't want false positives about alignment from UBSAN, see:
https://github.com/WayneD/rsync/issues/427#issuecomment-1375132291
*/
/* From https://gcc.gnu.org/onlinedocs/cpp/Common-Predefined-Macros.html */
#ifndef GCC_VERSION
#define GCC_VERSION (__GNUC__ * 10000 \
+ __GNUC_MINOR__ * 100 \
+ __GNUC_PATCHLEVEL__)
#endif
/* This handles things for architectures like the 386 that can handle alignment errors.
* WARNING: This section is dependent on the length of an int32 (and thus a uint32)
* being correct (4 bytes)! Set CAREFUL_ALIGNMENT if it is not. */
#ifdef __clang__
__attribute__((no_sanitize("undefined")))
#elif GCC_VERSION >= 409
__attribute__((no_sanitize_undefined))
#endif
static inline uint32
IVALu(const uchar *buf, int pos)
{
@@ -83,6 +99,11 @@ IVALu(const uchar *buf, int pos)
return *u.num;
}
#ifdef __clang__
__attribute__((no_sanitize("undefined")))
#elif GCC_VERSION >= 409
__attribute__((no_sanitize_undefined))
#endif
static inline void
SIVALu(uchar *buf, int pos, uint32 val)
{
@@ -94,6 +115,11 @@ SIVALu(uchar *buf, int pos, uint32 val)
*u.num = val;
}
#ifdef __clang__
__attribute__((no_sanitize("undefined")))
#elif GCC_VERSION >= 409
__attribute__((no_sanitize_undefined))
#endif
static inline int64
IVAL64(const char *buf, int pos)
{
@@ -105,6 +131,11 @@ IVAL64(const char *buf, int pos)
return *u.num;
}
#ifdef __clang__
__attribute__((no_sanitize("undefined")))
#elif GCC_VERSION >= 409
__attribute__((no_sanitize_undefined))
#endif
static inline void
SIVAL64(char *buf, int pos, int64 val)
{
+22 -5
View File
@@ -87,6 +87,24 @@ struct name_num_obj valid_auth_checksums = {
"daemon auth checksum", NULL, 0, 0, valid_auth_checksums_items
};
/* Return the strength rank (0 = strongest) of a daemon-auth digest by name in
* valid_auth_checksums_items[], which is listed strongest-first; -1 if the name
* is not a supported auth digest on this build. Used by the daemon's
* "auth digest" floor to compare the negotiated digest against the minimum. */
int auth_digest_rank(const char *name)
{
struct name_num_item *nni;
int rank = 0;
if (!name || !*name)
return -1;
for (nni = valid_auth_checksums_items; nni->name; nni++, rank++) {
if (strcasecmp(nni->name, name) == 0)
return rank;
}
return -1;
}
/* These cannot make use of openssl, so they're marked just as built-in */
struct name_num_item implied_checksum_md4 =
{ CSUM_MD4, NNI_BUILTIN, "md4", NULL };
@@ -176,7 +194,7 @@ void parse_checksum_choice(int final_call)
if (valid_checksums.negotiated_nni)
xfer_sum_nni = file_sum_nni = valid_checksums.negotiated_nni;
else {
char *cp = checksum_choice ? strchr(checksum_choice, ',') : NULL;
const char *cp = checksum_choice ? strchr(checksum_choice, ',') : NULL;
if (cp) {
xfer_sum_nni = parse_csum_name(checksum_choice, cp - checksum_choice);
file_sum_nni = parse_csum_name(cp+1, -1);
@@ -366,9 +384,8 @@ void get_checksum2(char *buf, int32 len, char *sum)
mdfour_begin(&m);
if (len > len1) {
if (buf1)
free(buf1);
if (len > len1 || !buf1) {
free(buf1);
buf1 = new_array(char, len+4);
len1 = len;
}
@@ -406,7 +423,7 @@ void file_checksum(const char *fname, const STRUCT_STAT *st_p, char *sum)
int32 remainder;
int fd;
fd = do_open_checklinks(fname);
fd = vfs_open_checklinks(fname);
if (fd == -1) {
memset(sum, 0, file_sum_len);
return;
+89 -7
View File
@@ -29,7 +29,7 @@ extern mode_t orig_umask;
struct chmod_mode_struct {
struct chmod_mode_struct *next;
int ModeAND, ModeOR;
int ModeAND, ModeOR, ModeCOPY_SRC, ModeCOPY_DST, ModeCOPY_AND, ModeOP;
char flags;
};
@@ -43,6 +43,20 @@ struct chmod_mode_struct {
#define STATE_2ND_HALF 2
#define STATE_OCTAL_NUM 3
static int mode_dest_special_bits(int where)
{
int bits = 0;
if (where & 0100)
bits |= S_ISUID;
if (where & 0010)
bits |= S_ISGID;
if (where & 0001)
bits |= S_ISVTX;
return bits;
}
/* Parse a chmod-style argument, and break it down into one or more AND/OR
* pairs in a linked list. We return a pointer to new items on success
* (appending the items to the specified list), or NULL on error. */
@@ -50,13 +64,13 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
struct chmod_mode_struct **root_mode_ptr)
{
int state = STATE_1ST_HALF;
int where = 0, what = 0, op = 0, topbits = 0, topoct = 0, flags = 0;
int where = 0, what = 0, op = 0, topbits = 0, topoct = 0, flags = 0, copybits = 0;
struct chmod_mode_struct *first_mode = NULL, *curr_mode = NULL,
*prev_mode = NULL;
while (state != STATE_ERROR) {
if (!*modestr || *modestr == ',') {
int bits;
int bits, where_specified;
if (!op) {
state = STATE_ERROR;
@@ -70,9 +84,10 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
first_mode = curr_mode;
curr_mode->next = NULL;
if (where)
where_specified = where;
if (where) {
bits = where * what;
else {
} else {
where = 0111;
bits = (where * what) & ~orig_umask;
}
@@ -81,18 +96,35 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
case CHMOD_ADD:
curr_mode->ModeAND = CHMOD_BITS;
curr_mode->ModeOR = bits + topoct;
curr_mode->ModeCOPY_SRC = copybits;
curr_mode->ModeCOPY_DST = where;
curr_mode->ModeCOPY_AND = where_specified ? CHMOD_BITS : ~orig_umask;
curr_mode->ModeOP = op;
break;
case CHMOD_SUB:
curr_mode->ModeAND = CHMOD_BITS - bits - topoct;
curr_mode->ModeOR = 0;
curr_mode->ModeCOPY_SRC = copybits;
curr_mode->ModeCOPY_DST = where;
curr_mode->ModeCOPY_AND = where_specified ? CHMOD_BITS : ~orig_umask;
curr_mode->ModeOP = op;
break;
case CHMOD_EQ:
curr_mode->ModeAND = CHMOD_BITS - (where * 7) - (topoct ? topbits : 0);
curr_mode->ModeAND = CHMOD_BITS - (where * 7) - (topoct ? topbits : 0)
- (copybits ? mode_dest_special_bits(where) : 0);
curr_mode->ModeOR = bits + topoct;
curr_mode->ModeCOPY_SRC = copybits;
curr_mode->ModeCOPY_DST = where;
curr_mode->ModeCOPY_AND = where_specified ? CHMOD_BITS : ~orig_umask;
curr_mode->ModeOP = op;
break;
case CHMOD_SET:
curr_mode->ModeAND = 0;
curr_mode->ModeOR = bits;
curr_mode->ModeCOPY_SRC = 0;
curr_mode->ModeCOPY_DST = 0;
curr_mode->ModeCOPY_AND = CHMOD_BITS;
curr_mode->ModeOP = op;
break;
}
@@ -103,7 +135,7 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
modestr++;
state = STATE_1ST_HALF;
where = what = op = topoct = topbits = flags = 0;
where = what = op = topoct = topbits = flags = copybits = 0;
}
switch (state) {
@@ -132,6 +164,7 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
break;
case 'a':
where |= 0111;
topbits |= 06000; /* a+s sets BOTH setuid and setgid (like chmod(1)) */
break;
case '+':
op = CHMOD_ADD;
@@ -159,26 +192,53 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
case STATE_2ND_HALF:
switch (*modestr) {
case 'r':
if (copybits)
state = STATE_ERROR;
what |= 4;
break;
case 'w':
if (copybits)
state = STATE_ERROR;
what |= 2;
break;
case 'X':
if (copybits)
state = STATE_ERROR;
flags |= FLAG_X_KEEP;
/* FALL THROUGH */
case 'x':
if (copybits)
state = STATE_ERROR;
what |= 1;
break;
case 's':
if (copybits)
state = STATE_ERROR;
if (topbits)
topoct |= topbits;
else
topoct = 04000;
break;
case 't':
if (copybits)
state = STATE_ERROR;
topoct |= 01000;
break;
case 'u':
if (what || topoct || copybits)
state = STATE_ERROR;
copybits = 0100;
break;
case 'g':
if (what || topoct || copybits)
state = STATE_ERROR;
copybits = 0010;
break;
case 'o':
if (what || topoct || copybits)
state = STATE_ERROR;
copybits = 0001;
break;
default:
state = STATE_ERROR;
break;
@@ -212,6 +272,20 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
return first_mode;
}
static int mode_copy_bits(int mode, int copy_src, int copy_dst, int copy_and)
{
int copy_bits = 0;
if (copy_src & 0100)
copy_bits |= (mode >> 6) & 7;
if (copy_src & 0010)
copy_bits |= (mode >> 3) & 7;
if (copy_src & 0001)
copy_bits |= mode & 7;
return (copy_dst * copy_bits) & copy_and;
}
/* Takes an existing file permission and a list of AND/OR changes, and
* create a new permissions. */
@@ -219,17 +293,25 @@ int tweak_mode(int mode, struct chmod_mode_struct *chmod_modes)
{
int IsX = mode & 0111;
int NonPerm = mode & ~CHMOD_BITS;
int copy_bits;
for ( ; chmod_modes; chmod_modes = chmod_modes->next) {
if ((chmod_modes->flags & FLAG_DIRS_ONLY) && !S_ISDIR(NonPerm))
continue;
if ((chmod_modes->flags & FLAG_FILES_ONLY) && S_ISDIR(NonPerm))
continue;
copy_bits = mode_copy_bits(mode, chmod_modes->ModeCOPY_SRC,
chmod_modes->ModeCOPY_DST,
chmod_modes->ModeCOPY_AND);
mode &= chmod_modes->ModeAND;
if ((chmod_modes->flags & FLAG_X_KEEP) && !IsX && !S_ISDIR(NonPerm))
mode |= chmod_modes->ModeOR & ~0111;
else
mode |= chmod_modes->ModeOR;
if (chmod_modes->ModeOP == CHMOD_SUB)
mode &= CHMOD_BITS - copy_bits;
else
mode |= copy_bits;
}
return mode | NonPerm;
+11 -3
View File
@@ -58,7 +58,7 @@ void close_all(void)
max_fd = sysconf(_SC_OPEN_MAX) - 1;
for (fd = max_fd; fd >= 0; fd--) {
if ((ret = do_fstat(fd, &st)) == 0) {
if ((ret = vfs_fstat(fd, &st)) == 0) {
if (is_a_socket(fd))
ret = shutdown(fd, 2);
ret = close(fd);
@@ -198,7 +198,7 @@ NORETURN void _exit_cleanup(int code, const char *file, int line)
switch_step++;
if (cleanup_fname)
do_unlink(cleanup_fname);
vfs_unlink(VFS_AT_FDCWD, cleanup_fname, 0);
if (exit_code)
kill_all(SIGUSR1);
if (cleanup_pid && cleanup_pid == getpid()) {
@@ -269,8 +269,16 @@ NORETURN void _exit_cleanup(int code, const char *file, int line)
break;
}
if (called_from_signal_handler)
if (called_from_signal_handler) {
#ifdef GCOV_COVERAGE
/* _exit() bypasses the gcov atexit flush; rsync's generator (and
* other processes) normally finish via the signal handler, so
* without this they would write no .gcda. Harmless otherwise. */
extern void __gcov_dump(void);
__gcov_dump();
#endif
_exit(exit_code);
}
exit(exit_code);
}
+1 -1
View File
@@ -167,7 +167,7 @@ int read_proxy_protocol_header(int fd)
char sig[PROXY_V2_SIG_SIZE];
char ver_cmd;
char fam;
char len[2];
unsigned char len[2];
union {
struct {
char src_addr[4];
+269 -21
View File
@@ -30,6 +30,7 @@ extern int list_only;
extern int am_sender;
extern int am_server;
extern int am_daemon;
extern int am_chrooted;
extern int am_root;
extern int msgs2stderr;
extern int rsync_port;
@@ -38,8 +39,10 @@ extern int ignore_errors;
extern int preserve_xattrs;
extern int kluge_around_eof;
extern int munge_symlinks;
extern int use_secure_symlinks;
extern int open_noatime;
extern int sanitize_paths;
extern int daemon_config_filter_file;
extern int numeric_ids;
extern int filesfrom_fd;
extern int remote_protocol;
@@ -68,6 +71,8 @@ extern gid_t our_gid;
char *auth_user;
char *daemon_auth_choices;
/* read_args() enforces MAX_DAEMON_ARGS and reports "too many daemon arguments"
* before a daemon client can grow argv without bound. */
int read_only = 0;
int module_id = -1;
int pid_file_fd = -1;
@@ -79,11 +84,32 @@ struct chmod_mode_struct *daemon_chmod_modes;
#define EARLY_INPUT_CMD "#early_input="
#define EARLY_INPUT_CMDLEN (sizeof EARLY_INPUT_CMD - 1)
/* Fallback bound on each peer-driven daemon handshake phase when no positive
* "timeout" is configured. A module value can shorten the pre-auth and
* argument-read phases, but cannot extend either beyond this limit. */
#define DAEMON_HANDSHAKE_TIMEOUT 60
static int daemon_handshake_timeout(int module)
{
int timeout = lp_timeout(module);
/* "timeout" is parsed with atoi(), so negative values are possible. */
if (timeout <= 0 || timeout > DAEMON_HANDSHAKE_TIMEOUT)
timeout = DAEMON_HANDSHAKE_TIMEOUT;
return timeout;
}
/* module_dirlen is the length of the module_dir string when in daemon
* mode and module_dir is not "/"; otherwise 0. (Note that a chroot-
* enabled module can have a non-"/" module_dir these days.) */
char *module_dir = NULL;
unsigned int module_dirlen = 0;
/* An fd held open on the served module root, captured while the daemon is still
* positioned there (and privileged) -- so the sender's directory scan can be
* confined beneath the module by resolving module-relative paths against this fd,
* without re-walking (and re-permission-checking) the absolute module path as the
* dropped-privilege module uid. -1 when not a daemon or not yet captured. */
int module_dirfd = -1;
char *full_module_path;
@@ -156,7 +182,12 @@ static int exchange_protocols(int f_in, int f_out, char *buf, size_t bufsiz, int
if (!am_client) {
char *motd = lp_motd_file();
if (motd && *motd) {
FILE *f = fopen(motd, "r");
/* 'motd file = PATH': motd content is sent to every client, so
* a planted symlink would leak the target's bytes. Refuse
* symlinks not owned by uid 0 or our euid. */
int motd_fd = vfs_open_owner_walk(motd, O_RDONLY, 0, 0);
FILE *f = motd_fd >= 0 ? fdopen(motd_fd, "r") : NULL;
if (!f && motd_fd >= 0) close(motd_fd);
while (f && !feof(f)) {
int len = fread(buf, 1, bufsiz - 1, f);
if (len > 0)
@@ -260,19 +291,30 @@ int start_inband_exchange(int f_in, int f_out, const char *user, int argc, char
if (!user)
user = getenv("LOGNAME");
if (exchange_protocols(f_in, f_out, line, sizeof line, 1) < 0)
if (exchange_protocols(f_in, f_out, line, sizeof line, 1) < 0) {
free(modname);
return -1;
}
if (early_input_file) {
STRUCT_STAT st;
FILE *f = fopen(early_input_file, "rb");
if (!f || do_fstat(fileno(f), &st) < 0) {
/* --early-input-file=PATH: refuse symlinks not owned by uid 0 or
* our euid anywhere in the path. */
int ei_fd = vfs_open_owner_walk(early_input_file, O_RDONLY, 0, 0);
FILE *f = ei_fd >= 0 ? fdopen(ei_fd, "rb") : NULL;
if (!f && ei_fd >= 0) close(ei_fd);
if (!f || vfs_fstat(fileno(f), &st) < 0) {
rsyserr(FERROR, errno, "failed to open %s", early_input_file);
if (f)
fclose(f);
free(modname);
return -1;
}
early_input_len = st.st_size;
if (early_input_len > (int)sizeof line) {
rprintf(FERROR, "%s is > %d bytes.\n", early_input_file, (int)sizeof line);
fclose(f);
free(modname);
return -1;
}
if (early_input_len > 0) {
@@ -281,6 +323,8 @@ int start_inband_exchange(int f_in, int f_out, const char *user, int argc, char
int len;
if (feof(f)) {
rprintf(FERROR, "Early EOF in %s\n", early_input_file);
fclose(f);
free(modname);
return -1;
}
len = fread(line, 1, early_input_len, f);
@@ -357,6 +401,7 @@ int start_inband_exchange(int f_in, int f_out, const char *user, int argc, char
while (1) {
if (!read_line_old(f_in, line, sizeof line, 0)) {
rprintf(FERROR, "rsync: didn't get server startup line\n");
free(modname);
return -1;
}
@@ -380,6 +425,7 @@ int start_inband_exchange(int f_in, int f_out, const char *user, int argc, char
rprintf(FERROR, "%s\n", line);
/* This is always fatal; the server will now
* close the socket. */
free(modname);
return -1;
}
@@ -541,6 +587,7 @@ static pid_t start_pre_exec(const char *cmd, int *arg_fd_ptr, int *error_fd_ptr)
status = shell_exec(cmd);
gcov_flush();
if (!WIFEXITED(status))
_exit(1);
_exit(WEXITSTATUS(status));
@@ -756,6 +803,9 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
}
read_only = lp_read_only(i); /* may also be overridden by auth_server() */
/* The module is now known, so its local timeout policy can tighten the
* absolute deadline while the claimed slot is awaiting authentication. */
set_daemon_handshake_timeout(daemon_handshake_timeout(i));
auth_user = auth_server(f_in, f_out, i, host, addr, "@RSYNCD: AUTHREQD ");
if (!auth_user) {
@@ -763,6 +813,10 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
return -1;
}
set_env_str("RSYNC_USER_NAME", auth_user);
/* Do not count local setup or operator hooks against a peer's read time.
* In particular, the post-xfer parent and pre-xfer/name-converter children
* are forked below and must never inherit an armed asynchronous deadline. */
set_daemon_handshake_timeout(0);
module_id = i;
@@ -871,6 +925,17 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
} else
set_filter_dir(module_dir, module_dirlen);
/* Snapshot the module root for the VFS confinement checks now that the
* path is final. The root dirfd is pinned later (below); this first call
* must precede any VFS open of an operator-supplied path -- the filter/
* include files just below, and the log file -- so they see the boundary. */
vfs_set_module_root(module_dir, module_dirlen, -1);
/* Everything loaded from here to the end of the exclude block is the
* operator's own configuration, so it keeps the ownership walk without the
* module-confinement parse_filter_file() applies to peer-driven merges. */
daemon_config_filter_file = 1;
p = lp_filter(module_id);
parse_filter_str(&daemon_filter_list, p, rule_template(FILTRULE_WORD_SPLIT),
XFLG_ABS_IF_SLASH | XFLG_DIR2WILD3);
@@ -892,6 +957,8 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
parse_filter_str(&daemon_filter_list, p, rule_template(FILTRULE_WORD_SPLIT),
XFLG_ABS_IF_SLASH | XFLG_DIR2WILD3 | XFLG_OLD_PREFIXES);
daemon_config_filter_file = 0;
log_init(1);
#if defined HAVE_SETENV || defined HAVE_PUTENV
@@ -925,6 +992,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
set_env_num("RSYNC_EXIT_STATUS", status);
if (shell_exec(lp_postxfer_exec(module_id)) < 0)
status = -1;
gcov_flush();
_exit(status);
}
}
@@ -976,16 +1044,34 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
}
if (use_chroot) {
/* Cache timezone data before chroot makes /etc/localtime inaccessible */
tzset();
/* Flush gcov counters now: after chroot the build-tree .gcda
* paths are unreachable, so everything this child has executed
* so far (the whole rsync_module() pre-chroot path) would
* otherwise be lost. Post-chroot coverage from this child is
* still unrecordable -- accepted, documented in
* testsuite/COVERAGE.md. */
gcov_flush();
if (chroot(module_chdir)) {
rsyserr(FLOG, errno, "chroot(\"%s\") failed", module_chdir);
io_printf(f_out, "@ERROR: chroot failed\n");
return -1;
}
am_chrooted = 1;
module_chdir = module_dir;
}
if (!change_dir(module_chdir, CD_NORMAL))
return path_failure(f_out, module_chdir, True);
/* Pin the module root by identity now -- cwd is the served root and we are
* still privileged -- so the sender's later directory scans resolve against
* this fd rather than re-walking the absolute module path post-setuid. */
#if defined HAVE_FDOPENDIR && defined O_DIRECTORY
module_dirfd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
#endif
/* Update the VFS snapshot with the now-pinned root dirfd. */
vfs_set_module_root(module_dir, module_dirlen, module_dirfd);
if (module_dirlen)
sanitize_paths = 1;
@@ -995,7 +1081,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
STRUCT_STAT st;
char prefix[SYMLINK_PREFIX_LEN]; /* NOT +1 ! */
strlcpy(prefix, SYMLINK_PREFIX, sizeof prefix); /* trim the trailing slash */
if (do_stat(prefix, &st) == 0 && S_ISDIR(st.st_mode)) {
if (vfs_stat(VFS_AT_FDCWD, prefix, &st, VFS_ALLOW_SYMLINK) == 0 && S_ISDIR(st.st_mode)) {
rprintf(FLOG, "Symlink munging is unsafe when a %s directory exists.\n",
prefix);
io_printf(f_out, "@ERROR: daemon security issue -- contact admin\n", name);
@@ -1003,6 +1089,18 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
}
}
/* Enable secure symlink handling for any non-chrooted daemon module, and
* for a chroot module with a /./ inner boundary (module_dirlen) -- there
* the kernel chroot confines the outer path but not the inner module, so
* the receiver finish/rename path must still resolve beneath the module
* root. This prevents TOCTOU race attacks where an attacker could switch a
* directory to a symlink between path validation and file open. Match the
* gate in vfs_relpath_active() (syscall.c) -- the protection has nothing
* to do with symlink munging, so a module configured with "munge symlinks =
* false" must still get the secure-open path. */
use_secure_symlinks = am_daemon && (!am_chrooted || module_dirlen)
&& !vfs_symlink_optout_allowed();
if (gid_list.count) {
gid_t *gid_array = gid_list.items;
if (setgid(gid_array[0])) {
@@ -1054,9 +1152,14 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
}
}
/* This deadline is checked only in the read path, so the preceding local
* setup and hooks can take as long as necessary. Keep one absolute bound
* across both read_args() calls: anonymous modules must not be able to pin
* a max-connections slot by trickling an unterminated argument forever. */
set_daemon_handshake_timeout(daemon_handshake_timeout(module_id));
io_printf(f_out, "@RSYNCD: OK\n");
read_args(f_in, name, line, sizeof line, rl_nulls, &argv, &argc, &request);
read_args(f_in, name, line, sizeof line, rl_nulls, 1, &argv, &argc, &request);
orig_argv = argv;
save_munge_symlinks = munge_symlinks;
@@ -1066,11 +1169,12 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
if (protect_args && ret) {
orig_early_argv = orig_argv;
protect_args = 2;
read_args(f_in, name, line, sizeof line, 1, &argv, &argc, &request);
read_args(f_in, name, line, sizeof line, 1, 0, &argv, &argc, &request);
orig_argv = argv;
ret = parse_arguments(&argc, (const char ***) &argv);
} else
orig_early_argv = NULL;
set_daemon_handshake_timeout(0);
/* The default is to use the user's setting unless the module sets True or False. */
if (lp_open_noatime(module_id) >= 0)
@@ -1210,14 +1314,20 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
return 0;
}
static BOOL namecvt_safe_token(const char *s);
BOOL namecvt_call(const char *cmd, const char **name_p, id_t *id_p)
{
char buf[1024];
int got, len;
if (*name_p)
if (*name_p) {
if (!namecvt_safe_token(*name_p)) {
rprintf(FERROR, "invalid name-converter token: %s\n", *name_p);
return False;
}
len = snprintf(buf, sizeof buf, "%s %s\n", cmd, *name_p);
else
} else
len = snprintf(buf, sizeof buf, "%s %ld\n", cmd, (long)*id_p);
if (len >= (int)sizeof buf) {
rprintf(FERROR, "namecvt_call() request was too large.\n");
@@ -1234,14 +1344,39 @@ BOOL namecvt_call(const char *cmd, const char **name_p, id_t *id_p)
if (!read_line_old(namecvt_fd_ans, buf, sizeof buf, 0))
return False;
if (*name_p)
*id_p = (id_t)atol(buf);
else
if (*name_p) {
/* Name-to-id: an unknown name returns an empty line and atol("")=0
* would map it to root, so validate strictly below (all digits, no
* ERANGE, fits id_t). */
const char *p;
unsigned long v;
if (!*buf)
return False;
for (p = buf; *p; p++) {
if (*p < '0' || *p > '9')
return False;
}
errno = 0;
v = strtoul(buf, NULL, 10);
if (errno == ERANGE || v > (unsigned long)(id_t)-1)
return False;
*id_p = (id_t)v;
} else
*name_p = strdup(buf);
return True;
}
static BOOL namecvt_safe_token(const char *s)
{
for (; *s; s++) {
unsigned char ch = (unsigned char)*s;
if (ch < ' ' || ch == 0x7f)
return False;
}
return True;
}
/* send a list of available modules to the client. Don't list those
with "list = False". */
static void send_listing(int fd)
@@ -1258,6 +1393,18 @@ static void send_listing(int fd)
io_printf(fd,"@RSYNCD: EXIT\n");
}
static int proxy_peer_allowed(int fd)
{
const char *host = undetermined_hostname;
const char *addr = client_addr(fd);
if (!allow_proxy_protocol_peer(lp_proxy_protocol_hosts(), addr, &host)) {
rprintf(FLOG, "proxy protocol rejected from untrusted peer %s (%s)\n", host, addr);
return 0;
}
return 1;
}
static int load_config(int globals_only)
{
if (!config_file) {
@@ -1295,16 +1442,60 @@ int start_daemon(int f_in, int f_out)
if (!load_config(0))
exit_cleanup(RERR_SYNTAX);
if (lp_proxy_protocol() && !read_proxy_protocol_header(f_in))
return -1;
/* Bound the handshake before ANY peer input is read -- the PROXY-protocol
* header below is peer-supplied too, and was previously unbounded. An
* rsh-run daemon is not a listener and has no shared slot to exhaust. */
if (am_daemon > 0)
set_daemon_handshake_timeout(daemon_handshake_timeout(-1));
if (lp_proxy_protocol()) {
if (!proxy_peer_allowed(f_in) || !read_proxy_protocol_header(f_in))
return -1;
}
/* Do reverse DNS lookup before chroot/setuid. The result is cached,
* so the later client_name() call will use this cached value. This
* ensures hostname-based ACLs work even when DNS is unavailable
* after chroot.
*
* "reverse lookup" can be set globally OR per-module, so we also
* scan each module: a deployment with "reverse lookup = no" in the
* global section but "reverse lookup = yes" in a specific module
* still triggers a post-chroot lookup at access-check time
* (rsync_module() in this file), which would also fail in the
* chroot and turn hostname-based deny rules into silent bypasses. */
{
int need_reverse = lp_reverse_lookup(-1);
int j, num_modules = lp_num_modules();
for (j = 0; !need_reverse && j < num_modules; j++) {
if (lp_reverse_lookup(j))
need_reverse = 1;
}
if (need_reverse)
(void)client_name(client_addr(f_in));
}
p = lp_daemon_chroot();
if (*p) {
log_init(0); /* Make use we've initialized syslog before chrooting. */
tzset();
if (chroot(p) < 0) {
rsyserr(FLOG, errno, "daemon chroot(\"%s\") failed", p);
return -1;
}
/* Deliberately do NOT set am_chrooted here. am_chrooted
* gates the per-module symlink-race defenses
* (vfs_resolve_open() and the do_*_at() wrappers in
* syscall.c) and means "the kernel is enforcing path
* confinement at the module boundary". The daemon chroot
* confines path resolution to the daemon-chroot directory,
* not to any individual module path -- modules sharing the
* daemon chroot are still distinguishable filesystem
* subtrees and a sender-controlled symlink in module A
* could redirect a syscall to module B (or to other files
* inside the daemon chroot) without the per-module
* defenses. Leave am_chrooted=0 here so vfs_resolve_open()
* still fires for "use chroot = no" modules. */
if (chdir("/") < 0) {
rsyserr(FLOG, errno, "daemon chdir(\"/\") failed");
return -1;
@@ -1347,6 +1538,7 @@ int start_daemon(int f_in, int f_out)
set_nonblocking(f_in);
}
if (exchange_protocols(f_in, f_out, line, sizeof line, 0) < 0)
return -1;
@@ -1401,36 +1593,73 @@ static void create_pid_file(void)
char pidbuf[32];
STRUCT_STAT st1, st2;
char *fail = NULL;
const char *base = pid_file;
int pdfd = -1;
if (!pid_file || !*pid_file)
return;
#ifdef O_NOFOLLOW
#define SAFE_OPEN_FLAGS (O_CREAT|O_NOFOLLOW)
#define SAFE_NOFOLLOW O_NOFOLLOW
#else
#define SAFE_OPEN_FLAGS (O_CREAT)
#define SAFE_NOFOLLOW 0
#endif
#ifdef AT_FDCWD
/* Pin the parent directory so the existence check, open and re-stat below
* all resolve the leaf against one stable directory inode, removing the
* lstat->open path race. The parent is operator-configured and trusted, so
* it is opened following symlinks (e.g. a /var/run -> /run); only the leaf
* is opened/checked O_NOFOLLOW (the do_*_atfd wrappers force that). */
{
const char *slash = strrchr(pid_file, '/');
char dirbuf[MAXPATHLEN];
const char *dir = ".";
if (slash) {
size_t dlen = slash == pid_file ? 1 : (size_t)(slash - pid_file);
if (dlen >= sizeof dirbuf) {
rprintf(FLOG, "pid file path is too long: %s\n", pid_file);
exit_cleanup(RERR_FILEIO);
}
memcpy(dirbuf, pid_file, dlen);
dirbuf[dlen] = '\0';
dir = dirbuf;
base = slash + 1;
}
if ((pdfd = vfs_open(dir, O_RDONLY|O_DIRECTORY, 0)) < 0) {
rsyserr(FLOG, errno, "failed to open pid-file directory \"%s\"", dir);
exit_cleanup(RERR_FILEIO);
}
}
#define PID_LSTAT(stp) vfs_lstat(pdfd, base, stp, 0)
#define PID_UNLINK() vfs_unlink(pdfd, base, 0)
#define PID_OPEN() vfs_open_atfd(pdfd, base, O_RDWR|O_CREAT, 0664)
#else
#define PID_LSTAT(stp) vfs_lstat(VFS_AT_FDCWD, base, stp, VFS_ALLOW_SYMLINK)
#define PID_UNLINK() unlink(base)
#define PID_OPEN() vfs_open(base, O_RDWR|O_CREAT|SAFE_NOFOLLOW, 0664)
#endif
/* These tests make sure that a temp-style lock dir is handled safely. */
st1.st_mode = 0;
if (do_lstat(pid_file, &st1) == 0 && !S_ISREG(st1.st_mode) && unlink(pid_file) < 0)
if (PID_LSTAT(&st1) == 0 && !S_ISREG(st1.st_mode) && PID_UNLINK() < 0)
fail = "unlink";
else if ((pid_file_fd = do_open(pid_file, O_RDWR|SAFE_OPEN_FLAGS, 0664)) < 0)
else if ((pid_file_fd = PID_OPEN()) < 0)
fail = S_ISREG(st1.st_mode) ? "open" : "create";
else if (!lock_range(pid_file_fd, 0, 4))
fail = "lock";
else if (do_fstat(pid_file_fd, &st1) < 0)
else if (vfs_fstat(pid_file_fd, &st1) < 0)
fail = "fstat opened";
else if (st1.st_size > (int)sizeof pidbuf)
fail = "find small";
else if (do_lstat(pid_file, &st2) < 0)
else if (PID_LSTAT(&st2) < 0)
fail = "lstat";
else if (!S_ISREG(st1.st_mode))
fail = "avoid file overwrite race for";
else if (st1.st_dev != st2.st_dev || st1.st_ino != st2.st_ino)
fail = "verify stat info for";
#ifdef HAVE_FTRUNCATE
else if (do_ftruncate(pid_file_fd, 0) < 0)
else if (vfs_ftruncate(pid_file_fd, 0) < 0)
fail = "truncate";
#endif
else {
@@ -1447,6 +1676,13 @@ static void create_pid_file(void)
cleanup_set_pid(pid); /* Mark the file for removal on exit, even if the write failed. */
}
#undef PID_LSTAT
#undef PID_UNLINK
#undef PID_OPEN
#undef SAFE_NOFOLLOW
if (pdfd >= 0)
close(pdfd);
if (fail) {
char msg[1024];
snprintf(msg, sizeof msg, "failed to %s pid file %s: %s\n",
@@ -1470,6 +1706,7 @@ static void become_daemon(void)
fprintf(stderr, "failed to fork: %s\n", strerror(errno));
exit_cleanup(RERR_FILEIO);
}
gcov_flush();
_exit(0);
}
@@ -1515,6 +1752,17 @@ int daemon_main(void)
}
set_dparams(0);
/* "proxy protocol = true" with no trusted-proxy list rejects every
* connection as an untrusted proxy peer (fail-closed). That is intended,
* but silent at startup, so warn the operator while stderr is still open. */
if (lp_proxy_protocol()
&& (!lp_proxy_protocol_hosts() || !*lp_proxy_protocol_hosts())) {
rprintf(FWARNING,
"\"proxy protocol = true\" but \"proxy protocol hosts\" is unset:"
" all connections will be rejected as untrusted proxy peers."
" Set \"proxy protocol hosts\" to your trusted proxy's address.\n");
}
if (no_detach)
create_pid_file();
else
+25 -13
View File
@@ -52,6 +52,7 @@ extern int need_messages_from_generator;
extern int delete_mode, delete_before, delete_during, delete_after;
extern int do_compression;
extern int do_compression_level;
extern int do_compression_threads;
extern int saw_stderr_opt;
extern int msgs2stderr;
extern char *shell_cmd;
@@ -131,7 +132,7 @@ static const char *client_info;
* of that protocol for it to be advertised as available. */
static void check_sub_protocol(void)
{
char *dot;
const char *dot;
int their_protocol, their_sub;
int our_sub = get_subprotocol_version();
@@ -350,7 +351,7 @@ static int parse_negotiate_str(struct name_num_obj *nno, char *tmpbuf)
continue;
ret = nni;
best = nno->saw[nni->num];
if (best == 1 || am_server) /* The server side stops at the first acceptable client choice */
if (best == 1) /* Can't improve on our own #1 preference */
break;
}
if (ret) {
@@ -414,7 +415,7 @@ static const char *getenv_nstr(int ntype)
env_str = ntype == NSTR_COMPRESS ? "zlib" : protocol_version >= 30 ? "md5" : "md4";
if (am_server && env_str) {
char *cp = strchr(env_str, '&');
const char *cp = strchr(env_str, '&');
if (cp)
env_str = cp + 1;
}
@@ -525,8 +526,11 @@ static void send_negotiate_str(int f_out, struct name_num_obj *nno, int ntype)
rprintf(FINFO, "Client %s list (on client): %s\n", nno->type, tmpbuf);
}
/* Each side sends their list of valid names to the other side and then both sides
* pick the first name in the client's list that is also in the server's list. */
/* Each side sends their list of valid names to the other side and then each
* side picks its own most-preferred name that also appears in the peer's
* list. Honest peers emit their list in table (strongest-first) order via
* get_default_nno_list(), so both sides converge on the strongest mutual
* choice; a peer that front-loads a weaker name only desyncs itself. */
if (do_negotiated_strings)
write_vstring(f_out, tmpbuf, len);
}
@@ -584,14 +588,13 @@ void setup_protocol(int f_out,int f_in)
pathname_ndx = (file_extra_cnt += PTR_EXTRA_CNT);
else
depth_ndx = ++file_extra_cnt;
if (preserve_uid)
uid_ndx = ++file_extra_cnt;
if (preserve_gid)
gid_ndx = ++file_extra_cnt;
if (preserve_acls && !am_sender)
acls_ndx = ++file_extra_cnt;
if (preserve_xattrs)
xattrs_ndx = ++file_extra_cnt;
/* uid_ndx/gid_ndx/acls_ndx/xattrs_ndx are assigned AFTER
* check_batch_flags() below: a batch file's stream-flags can flip
* preserve_uid/gid/acls/xattrs on, and computing the *_ndx slots
* before that leaves e.g. preserve_xattrs=1 with xattrs_ndx=0 -- so
* F_XATTR(file) (= REQ_EXTRA(file, 0)) writes at offset 0 of every
* file_struct, clobbering file->dirname. Nothing between here and
* check_batch_flags() reads file_extra_cnt or the *_ndx values. */
if (am_server)
set_allow_inc_recurse();
@@ -638,6 +641,15 @@ void setup_protocol(int f_out,int f_in)
if (read_batch)
check_batch_flags();
if (preserve_uid)
uid_ndx = ++file_extra_cnt;
if (preserve_gid)
gid_ndx = ++file_extra_cnt;
if (preserve_acls && !am_sender)
acls_ndx = ++file_extra_cnt;
if (preserve_xattrs)
xattrs_ndx = ++file_extra_cnt;
if (!saw_stderr_opt && protocol_version <= 28 && am_server)
msgs2stderr = 0; /* The client side may not have stderr setup for us. */
+160 -49
View File
@@ -5,7 +5,7 @@ AC_INIT([rsync],[ ],[https://rsync.samba.org/bug-tracking.html])
AC_C_BIGENDIAN
AC_HEADER_DIRENT
AC_HEADER_SYS_WAIT
AC_CHECK_HEADERS(sys/fcntl.h sys/select.h fcntl.h sys/time.h sys/unistd.h \
AC_CHECK_HEADERS(poll.h sys/fcntl.h sys/select.h fcntl.h sys/time.h sys/unistd.h \
unistd.h utime.h compat.h sys/param.h ctype.h sys/wait.h sys/stat.h \
sys/ioctl.h sys/filio.h string.h stdlib.h sys/socket.h sys/mode.h grp.h \
sys/un.h sys/attr.h arpa/inet.h arpa/nameser.h locale.h sys/types.h \
@@ -13,7 +13,7 @@ AC_CHECK_HEADERS(sys/fcntl.h sys/select.h fcntl.h sys/time.h sys/unistd.h \
sys/acl.h acl/libacl.h attr/xattr.h sys/xattr.h sys/extattr.h dl.h \
popt.h popt/popt.h linux/falloc.h netinet/in_systm.h netgroup.h \
zlib.h xxhash.h openssl/md4.h openssl/md5.h zstd.h lz4.h sys/file.h \
bsd/string.h)
sys/resource.h bsd/string.h)
AC_CHECK_HEADERS([netinet/ip.h], [], [], [[#include <netinet/in.h>]])
AC_HEADER_MAJOR_FIXED
@@ -60,6 +60,8 @@ AC_PROG_AWK
AC_PROG_EGREP
AC_PROG_INSTALL
AC_PROG_MKDIR_P
AC_CHECK_TOOL([AR], [ar], [ar])
AC_PROG_RANLIB
AC_SUBST(SHELL)
AC_PATH_PROG([PERL], [perl])
AC_PATH_PROG([PYTHON3], [python3])
@@ -82,6 +84,34 @@ if test x"$enable_profile" = x"yes"; then
CFLAGS="$CFLAGS -pg"
fi
dnl Coverage build (gcov) for `make coverage`. NOTE: --enable-profile above is
dnl gprof (-pg) and is NOT coverage. -O0 keeps branch coverage meaningful;
dnl -fprofile-update=atomic keeps the shared .gcda counters correct while the
dnl suite runs many rsync processes in parallel.
AC_ARG_ENABLE(coverage,
AS_HELP_STRING([--enable-coverage],[build with gcov instrumentation for `make coverage`]))
if test x"$enable_coverage" = x"yes"; then
CFLAGS="$CFLAGS --coverage -fprofile-update=atomic -O0"
CXXFLAGS="$CXXFLAGS --coverage -fprofile-update=atomic -O0"
LDFLAGS="$LDFLAGS --coverage"
AC_DEFINE([GCOV_COVERAGE], 1,
[Flush gcov counters at exit_cleanup: rsync's children exit via _exit(), which bypasses the gcov atexit handler, so without this no .gcda is written for the receiver/generator/daemon-worker processes.])
fi
dnl openat2(RESOLVE_BENEATH) is used on Linux 5.6+ for the secure resolver.
dnl --disable-openat2 forces the portable per-component O_NOFOLLOW fallback to
dnl run as the primary resolver on ordinary Linux, so that tier is exercised
dnl (and coverage-counted) without needing a pre-5.6 kernel. Behaviour-neutral
dnl by default (the knob only REMOVES a tier when explicitly disabled).
AC_ARG_ENABLE(openat2,
AS_HELP_STRING([--disable-openat2],[do not use Linux openat2(RESOLVE_BENEATH); force the portable resolver (for exercising the fallback tier)]))
AC_ARG_ENABLE(strict-confinement,
AS_HELP_STRING([--enable-strict-confinement],[abort if a confined receiver ever does a raw path-based metadata op (a CI/dev hardening check; no effect on a normal build)]))
if test x"$enable_strict_confinement" = x"yes"; then
AC_DEFINE(STRICT_CONFINEMENT, 1, [Define to abort on a confined-regime raw path-based metadata op (CI hardening check)])
fi
AC_MSG_CHECKING([if md2man can create manpages])
if test x"$ac_cv_path_PYTHON3" = x; then
AC_MSG_RESULT(no - python3 not found)
@@ -331,6 +361,28 @@ AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[ ]], [[return 0;]])],
CFLAGS="$OLD_CFLAGS"
AC_SUBST(NOEXECSTACK)
dnl We need both the SYS_openat2 syscall number and <linux/openat2.h> (for
dnl struct open_how / RESOLVE_BENEATH); some setups have one without the other.
AC_CACHE_CHECK([for openat2],rsync_cv_HAVE_OPENAT2,[
AC_COMPILE_IFELSE([
AC_LANG_PROGRAM([[
#include <sys/syscall.h>
#include <linux/openat2.h>
]], [[
struct open_how how;
how.resolve = RESOLVE_BENEATH;
return SYS_openat2 + (int)how.resolve;
]])
],
[rsync_cv_HAVE_OPENAT2=yes], [rsync_cv_HAVE_OPENAT2=no])
])
if test x"$enable_openat2" != x"no"; then
if test x"$rsync_cv_HAVE_OPENAT2" = x"yes"; then
AC_DEFINE([HAVE_OPENAT2], 1,
[Define to use Linux openat2(RESOLVE_BENEATH) in vfs_resolve_open where available.])
fi
fi
# arrgh. libc in some old debian version screwed up the largefile
# stuff, getting byte range locking wrong
AC_CACHE_CHECK([for broken largefile support],rsync_cv_HAVE_BROKEN_LARGEFILE,[
@@ -388,21 +440,17 @@ AS_HELP_STRING([--disable-ipv6],[disable to omit ipv6 support]),
;;
esac ],
AC_RUN_IFELSE([AC_LANG_SOURCE([[ /* AF_INET6 availability check */
#include <stdlib.h>
AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[
#include <sys/types.h>
#include <sys/socket.h>
int main()
{
if (socket(AF_INET6, SOCK_STREAM, 0) < 0)
exit(1);
else
exit(0);
}
#include <netinet/in.h>
]], [[
struct sockaddr_in6 sa6;
(void)sa6;
(void)AF_INET6;
]])],
[AC_MSG_RESULT(yes)
AC_DEFINE(INET6, 1, true if you have IPv6)],
[AC_MSG_RESULT(no)],
AC_DEFINE(INET6, 1, [true if you have IPv6])],
[AC_MSG_RESULT(no)]
))
@@ -869,6 +917,19 @@ AC_HAVE_TYPE([struct stat64], [#include <stdio.h>
# if we can't find strcasecmp, look in -lresolv (for Unixware at least)
#
dnl rsync's I/O readiness loops use poll() rather than select() so that a
dnl file descriptor at or above FD_SETSIZE cannot overflow an fd_set (which
dnl is undefined behaviour and could hang the transfer). poll() is in
dnl POSIX.1-2001; fail early and clearly if this target lacks it.
dnl
dnl io.c and socket.c include <poll.h> unconditionally, so the HEADER has to
dnl be required too: a system that exposes poll() through some other header
dnl would otherwise pass configure and then fail to compile.
AC_CHECK_FUNCS([poll], , [AC_MSG_ERROR([rsync requires poll(); please report the platform to the rsync developers])])
if test x"$ac_cv_header_poll_h" != x"yes"; then
AC_MSG_ERROR([rsync requires <poll.h>; please report the platform to the rsync developers])
fi
AC_CHECK_FUNCS(strcasecmp)
if test x"$ac_cv_func_strcasecmp" = x"no"; then
AC_CHECK_LIB(resolv, strcasecmp)
@@ -886,17 +947,30 @@ dnl AC_FUNC_MEMCMP
AC_FUNC_UTIME_NULL
AC_FUNC_ALLOCA
AC_CHECK_FUNCS(waitpid wait4 getcwd chown chmod lchmod mknod mkfifo \
AC_CHECK_FUNCS(waitpid wait4 getcwd chown chmod lchmod mknod mkfifo fdopendir \
getrlimit setrlimit \
fchmod fstat ftruncate strchr readlink link utime utimes lutimes strftime \
chflags getattrlist mktime innetgr linkat \
chflags getattrlist mktime innetgr linkat mknodat mkfifoat \
memmove lchown vsnprintf snprintf vasprintf asprintf setsid strpbrk \
strlcat strlcpy stpcpy strtol mallinfo mallinfo2 getgroups setgroups geteuid getegid \
setlocale setmode open64 lseek64 mkstemp64 mtrace va_copy __va_copy \
seteuid strerror putenv iconv_open locale_charset nl_langinfo getxattr \
extattr_get_link sigaction sigprocmask setattrlist getgrouplist \
initgroups utimensat posix_fallocate attropen setvbuf nanosleep usleep \
initgroups utimensat futimens posix_fallocate attropen setvbuf nanosleep usleep \
setenv unsetenv)
dnl dirfd() is a macro or static inline on several systems (the BSDs), so the
dnl default AC_CHECK_FUNCS link probe -- which declares `char dirfd(void);` and
dnl links against a bare symbol -- gives a false negative there. Probe it with a
dnl real compile+link that includes <dirent.h> and actually calls dirfd().
AC_CACHE_CHECK([for dirfd], rsync_cv_HAVE_DIRFD,
[AC_LINK_IFELSE([AC_LANG_PROGRAM([[#include <dirent.h>]],
[[DIR *d = opendir("."); return d ? dirfd(d) < -1 : 0;]])],
[rsync_cv_HAVE_DIRFD=yes], [rsync_cv_HAVE_DIRFD=no])])
if test x"$rsync_cv_HAVE_DIRFD" = x"yes"; then
AC_DEFINE([HAVE_DIRFD], 1, [Define to 1 if you have a working dirfd() (function or macro).])
fi
dnl cygwin iconv.h defines iconv_open as libiconv_open
if test x"$ac_cv_func_iconv_open" != x"yes"; then
AC_CHECK_FUNC(libiconv_open, [ac_cv_func_iconv_open=yes; AC_DEFINE(HAVE_ICONV_OPEN, 1)])
@@ -1218,37 +1292,14 @@ if test x"$rsync_cv_HAVE_SECURE_MKSTEMP" = x"yes"; then
fi
AC_CACHE_CHECK([if mknod creates FIFOs],rsync_cv_MKNOD_CREATES_FIFOS,[
AC_RUN_IFELSE([AC_LANG_SOURCE([[
#include <stdio.h>
#include <sys/stat.h>
#include <errno.h>
#if HAVE_UNISTD_H
# include <unistd.h>
#endif
int main(void) { int rc, ec; char *fn = "fifo-test";
unlink(fn); rc = mknod(fn,S_IFIFO,0600); ec = errno; unlink(fn);
if (rc) {printf("(%d %d) ",rc,ec); return ec;}
return 0;}]])],[rsync_cv_MKNOD_CREATES_FIFOS=yes],[rsync_cv_MKNOD_CREATES_FIFOS=no],[rsync_cv_MKNOD_CREATES_FIFOS=cross])])
if test x"$rsync_cv_MKNOD_CREATES_FIFOS" = x"yes"; then
AC_DEFINE(MKNOD_CREATES_FIFOS, 1, [Define to 1 if mknod() can create FIFOs.])
fi
AC_CACHE_CHECK([if mknod creates sockets],rsync_cv_MKNOD_CREATES_SOCKETS,[
AC_RUN_IFELSE([AC_LANG_SOURCE([[
#include <stdio.h>
#include <sys/stat.h>
#include <errno.h>
#if HAVE_UNISTD_H
# include <unistd.h>
#endif
int main(void) { int rc, ec; char *fn = "sock-test";
unlink(fn); rc = mknod(fn,S_IFSOCK,0600); ec = errno; unlink(fn);
if (rc) {printf("(%d %d) ",rc,ec); return ec;}
return 0;}]])],[rsync_cv_MKNOD_CREATES_SOCKETS=yes],[rsync_cv_MKNOD_CREATES_SOCKETS=no],[rsync_cv_MKNOD_CREATES_SOCKETS=cross])])
if test x"$rsync_cv_MKNOD_CREATES_SOCKETS" = x"yes"; then
AC_DEFINE(MKNOD_CREATES_SOCKETS, 1, [Define to 1 if mknod() can create sockets.])
fi
# Whether mknod()/mknodat() can create a FIFO or a unix-domain socket is a
# property of the target filesystem, not a build-time constant -- e.g. mknod
# makes sockets on Linux but not the BSDs/macOS/Solaris, and a single transfer
# can write to filesystems with different capabilities. So rsync no longer
# probes this at configure time (a run-test that also misfired when cross-
# compiling); do_mknod*() just try mknod[at]() and, on failure, fall back to
# mkfifo[at]()/socket+bind() per call. We only need the libc symbols, checked
# above via AC_CHECK_FUNCS (mknod mknodat mkfifo mkfifoat) -- all link tests.
#
# The following test was mostly taken from the tcl/tk plus patches
@@ -1392,7 +1443,7 @@ else
AC_DEFINE(HAVE_LINUX_XATTRS, 1, [True if you have Linux xattrs (or equivalent)])
AC_DEFINE(SUPPORT_XATTRS, 1)
AC_DEFINE(NO_SYMLINK_USER_XATTRS, 1, [True if symlinks do not support user xattrs])
AC_CHECK_LIB(attr,getxattr)
AC_SEARCH_LIBS(getxattr,attr)
;;
darwin*)
AC_MSG_RESULT(Using OS X xattrs)
@@ -1422,6 +1473,66 @@ else
esac
fi
#################################################
# On Linux, POSIX ACLs are stored as the "system.posix_acl_{access,default}"
# xattrs, so we can get/set them through a held O_NOFOLLOW fd (fsetxattr) or a
# dirfd+leaf (setxattrat, AT_SYMLINK_NOFOLLOW) instead of the path-based libacl
# acl_*_file() calls -- making the operation safe against a parent-symlink race.
# This needs POSIX ACLs and the f/at xattr syscalls, which on Linux are
# available whenever <sys/xattr.h> (or <attr/xattr.h>) is -- independent of the
# -X feature (--disable-xattr-support), so we gate on the header, not
# enable_xattr_support.
AH_TEMPLATE([SUPPORT_ACL_FD],
[Define to 1 to do POSIX ACL ops via fd/at xattr syscalls (lib/acl.c)])
AH_TEMPLATE([HAVE_XATTRAT_SYSCALLS],
[Define to 1 if the setxattrat/getxattrat/removexattrat syscalls are available])
if test x"$samba_cv_HAVE_POSIX_ACLS" = x"yes" \
&& { test x"$ac_cv_header_sys_xattr_h" = x"yes" || test x"$ac_cv_header_attr_xattr_h" = x"yes"; }; then
case "$host_os" in
*linux*)
AC_DEFINE(SUPPORT_ACL_FD, 1)
AC_CACHE_CHECK([for SYS_setxattrat],rsync_cv_have_sys_setxattrat,[
AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[#include <sys/syscall.h>
#include <stdint.h>
#ifdef HAVE_UNISTD_H
#include <unistd.h>
#endif
struct xattr_args { uint64_t value; uint32_t size; uint32_t flags; };]],
[[struct xattr_args a; a.value = 0; a.size = 0; a.flags = 0;
syscall(SYS_setxattrat, 0, ".", 0, "n", &a, sizeof a);
syscall(SYS_getxattrat, 0, ".", 0, "n", &a, sizeof a);
syscall(SYS_removexattrat, 0, ".", 0, "n");]])],[rsync_cv_have_sys_setxattrat=yes],[rsync_cv_have_sys_setxattrat=no])])
if test x"$rsync_cv_have_sys_setxattrat" = x"yes"; then
AC_DEFINE(HAVE_XATTRAT_SYSCALLS, 1)
fi
;;
esac
fi
#################################################
# Detect a patched libacl providing the race-safe
# *_at ACL entry points (acl_get_file_at/acl_set_file_at/acl_delete_def_file_at,
# ACL_1.3, unreleased upstream). When present we route the race-safe ACL get/
# set/delete through them on Linux -- race-safe on every kernel (6.13+ uses
# *xattrat; older uses libacl's /proc/self/fd compat). A stock -lacl lacks these
# symbols, so this stays undefined and the build falls back to lib/acl.c;
# detection must therefore run against the patched lib (CPPFLAGS/LDFLAGS).
AH_TEMPLATE([HAVE_LIBACL_AT],
[Define to 1 if libacl provides acl_get_file_at/acl_set_file_at/acl_delete_def_file_at])
if test x"$samba_cv_HAVE_POSIX_ACLS" = x"yes"; then
case "$host_os" in
*linux*)
AC_CHECK_LIB(acl, acl_get_file_at, [rsync_have_libacl_at=yes], [rsync_have_libacl_at=no])
if test x"$rsync_have_libacl_at" = x"yes"; then
AC_CHECK_FUNCS([acl_set_file_at acl_delete_def_file_at], [], [rsync_have_libacl_at=no])
fi
if test x"$rsync_have_libacl_at" = x"yes"; then
AC_DEFINE(HAVE_LIBACL_AT, 1)
fi
;;
esac
fi
if test x"$enable_acl_support" = x"no" || test x"$enable_xattr_support" = x"no" || test x"$enable_iconv" = x"no"; then
AC_MSG_CHECKING([whether $CC supports -Wno-unused-parameter])
OLD_CFLAGS="$CFLAGS"
@@ -1439,7 +1550,7 @@ case "$CC" in
;;
esac
AC_CONFIG_FILES([Makefile lib/dummy zlib/dummy popt/dummy shconfig])
AC_CONFIG_FILES([Makefile lib/dummy zlib/dummy popt/dummy vfs/dummy shconfig])
AC_OUTPUT
AC_MSG_RESULT()
+3 -1
View File
@@ -30,7 +30,9 @@ int claim_connection(char *fname, int max_connections)
if (max_connections == 0)
return 1;
if ((fd = open(fname, O_RDWR|O_CREAT, 0600)) < 0)
/* 'lock file = PATH': refuse symlinks not owned by uid 0 or our euid so
* a planted parent can't redirect the root daemon's O_CREAT open. */
if ((fd = vfs_open_owner_walk(fname, O_RDWR|O_CREAT, 0600, 0)) < 0)
return 0;
/* Find a free spot. */
+9 -2
View File
@@ -6,7 +6,7 @@
BEGIN {
heading = "/* DO NOT EDIT THIS FILE! It is auto-generated from a list of values in " ARGV[1] "! */\n\n"
sect = psect = defines = accessors = prior_ptype = ""
parms = "\nstatic struct parm_struct parm_table[] = {"
parms = "\nstatic const struct parm_struct parm_table[] = {"
comment_fmt = "\n/********** %s **********/\n"
tdstruct = "typedef struct {"
}
@@ -84,7 +84,14 @@ BEGIN {
defines = defines "\t" vtype " " name ";\n"
values = values "\t" $0 ", /* " name " */\n"
parms = parms " {\"" pubname "\", P_" ptype psect name ", " enum ", 0},\n"
accessors = accessors "FN_" sect "_" atype "(lp_" name ", " name ")\n"
# The shell-executed hook params (whose %RSYNC_*% expansion is fed to
# /bin/sh) use the _SHELL accessor, which single-quotes peer-controlled
# values to prevent injection. Ordinary string params must NOT quote --
# it would corrupt a documented `path = /home/%RSYNC_USER_NAME%` etc.
if (atype == "STRING" && (name == "early_exec" || name == "prexfer_exec" || name == "postxfer_exec" || name == "name_converter"))
accessors = accessors "FN_" sect "_STRING_SHELL(lp_" name ", " name ")\n"
else
accessors = accessors "FN_" sect "_" atype "(lp_" name ", " name ")\n"
if (vtype == "char*") {
exps = exps "\tBOOL " name "_EXP;\n"
+3
View File
@@ -6,6 +6,7 @@ STRING daemon_gid NULL
STRING daemon_uid NULL
STRING motd_file NULL
STRING pid_file NULL
STRING proxy_protocol_hosts NULL
STRING socket_options NULL
INTEGER listen_backlog 5
@@ -15,6 +16,7 @@ BOOL proxy_protocol False
Locals: =================================================================
STRING auth_digest NULL
STRING auth_users NULL
STRING charset NULL
STRING comment NULL
@@ -55,6 +57,7 @@ BOOL fake_super False
BOOL forward_lookup True
BOOL ignore_errors False
BOOL ignore_nonreadable False
BOOL insecure_links False
BOOL list True
BOOL read_only True
BOOL reverse_lookup True
+75 -5
View File
@@ -34,6 +34,54 @@ int ignore_perishable = 0;
int non_perishable_cnt = 0;
int skipped_deletes = 0;
/* Held fd of the directory whose contents delete_dir_contents() is currently
* removing, so delete_item()'s per-entry rmdir/unlink/chmod go through it
* instead of re-resolving the full path for every entry. Set (with save/
* restore across the recursion) around the delete loop; -1 outside a recursive
* delete or when the secure resolver is gated off (chroot / non-receiver) or
* the path doesn't live directly in that dir. */
static int del_dirfd = -1;
static const char *del_dir_prefix;
static int del_dir_prefix_len;
/* If `path` is a single component directly inside the dir being deleted,
* point *leaf at its basename and return the held dir fd; else return -1. */
static int del_held_dfd(const char *path, const char **leaf)
{
if (del_dirfd >= 0
&& strncmp(path, del_dir_prefix, del_dir_prefix_len) == 0
&& path[del_dir_prefix_len] == '/'
&& strchr(path + del_dir_prefix_len + 1, '/') == NULL) {
*leaf = path + del_dir_prefix_len + 1;
return del_dirfd;
}
return -1;
}
static void del_chmod(const char *fbuf, mode_t mode)
{
const char *leaf;
int dfd = del_held_dfd(fbuf, &leaf);
if (dfd >= 0)
vfs_chmod(dfd, leaf, mode, 0);
else
vfs_chmod(VFS_AT_FDCWD, fbuf, mode, 0);
}
/* vfs_flags carries VFS_OPERATOR_PATH for a backup-tree delete (DEL_FOR_BACKUP):
* the path-based fallback then resolves the leaf's parent via the ownership walk,
* matching the confinement the base gives this unlink under make_backup() (where
* the held dirfd is absent for a cross-tree --backup-dir leaf). A held-dirfd
* delete is already confined, so it ignores the flag. */
static int del_unlink(const char *fbuf, int vfs_flags)
{
const char *leaf;
int dfd = del_held_dfd(fbuf, &leaf);
if (dfd >= 0 && vfs_unlink(dfd, leaf, 0) == 0)
return 0;
return robust_unlink(fbuf, vfs_flags); /* fall back (ETXTBSY retry, or not held) */
}
static inline int is_backup_file(char *fn)
{
int k = strlen(fn) - backup_suffix_len;
@@ -83,6 +131,18 @@ static enum delret delete_dir_contents(char *fname, uint16 flags)
flags = (flags & ~(DEL_RECURSE|DEL_MAKE_ROOM|DEL_NO_UID_WRITE))
| DEL_DIR_IS_EMPTY;
/* Hold this dir open so the per-entry chmod/rmdir/unlink below (and in
* delete_item) become *at() calls against it rather than re-resolving the
* full path for every entry. Save/restore around the recursion. */
int save_del_dirfd = del_dirfd;
const char *save_del_prefix = del_dir_prefix;
int save_del_prefix_len = del_dir_prefix_len;
fname[dlen] = '\0';
del_dirfd = vfs_opendir(fname);
fname[dlen] = '/';
del_dir_prefix = fname;
del_dir_prefix_len = dlen;
for (j = dirlist->used; j--; ) {
struct file_struct *fp = dirlist->files[j];
@@ -98,7 +158,7 @@ static enum delret delete_dir_contents(char *fname, uint16 flags)
strlcpy(p, fp->basename, remainder);
if (!(fp->mode & S_IWUSR) && !am_root && fp->flags & FLAG_OWNED_BY_US)
do_chmod(fname, fp->mode | S_IWUSR);
del_chmod(fname, fp->mode | S_IWUSR);
/* Save stack by recursing to ourself directly. */
if (S_ISDIR(fp->mode)) {
if (delete_dir_contents(fname, flags | DEL_RECURSE) != DR_SUCCESS)
@@ -108,6 +168,12 @@ static enum delret delete_dir_contents(char *fname, uint16 flags)
ret = DR_NOT_EMPTY;
}
if (del_dirfd >= 0)
close(del_dirfd);
del_dirfd = save_del_dirfd;
del_dir_prefix = save_del_prefix;
del_dir_prefix_len = save_del_prefix_len;
fname[dlen] = '\0';
done:
@@ -139,7 +205,7 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags)
}
if (flags & DEL_NO_UID_WRITE)
do_chmod(fbuf, mode | S_IWUSR);
del_chmod(fbuf, mode | S_IWUSR);
if (S_ISDIR(mode) && !(flags & DEL_DIR_IS_EMPTY)) {
/* This only happens on the first call to delete_item() since
@@ -159,19 +225,23 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags)
}
if (S_ISDIR(mode)) {
const char *leaf;
int dfd = del_held_dfd(fbuf, &leaf);
what = "rmdir";
ok = do_rmdir(fbuf) == 0;
ok = (dfd >= 0 ? vfs_unlink(dfd, leaf, VFS_REMOVEDIR)
: vfs_unlink(VFS_AT_FDCWD, fbuf,
VFS_REMOVEDIR | ((flags & DEL_FOR_BACKUP) ? VFS_OPERATOR_PATH : 0))) == 0;
} else {
if (make_backups > 0 && !(flags & DEL_FOR_BACKUP) && (backup_dir || !is_backup_file(fbuf))) {
what = "make_backup";
ok = make_backup(fbuf, True);
if (ok == 2) {
what = "unlink";
ok = robust_unlink(fbuf) == 0;
ok = del_unlink(fbuf, (flags & DEL_FOR_BACKUP) ? VFS_OPERATOR_PATH : 0) == 0;
}
} else {
what = "unlink";
ok = robust_unlink(fbuf) == 0;
ok = del_unlink(fbuf, (flags & DEL_FOR_BACKUP) ? VFS_OPERATOR_PATH : 0) == 0;
}
}
-20
View File
@@ -1,20 +0,0 @@
Handling the rsync SGML documentation
rsync documentation is now primarily in Docbook format. Docbook is an
SGML/XML documentation format that is becoming standard on free
operating systems. It's also used for Samba documentation.
The SGML files are source code that can be translated into various
useful output formats, primarily PDF, HTML, Postscript and plain text.
To do this transformation on Debian, you should install the
docbook-utils package. Having done that, you can say
docbook2pdf rsync.sgml
and so on.
On other systems you probably need James Clark's "sp" and "JadeTeX"
packages. Work it out for yourself and send a note to the mailing
list.
-42
View File
@@ -1,42 +0,0 @@
Notes on rsync profiling
strlcpy is hot:
0.00 0.00 1/7735635 push_dir [68]
0.00 0.00 1/7735635 pop_dir [71]
0.00 0.00 1/7735635 send_file_list [15]
0.01 0.00 18857/7735635 send_files [4]
0.04 0.00 129260/7735635 send_file_entry [18]
0.04 0.00 129260/7735635 make_file [20]
0.04 0.00 141666/7735635 send_directory <cycle 1> [36]
2.29 0.00 7316589/7735635 f_name [13]
[14] 11.7 2.42 0.00 7735635 strlcpy [14]
Here's the top few functions:
46.23 9.57 9.57 13160929 0.00 0.00 mdfour64
14.78 12.63 3.06 13160929 0.00 0.00 copy64
11.69 15.05 2.42 7735635 0.00 0.00 strlcpy
10.05 17.13 2.08 41438 0.05 0.38 sum_update
4.11 17.98 0.85 13159996 0.00 0.00 mdfour_update
1.50 18.29 0.31 file_compare
1.45 18.59 0.30 129261 0.00 0.01 send_file_entry
1.23 18.84 0.26 2557585 0.00 0.00 f_name
1.11 19.07 0.23 1483750 0.00 0.00 u_strcmp
1.11 19.30 0.23 118129 0.00 0.00 writefd_unbuffered
0.92 19.50 0.19 1085011 0.00 0.00 writefd
0.43 19.59 0.09 156987 0.00 0.00 read_timeout
0.43 19.68 0.09 129261 0.00 0.00 clean_fname
0.39 19.75 0.08 32887 0.00 0.38 matched
0.34 19.82 0.07 1 70.00 16293.92 send_files
0.29 19.89 0.06 129260 0.00 0.00 make_file
0.29 19.95 0.06 75430 0.00 0.00 read_unbuffered
mdfour could perhaps be made faster:
/* NOTE: This code makes no attempt to be fast! */
There might be an optimized version somewhere that we can borrow.
-351
View File
@@ -1,351 +0,0 @@
<!DOCTYPE book PUBLIC "-//OASIS//DTD DocBook V4.1//EN">
<book id="rsync">
<bookinfo>
<title>rsync</title>
<copyright>
<year>1996 -- 2002</year>
<holder>Martin Pool</holder>
<holder>Andrew Tridgell</holder>
</copyright>
<author>
<firstname>Martin</firstname>
<surname>Pool</surname>
</author>
</bookinfo>
<chapter>
<title>Introduction</title>
<para>rsync is a flexible program for efficiently copying files or
directory trees.
<para>rsync has many options to select which files will be copied
and how they are to be transferred. It may be used as an
alternative to ftp, http, scp or rcp.
<para>The rsync remote-update protocol allows rsync to transfer just
the differences between two sets of files across the network link,
using an efficient checksum-search algorithm described in the
technical report that accompanies this package.</para>
<para>Some of the additional features of rsync are:</para>
<itemizedlist>
<listitem>
<para>support for copying links, devices, owners, groups and
permissions
</para>
</listitem>
<listitem>
<para>
exclude and exclude-from options similar to GNU tar
</para>
</listitem>
<listitem>
<para>
a CVS exclude mode for ignoring the same files that CVS would ignore
</listitem>
<listitem>
<para>
can use any transparent remote shell, including rsh or ssh
</listitem>
<listitem>
<para>
does not require root privileges
</listitem>
<listitem>
<para>
pipelining of file transfers to minimize latency costs
</listitem>
<listitem>
<para>
support for anonymous or authenticated rsync servers (ideal for
mirroring)
</para>
</listitem>
</itemizedlist>
</chapter>
<chapter>
<title>Using rsync</title>
<section>
<title>
Introductory example
</title>
<para>
Probably the most common case of rsync usage is to copy files
to or from a remote machine using
<application>ssh</application> as a network transport. In
this situation rsync is a good alternative to
<application>scp</application>.
</para>
<para>
The most commonly used arguments for rsync are
</para>
<variablelist>
<varlistentry>
<term><option>-v</option></term>
<listitem>
<para>Be verbose. Primarily, display the name of each file as it is copied.</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>-a</option></term>
<listitem>
<para>
Reproduce the structure and attributes of the origin files as exactly
as possible: this includes copying subdirectories, symlinks, special
files, ownership and permissions. (@xref{Attributes to
copy}.)
</para>
</listitem>
</varlistentry>
</variablelist>
<para><option>-v </option>
<para><option>-z</option>
Compress network traffic, using a modified version of the
@command{zlib} library.</para>
<para><option>-P</option>
Display a progress indicator while files are transferred. This should
normally be omitted if rsync is not run on a terminal.
</para>
</section>
<section>
<title>Local and remote</title>
<para>There are six different ways of using rsync. They
are:</para>
<!-- one of (CALLOUTLIST GLOSSLIST ITEMIZEDLIST ORDEREDLIST SEGMENTEDLIST SIMPLELIST VARIABLELIST CAUTION IMPORTANT NOTE TIP WARNING LITERALLAYOUT PROGRAMLISTING PROGRAMLISTINGCO SCREEN SCREENCO SCREENSHOT SYNOPSIS CMDSYNOPSIS FUNCSYNOPSIS CLASSSYNOPSIS FIELDSYNOPSIS CONSTRUCTORSYNOPSIS DESTRUCTORSYNOPSIS METHODSYNOPSIS FORMALPARA PARA SIMPARA ADDRESS BLOCKQUOTE GRAPHIC GRAPHICCO MEDIAOBJECT MEDIAOBJECTCO INFORMALEQUATION INFORMALEXAMPLE INFORMALFIGURE INFORMALTABLE EQUATION EXAMPLE FIGURE TABLE MSGSET PROCEDURE SIDEBAR QANDASET ANCHOR BRIDGEHEAD REMARK HIGHLIGHTS ABSTRACT AUTHORBLURB EPIGRAPH INDEXTERM REFENTRY SECTION) -->
<orderedlist>
<listitem>
<para>
for copying local files. This is invoked when neither
source nor destination path contains a @code{:} separator
<listitem>
<para>
for copying from the local machine to a remote machine using
a remote shell program as the transport (such as rsh or
ssh). This is invoked when the destination path contains a
single @code{:} separator.
<listitem>
<para>
for copying from a remote machine to the local machine
using a remote shell program. This is invoked when the source
contains a @code{:} separator.
<listitem>
<para>
for copying from a remote rsync server to the local
machine. This is invoked when the source path contains a @code{::}
separator or a @code{rsync://} URL.
<listitem>
<para>
for copying from the local machine to a remote rsync
server. This is invoked when the destination path contains a @code{::}
separator.
<listitem>
<para>
for listing files on a remote machine. This is done the
same way as rsync transfers except that you leave off the
local destination.
</listitem>
</orderedlist>
<para>
Note that in all cases (other than listing) at least one of the source
and destination paths must be local.
<para>
Any one invocation of rsync makes a copy in a single direction. rsync
currently has no equivalent of @command{ftp}'s interactive mode.
@cindex @sc{nfs}
@cindex network filesystems
@cindex remote filesystems
<para>
rsync's network protocol is generally faster at copying files than
network filesystems such as @sc{nfs} or @sc{cifs}. It is better to
run rsync on the file server either as a daemon or over ssh than
running rsync giving the network directory.
</para>
</section>
</chapter>
<chapter>
<title>Frequently asked questions</title>
<!-- one of (CALLOUTLIST GLOSSLIST ITEMIZEDLIST ORDEREDLIST SEGMENTEDLIST SIMPLELIST VARIABLELIST CAUTION IMPORTANT NOTE TIP WARNING LITERALLAYOUT PROGRAMLISTING PROGRAMLISTINGCO SCREEN SCREENCO SCREENSHOT SYNOPSIS CMDSYNOPSIS FUNCSYNOPSIS CLASSSYNOPSIS FIELDSYNOPSIS CONSTRUCTORSYNOPSIS DESTRUCTORSYNOPSIS METHODSYNOPSIS FORMALPARA PARA SIMPARA ADDRESS BLOCKQUOTE GRAPHIC GRAPHICCO MEDIAOBJECT MEDIAOBJECTCO INFORMALEQUATION INFORMALEXAMPLE INFORMALFIGURE INFORMALTABLE EQUATION EXAMPLE FIGURE TABLE MSGSET PROCEDURE SIDEBAR QANDASET ANCHOR BRIDGEHEAD REMARK HIGHLIGHTS ABSTRACT AUTHORBLURB EPIGRAPH INDEXTERM SECTION SIMPLESECT REFENTRY SECT1) -->
<qandaset>
<!-- one of (QANDADIV QANDAENTRY) -->
<qandaentry>
<question>
<!-- one of (CALLOUTLIST GLOSSLIST ITEMIZEDLIST ORDEREDLIST
SEGMENTEDLIST SIMPLELIST VARIABLELIST CAUTION IMPORTANT NOTE
TIP WARNING LITERALLAYOUT PROGRAMLISTING PROGRAMLISTINGCO
SCREEN SCREENCO SCREENSHOT SYNOPSIS CMDSYNOPSIS FUNCSYNOPSIS
CLASSSYNOPSIS FIELDSYNOPSIS CONSTRUCTORSYNOPSIS
DESTRUCTORSYNOPSIS METHODSYNOPSIS FORMALPARA PARA SIMPARA
ADDRESS BLOCKQUOTE GRAPHIC GRAPHICCO MEDIAOBJECT
MEDIAOBJECTCO INFORMALEQUATION INFORMALEXAMPLE
INFORMALFIGURE INFORMALTABLE EQUATION EXAMPLE FIGURE TABLE
PROCEDURE ANCHOR BRIDGEHEAD REMARK HIGHLIGHTS INDEXTERM) -->
<para>Are there mailing lists for rsync?
</question>
<answer>
<para>Yes, and you can subscribe and unsubscribe through a
web interface at
<ulink
url="http://lists.samba.org/">http://lists.samba.org/</ulink>
</para>
<para>
If you are having trouble with the mailing list, please
send mail to the administrator
<email>rsync-admin@lists.samba.org</email>
not to the list itself.
</para>
<para>
The mailing list archives are searchable. Use
<ulink url="http://google.com/">Google</ulink> and prepend
the search with <userinput>site:lists.samba.org
rsync</userinput>, plus relevant keywords.
</para>
</answer>
</qandaentry>
<qandaentry>
<question>
<para>
Why is rsync so much bigger when I build it with
<command>gcc</command>?
</para>
</question>
<answer>
<para>
On gcc, rsync builds by default with debug symbols
included. If you strip both executables, they should end
up about the same size. (Use <command>make
install-strip</command>.)
</para>
</answer>
</qandaentry>
<qandaentry>
<question>
<para>Is rsync useful for a single large file like an ISO image?</para>
</question>
<answer>
<para>
Yes, but note the following:
<para>
Background: A common use of rsync is to update a file (or set of files) in one location from a more
correct or up-to-date copy in another location, taking advantage of portions of the files that are
identical to speed up the process. (Note that rsync will transfer a file in its entirety if no copy
exists at the destination.)
<para>
(This discussion is written in terms of updating a local copy of a file from a correct file in a
remote location, although rsync can work in either direction.)
<para>
The file to be updated (the local file) must be in a destination directory that has enough space for
two copies of the file. (In addition, keep an extra copy of the file to be updated in a different
location for safety -- see the discussion (below) about rsync's behavior when the rsync process is
interrupted before completion.)
<para>
The local file must have the same name as the remote file being sync'd to (I think?). If you are
trying to upgrade an iso from, for example, beta1 to beta2, rename the local file to the same name
as the beta2 file. *(This is a useful thing to do -- only the changed portions will be
transmitted.)*
<para>
The extra copy of the local file kept in a different location is because of rsync's behavior if
interrupted before completion:
<para>
* If you specify the --partial option and rsync is interrupted, rsync will save the partially
rsync'd file and throw away the original local copy. (The partially rsync'd file is correct but
truncated.) If rsync is restarted, it will not have a local copy of the file to check for duplicate
blocks beyond the section of the file that has already been rsync'd, thus the remainder of the rsync
process will be a "pure transfer" of the file rather than taking advantage of the rsync algorithm.
<para>
* If you don't specify the --partial option and rsync is interrupted, rsync will throw away the
partially rsync'd file, and, when rsync is restarted starts the rsync process over from the
beginning.
<para>
Which of these is most desirable depends on the degree of commonality between the local and remote
copies of the file *and how much progress was made before the interruption*.
<para>
The ideal approach after an interruption would be to create a new file by taking the original file
and deleting a portion equal in size to the portion already rsync'd and then appending *the
remaining* portion to the portion of the file that has already been rsync'd. (There has been some
discussion about creating an option to do this automatically.)
The --compare-dest option is useful when transferring multiple files, but is of no benefit in
transferring a single file. (AFAIK)
*Other potentially useful information can be found at:
-[3]http://twiki.org/cgi-bin/view/Wikilearn/RsyncingALargeFile
This answer, formatted with "real" bullets, can be found at:
-[4]http://twiki.org/cgi-bin/view/Wikilearn/RsyncingALargeFileFAQ*
</para>
</answer>
</qandaentry>
</qandaset>
</chapter>
<appendix>
<title>Other Resources</title>
<para><ulink url="http://www.ccp14.ac.uk/ccp14admin/rsync/"></ulink></para>
</appendix>
</book>
+349 -59
View File
@@ -42,8 +42,99 @@ extern int protocol_version;
extern int trust_sender_args;
extern int module_id;
extern char curr_dir[MAXPATHLEN];
extern unsigned int curr_dir_len;
/* Set while the daemon loads its own filter parameters; see parse_filter_file(). */
int daemon_config_filter_file = 0;
/* Where the rule text now being parsed came from, when that is a file's
* CONTENTS rather than an argument. A rule that fails to parse used to be
* echoed back verbatim, and the peer chooses which file gets merged (a
* per-directory merge rule travels over the protocol, so no argument of ours
* ever names it), which made the filter parser a read-any-line oracle: any
* line that is not valid filter syntax came straight back in the error.
* Report where the bad rule is, not what it says. */
static int rule_src_in_file = 0; /* parsing a file's contents right now */
static const char *rule_src_file = NULL; /* ...and its name is safe to show */
static int rule_src_line = 0;
/* Where a file whose own name we must NOT print was named, which is a location
* we CAN print: it keeps the diagnostic useful without echoing the pathname a
* merge rule supplied. */
static const char *rule_src_named_at = NULL;
/* True while the text we are handling came out of a file's contents: either we
* are parsing that file right now, or this is a deferred per-dir merge whose
* NAME came from one and which carries the provenance on the rule. */
#define TEXT_FROM_FILE(template) \
(rule_src_in_file \
|| ((template) && (template)->rflags & FILTRULE_FROM_FILE))
/* "FILE line N", or just "FILE" when the count is not a line count. */
static const char *rule_src_where(void)
{
static char buf[MAXPATHLEN + 32];
if (!rule_src_file) {
if (!rule_src_named_at)
return "a file read earlier"; /* origin not retained */
snprintf(buf, sizeof buf, "a file named at %s", rule_src_named_at);
return buf;
}
if (rule_src_line < 0)
return rule_src_file;
snprintf(buf, sizeof buf, "%s line %d", rule_src_file, rule_src_line);
return buf;
}
/* THE chokepoint. Every diagnostic string that is, or is built from, a filter
* rule's own text -- a pattern, a merge-file name, a path composed from one --
* must be passed through rule_text() on its way to rprintf(). When the rule
* came from an argument the text is returned unchanged, because it is the
* user's own and hiding it only makes typos harder to fix. When it came from
* a FILE's contents it is replaced by a description of where it came from,
* because the peer chooses which file gets merged and any line of it that
* reaches a message is a line the peer can read back.
*
* Doing it here rather than at each site is the point: a message added later
* cannot reintroduce the leak by forgetting to check, and there is one place
* to audit. `template' is the rule the text belongs to, or NULL when the only
* thing that matters is whether we are parsing a file right now.
*
* The returned buffer is rotated, so two calls in one rprintf() are safe. */
static const char *rule_text_len(const filter_rule *template,
const char *text, int len)
{
static char buf[2][BIGPATHBUFLEN];
static int which = 0;
char *b = buf[which];
which ^= 1;
if (!TEXT_FROM_FILE(template)) {
if (len < 0)
return text;
snprintf(b, sizeof buf[0], "%.*s", len, text);
return b;
}
snprintf(b, sizeof buf[0], "<rule from %s>", rule_src_where());
return b;
}
static const char *rule_text(const filter_rule *template, const char *text)
{
return rule_text_len(template, text, -1);
}
/* For the extra detail some messages add ABOUT the text -- a character of it,
* an offset into it. Dropped along with the text it describes. */
static const char *rule_detail(const filter_rule *template, const char *detail)
{
return TEXT_FROM_FILE(template) ? "" : detail;
}
static void filter_rule_err(const char *msg, const char *rulestr)
{
rprintf(FERROR, "%s: %s\n", msg, rule_text(NULL, rulestr));
exit_cleanup(RERR_SYNTAX);
}
extern unsigned int module_dirlen;
filter_rule_list filter_list = { .debug_type = "" };
@@ -61,7 +152,7 @@ int trust_sender_filter = 0;
#define SLASH_WILD3_SUFFIX "/***"
/* The dirbuf is set by push_local_filters() to the current subdirectory
* relative to curr_dir that is being processed. The path always has a
* relative to vfs.curr_dir that is being processed. The path always has a
* trailing slash appended, and the variable dirbuf_len contains the length
* of this path prefix. The path is always absolute. */
static char dirbuf[MAXPATHLEN+1];
@@ -71,6 +162,9 @@ static int dirbuf_depth;
/* This is True when we're scanning parent dirs for per-dir merge-files. */
static BOOL parent_dirscan = False;
#define MAX_MERGE_DEPTH 32
static int merge_depth = 0;
/* This array contains a list of all the currently active per-dir merge
* files. This makes it easier to save the appropriate values when we
* "push" down into each subdirectory. */
@@ -171,10 +265,10 @@ static void add_rule(filter_rule_list *listp, const char *pat, unsigned int pat_
else
mention_rule_suffix = DEBUG_GTE(FILTER, 2) ? "" : NULL;
if (mention_rule_suffix) {
rprintf(FINFO, "[%s] add_rule(%s%.*s%s)%s%s\n",
who_am_i(), get_rule_prefix(rule, pat, 0, NULL),
(int)pat_len, pat, (rule->rflags & FILTRULE_DIRECTORY) ? "/" : "",
listp->debug_type, mention_rule_suffix);
rprintf(FINFO, "[%s] add_rule(%s%s)%s%s\n",
who_am_i(), rule_detail(rule, get_rule_prefix(rule, pat, 0, NULL)),
rule_text_len(rule, pat, (int)pat_len),
listp->debug_type, rule_detail(rule, mention_rule_suffix));
}
/* These flags also indicate that we're reading a list that
@@ -279,7 +373,7 @@ static void add_rule(filter_rule_list *listp, const char *pat, unsigned int pat_
}
lp = new_array0(filter_rule_list, 1);
if (asprintf(&lp->debug_type, " [per-dir %s]", cp) < 0)
if (asprintf(&lp->debug_type, " [per-dir %s]", rule_text(rule, cp)) < 0)
out_of_memory("add_rule");
rule->u.mergelist = lp;
@@ -427,7 +521,7 @@ void add_implied_include(const char *arg, int skip_daemon_module)
if (cp[1] == ']') {
if (!saw_wild)
cp++; /* A \] in a non-wild filter causes a problem, so drop the \ . */
} else if (!strchr("*[?", cp[1])) {
} else if (!cp[1] || !strchr("*[?", cp[1])) {
backslash_cnt++;
if (saw_wild)
*p++ = '\\';
@@ -596,7 +690,8 @@ static void pop_filter_list(filter_rule_list *listp)
* value and will be updated with the length of the resulting name. We
* always return a name that is null terminated, even if the merge_file
* name was not. */
static char *parse_merge_name(const char *merge_file, unsigned int *len_ptr,
static char *parse_merge_name(const filter_rule *template,
const char *merge_file, unsigned int *len_ptr,
unsigned int prefix_skip)
{
static char buf[MAXPATHLEN];
@@ -627,7 +722,7 @@ static char *parse_merge_name(const char *merge_file, unsigned int *len_ptr,
}
if (!sanitize_path(fn, merge_file, r, dirbuf_depth, SP_DEFAULT)) {
rprintf(FERROR, "merge-file name overflows: %s\n",
merge_file);
rule_text(template, merge_file));
return NULL;
}
fn_len = strlen(fn);
@@ -640,7 +735,8 @@ static char *parse_merge_name(const char *merge_file, unsigned int *len_ptr,
if (fn != buf) {
int d_len = dirbuf_len - prefix_skip;
if (d_len + fn_len >= MAXPATHLEN) {
rprintf(FERROR, "merge-file name overflows: %s\n", fn);
rprintf(FERROR, "merge-file name overflows: %s\n",
rule_text(template, fn));
return NULL;
}
memcpy(buf, dirbuf + prefix_skip, d_len);
@@ -658,9 +754,9 @@ void set_filter_dir(const char *dir, unsigned int dirlen)
{
unsigned int len;
if (*dir != '/') {
memcpy(dirbuf, curr_dir, curr_dir_len);
dirbuf[curr_dir_len] = '/';
len = curr_dir_len + 1;
memcpy(dirbuf, vfs.curr_dir, vfs.curr_dir_len);
dirbuf[vfs.curr_dir_len] = '/';
len = vfs.curr_dir_len + 1;
if (len + dirlen >= MAXPATHLEN)
dirlen = 0;
} else
@@ -690,7 +786,7 @@ static BOOL setup_merge_file(int mergelist_num, filter_rule *ex,
char *x, *y, *pat = ex->pattern;
unsigned int len;
if (!(x = parse_merge_name(pat, NULL, 0)) || *x != '/')
if (!(x = parse_merge_name(ex, pat, NULL, 0)) || *x != '/')
return 0;
if (DEBUG_GTE(FILTER, 2)) {
@@ -754,7 +850,7 @@ struct local_filter_state {
/* Each time rsync changes to a new directory it call this function to
* handle all the per-dir merge-files. The "dir" value is the current path
* relative to curr_dir (which might not be null-terminated). We copy it
* relative to vfs.curr_dir (which might not be null-terminated). We copy it
* into dirbuf so that we can easily append a file name on the end. */
void *push_local_filters(const char *dir, unsigned int dirlen)
{
@@ -816,7 +912,7 @@ void *push_local_filters(const char *dir, unsigned int dirlen)
io_error |= IOERR_GENERAL;
rprintf(FERROR,
"cannot add local filter rules in long-named directory: %s\n",
full_fname(dirbuf));
rule_text(ex, full_fname(dirbuf)));
}
dirbuf[dirbuf_len] = '\0';
}
@@ -904,7 +1000,7 @@ static int rule_matches(const char *fname, filter_rule *ex, int name_flags)
{
int slash_handling, str_cnt = 0, anchored_match = 0;
int ret_match = ex->rflags & FILTRULE_NEGATE ? 0 : 1;
char *p, *pattern = ex->pattern;
const char *p, *pattern = ex->pattern;
const char *strings[16]; /* more than enough */
const char *name = fname + (*fname == '/');
@@ -921,10 +1017,10 @@ static int rule_matches(const char *fname, filter_rule *ex, int name_flags)
if ((p = strrchr(name,'/')) != NULL)
name = p+1;
} else if (ex->rflags & FILTRULE_ABS_PATH && *fname != '/'
&& curr_dir_len > module_dirlen + 1) {
&& vfs.curr_dir_len > module_dirlen + 1) {
/* If we're matching against an absolute-path pattern,
* we need to prepend our full path info. */
strings[str_cnt++] = curr_dir + module_dirlen + 1;
strings[str_cnt++] = vfs.curr_dir + module_dirlen + 1;
strings[str_cnt++] = "/";
} else if (ex->rflags & FILTRULE_WILD2_PREFIX && *fname != '/') {
/* Allow "**"+"/" to match at the start of the string. */
@@ -999,8 +1095,8 @@ static void report_filter_result(enum logcode code, char const *name,
: "file";
rprintf(code, "[%s] %sing %s %s because of pattern %s%s%s\n",
w, actions[*w=='g'][!(ent->rflags & FILTRULE_INCLUDE)],
t, name, ent->pattern,
ent->rflags & FILTRULE_DIRECTORY ? "/" : "", type);
t, name, rule_text(ent, ent->pattern),
rule_detail(ent, ent->rflags & FILTRULE_DIRECTORY ? "/" : ""), type);
}
}
@@ -1033,6 +1129,56 @@ int check_server_filter(filter_rule_list *listp, enum logcode code, const char *
return ret;
}
/* Returns 1 if `name` matches an implied-parent rule (a directory component
* seeded by add_implied_include() with FILTRULE_DIRECTORY) but not a leaf
* rule -- i.e. the client asked for something under the dir, never the dir
* itself as content.
*
* The receiver uses this to refuse a malicious sender that sets XMIT_TOP_DIR
* without XMIT_NO_CONTENT_DIR on such a dir: the honest encoding is both flags
* (flist.c send path), so otherwise the receiver would set FLAG_CONTENT_DIR
* and delete_in_dir() could sweep pre-existing siblings under --delete. */
int is_implied_parent_dir(const char *name)
{
filter_rule *ent;
int parent_match = 0;
if (!implied_filter_list.head)
return 0;
/* The receiver exempts its synthetic transfer-root entry from the
* requested-name filter. Treat it as parent-only unless an empty/root
* source argument added the root-content rule. */
if ((name[0] == '.' && name[1] == '\0')
|| (name[0] == '/' && name[1] == '.' && name[2] == '\0')) {
for (ent = implied_filter_list.head; ent; ent = ent->next) {
if (!(ent->rflags & FILTRULE_INCLUDE))
continue;
if (strcmp(ent->pattern, "/**") == 0
|| strcmp(ent->pattern, "/*") == 0)
return 0;
}
return 1;
}
for (ent = implied_filter_list.head; ent; ent = ent->next) {
if (ent->rflags & (FILTRULE_PERDIR_MERGE | FILTRULE_CVS_IGNORE))
continue;
if (!rule_matches(name, ent, NAME_IS_DIR))
continue;
if (!(ent->rflags & FILTRULE_INCLUDE))
continue;
if (ent->rflags & FILTRULE_DIRECTORY) {
parent_match = 1;
continue;
}
/* A non-DIRECTORY include rule = a leaf the client asked for, so
* the dir is legitimately in the list, not parent-only. */
return 0;
}
return parent_match;
}
/* Return -1 if file "name" is defined to be excluded by the specified
* exclude list, 1 if it is included, and 0 if it was not matched. */
int check_filter(filter_rule_list *listp, enum logcode code,
@@ -1112,6 +1258,8 @@ static filter_rule *parse_rule_tok(const char **rulestr_ptr,
/* Inherit from the template. Don't inherit FILTRULES_SIDES; we check
* that later. */
rule->rflags = template->rflags & FILTRULES_FROM_CONTAINER;
if (rule_src_in_file)
rule->rflags |= FILTRULE_FROM_FILE; /* before parse_merge_name() */
/* Figure out what kind of a filter rule "s" is pointing at. Note
* that if FILTRULE_NO_PREFIXES is set, the rule is either an include
@@ -1209,8 +1357,7 @@ static filter_rule *parse_rule_tok(const char **rulestr_ptr,
rule->rflags |= FILTRULE_CLEAR_LIST;
break;
default:
rprintf(FERROR, "Unknown filter rule: `%s'\n", *rulestr_ptr);
exit_cleanup(RERR_SYNTAX);
filter_rule_err("Unknown filter rule", *rulestr_ptr);
}
while (ch != '!' && *++s && *s != ' ' && *s != '_') {
if (template->rflags & FILTRULE_WORD_SPLIT && isspace(*s)) {
@@ -1219,11 +1366,15 @@ static filter_rule *parse_rule_tok(const char **rulestr_ptr,
}
switch (*s) {
default:
invalid:
rprintf(FERROR,
"invalid modifier '%c' at position %d in filter rule: %s\n",
*s, (int)(s - (const uchar *)*rulestr_ptr), *rulestr_ptr);
invalid: {
char where[32];
snprintf(where, sizeof where, " '%c' at position %d",
*s, (int)(s - (const uchar *)*rulestr_ptr));
rprintf(FERROR, "invalid modifier%s in filter rule: %s\n",
rule_detail(NULL, where),
rule_text(NULL, *rulestr_ptr));
exit_cleanup(RERR_SYNTAX);
}
case '-':
if (!BITS_SETnUNSET(rule->rflags, FILTRULE_MERGE_FILE, FILTRULE_NO_PREFIXES))
goto invalid;
@@ -1295,10 +1446,8 @@ static filter_rule *parse_rule_tok(const char **rulestr_ptr,
/* The filter and template both specify side(s). This
* is dodgy (and won't work correctly if the template is
* a one-sided per-dir merge rule), so reject it. */
rprintf(FERROR,
"specified-side merge file contains specified-side filter: %s\n",
*rulestr_ptr);
exit_cleanup(RERR_SYNTAX);
filter_rule_err("specified-side merge file contains specified-side filter",
*rulestr_ptr);
}
rule->rflags |= template->rflags & FILTRULES_SIDES;
}
@@ -1313,17 +1462,14 @@ static filter_rule *parse_rule_tok(const char **rulestr_ptr,
len = strlen((char*)s);
if (rule->rflags & FILTRULE_CLEAR_LIST) {
if (!(rule->rflags & FILTRULE_NO_PREFIXES)
if (!(template->rflags & FILTRULE_NO_PREFIXES)
&& !(xflags & XFLG_OLD_PREFIXES) && len) {
rprintf(FERROR,
"'!' rule has trailing characters: %s\n", *rulestr_ptr);
exit_cleanup(RERR_SYNTAX);
filter_rule_err("'!' rule has trailing characters", *rulestr_ptr);
}
if (len > 1)
rule->rflags &= ~FILTRULE_CLEAR_LIST;
} else if (!len && !(rule->rflags & FILTRULE_CVS_IGNORE)) {
rprintf(FERROR, "unexpected end of filter rule: %s\n", *rulestr_ptr);
exit_cleanup(RERR_SYNTAX);
filter_rule_err("unexpected end of filter rule", *rulestr_ptr);
}
/* --delete-excluded turns an un-modified include/exclude into a sender-side rule. */
@@ -1382,8 +1528,8 @@ void parse_filter_str(filter_rule_list *listp, const char *rulestr,
break;
if (pat_len >= MAXPATHLEN) {
rprintf(FERROR, "discarding over-long filter: %.*s\n",
(int)pat_len, pat);
rprintf(FERROR, "discarding over-long filter: %s\n",
rule_text_len(NULL, pat, (int)pat_len));
free_continue:
free_filter(rule);
continue;
@@ -1411,6 +1557,11 @@ void parse_filter_str(filter_rule_list *listp, const char *rulestr,
filter_rule *excl_self;
excl_self = new0(filter_rule);
/* The pattern below is the merge rule's own text, so it
* inherits that rule's provenance. Built by hand, this
* rule looked argument-origin once parsing finished and
* the match trace echoed a merge file's contents at -vv. */
excl_self->rflags = rule->rflags & FILTRULE_FROM_FILE;
/* Find the beginning of the basename and add an exclude for it. */
for (name = pat + pat_len; name > pat && name[-1] != '/'; name--) {}
add_rule(listp, name, (pat + pat_len) - name, excl_self, 0);
@@ -1420,7 +1571,7 @@ void parse_filter_str(filter_rule_list *listp, const char *rulestr,
if (parent_dirscan) {
const char *p;
unsigned int len = pat_len;
if ((p = parse_merge_name(pat, &len, module_dirlen)))
if ((p = parse_merge_name(rule, pat, &len, module_dirlen)))
add_rule(listp, p, len, rule, 0);
else
free_filter(rule);
@@ -1429,7 +1580,7 @@ void parse_filter_str(filter_rule_list *listp, const char *rulestr,
} else {
const char *p;
unsigned int len = pat_len;
if ((p = parse_merge_name(pat, &len, 0)))
if ((p = parse_merge_name(rule, pat, &len, 0)))
parse_filter_file(listp, p, rule, XFLG_FATAL_ERRORS);
free_filter(rule);
continue;
@@ -1450,46 +1601,159 @@ void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_
char line[BIGPATHBUFLEN];
char *eob = line + sizeof line - 1;
BOOL word_split = (template->rflags & FILTRULE_WORD_SPLIT) != 0;
const char *save_src_file, *save_src_named_at;
int save_src_line, save_src_in_file;
int named_by_file;
int pending = EOF;
char named_at[MAXPATHLEN + 32];
/* Our own copy: fname may point into parse_merge_name()'s static buffer,
* which a merge rule inside THIS file overwrites while we still need it. */
char src_name[MAXPATHLEN];
if (!fname || !*fname)
return;
if (merge_depth >= MAX_MERGE_DEPTH) {
rprintf(FERROR,
"[%s] merge-file include depth limit (%d) exceeded at %s\n",
who_am_i(), MAX_MERGE_DEPTH, rule_text(template, fname));
/* Match the failed-open path below: abort under a fatal
* (operator-supplied) merge, otherwise drop the rule. */
if (xflags & XFLG_FATAL_ERRORS)
exit_cleanup(RERR_FILEIO);
return;
}
merge_depth++;
if (*fname != '-' || fname[1] || am_server) {
/* This path is operator- and (via per-directory merge files like
* .cvsignore) sender-controlled: a planted symlink could leak a
* root-readable file through the filter parser, or redirect an
* --exclude-from open via a planted parent. Refuse symlinks not
* owned by uid 0 or our euid. */
const char *open_path;
int fd;
if (daemon_filter_list.head) {
char *dir;
strlcpy(line, fname, sizeof line);
clean_fname(line, CFN_COLLAPSE_DOT_DOT_DIRS);
if (check_filter(&daemon_filter_list, FLOG, line, 0) < 0)
fp = NULL;
else
fp = fopen(line, "rb");
/* parse_merge_name() prepends module_dir for absolute paths,
* so strip module_dirlen back off before the check or the
* anchored module-relative daemon rule won't match (as
* options.c does for --exclude-from/--include-from). The
* original absolute path is still used for the open below. */
dir = line + (*line == '/' ? module_dirlen : 0);
clean_fname(dir, CFN_COLLAPSE_DOT_DOT_DIRS);
if (check_filter(&daemon_filter_list, FLOG, dir, 0) < 0) {
/* Hidden by the daemon filter: treat the merge file as
* non-existent rather than tripping XFLG_FATAL_ERRORS
* below, so it neither errors out nor leaks a
* fatal-vs-silent oracle. */
if (DEBUG_GTE(FILTER, 2)) {
/* Same rule as everywhere else: the name is
* file content when a rule we read named it,
* and so is "the daemon filter hides it". */
rprintf(FINFO, "[%s] parse_filter_file(%s)%s\n",
who_am_i(), rule_text(template, fname),
rule_detail(template, " hidden by daemon filter"));
}
merge_depth--;
return;
}
open_path = line;
} else
fp = fopen(fname, "rb");
open_path = fname;
/* Confine the open to the module root. The ownership walk on its own
* is not enough for a peer-driven merge file: a non-chrooted daemon
* writes --backup-dir entries as root, so a raced backup symlink is
* ROOT-owned -- exactly what the ownership walk treats as trusted --
* and naming it in a dir-merge rule would read an out-of-module file
* in as filter rules (their text comes back to the peer in "Unknown
* filter rule" errors).
*
* The daemon's own "filter"/"include from"/"exclude from" parameters
* are exempt: those are operator-configured and legitimately live
* outside the module (/etc/rsync/excludes and the like). */
fd = vfs_open_owner_walk(open_path, O_RDONLY, 0, !daemon_config_filter_file);
if (fd < 0)
fp = NULL;
else if (!(fp = fdopen(fd, "rb")))
close(fd);
} else
fp = stdin;
if (DEBUG_GTE(FILTER, 2)) {
/* The name is file CONTENT when a rule we read named it, and a
* word-split per-dir merge turns every word of a file into one
* of these -- so the trace would echo what the syntax errors no
* longer do. Say where it came from instead. */
rprintf(FINFO, "[%s] parse_filter_file(%s,%x,%x)%s\n",
who_am_i(), fname, template->rflags, xflags,
fp ? "" : " [not found]");
who_am_i(), rule_text(template, fname), template->rflags, xflags,
rule_detail(template, fp ? "" : " [not found]"));
}
if (!fp) {
if (xflags & XFLG_FATAL_ERRORS) {
rsyserr(FERROR, errno,
"failed to open %sclude file %s",
template->rflags & FILTRULE_INCLUDE ? "in" : "ex",
fname);
/* rule_src_file is still the PARENT's context here: when it
* is set, this name came out of a file we read, so neither
* the name nor errno (an existence oracle) may be shown. */
if (TEXT_FROM_FILE(template)) {
/* errno too: it answers "does this path exist". */
rprintf(FERROR, "failed to open %sclude file %s\n",
template->rflags & FILTRULE_INCLUDE ? "in" : "ex",
rule_text(template, fname));
} else {
rsyserr(FERROR, errno,
"failed to open %sclude file %s",
template->rflags & FILTRULE_INCLUDE ? "in" : "ex",
fname);
}
exit_cleanup(RERR_FILEIO);
}
merge_depth--;
return;
}
/* Before dirbuf is cut back: a per-directory fname points INTO dirbuf,
* so truncating first leaves only the directory and the location we
* report loses the filename. */
strlcpy(src_name, fname, sizeof src_name);
dirbuf[dirbuf_len] = '\0';
/* Rule text from here on is this file's contents, not an argument, so
* a syntax error must not echo it. Saved and restored because a merge
* rule inside this file can bring us back in for another file. */
save_src_in_file = rule_src_in_file;
save_src_file = rule_src_file;
save_src_line = rule_src_line;
/* If a rule we read named THIS file, our own path is file content too:
* track the location for provenance but do not put it in a message. */
named_by_file = TEXT_FROM_FILE(template);
save_src_named_at = rule_src_named_at;
if (named_by_file) {
/* Snapshot where we were told to merge this, before that state
* is replaced below (rule_src_where returns a static buffer).
* A DEFERRED merge has no live location to point at -- the file
* that named it was read and finished long ago -- so leave the
* generic description rather than nesting two vague ones. */
if (rule_src_in_file) {
strlcpy(named_at, rule_src_where(), sizeof named_at);
rule_src_named_at = named_at;
} else
rule_src_named_at = NULL;
}
rule_src_in_file = 1;
rule_src_file = named_by_file ? NULL : src_name;
rule_src_line = word_split ? -1 : 0; /* -1: tokens, not lines */
while (1) {
char *s = line;
int ch, overflow = 0;
if (rule_src_line >= 0)
rule_src_line++;
while (1) {
if ((ch = getc(fp)) == EOF) {
if (pending != EOF) { /* a CR lookahead we could not push back */
ch = pending;
pending = EOF;
} else if ((ch = getc(fp)) == EOF) {
if (ferror(fp) && errno == EINTR) {
clearerr(fp);
continue;
@@ -1498,25 +1762,51 @@ void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_
}
if (word_split && isspace(ch))
break;
if (eol_nulls? !ch : (ch == '\n' || ch == '\r'))
if (eol_nulls? !ch : (ch == '\n' || ch == '\r')) {
if (ch == '\r') { /* CRLF is one line, not two */
int nxt;
while ((nxt = getc(fp)) == EOF
&& ferror(fp) && errno == EINTR)
clearerr(fp);
if (nxt == EOF) {
if (!ferror(fp))
ch = EOF; /* real end of file */
} else if (nxt != '\n' && ungetc(nxt, fp) == EOF) {
/* Pushback failed: hand it to the
* NEXT rule, where it belongs --
* appending it here would both
* corrupt this rule and skip the
* s < eob bound below. */
pending = nxt;
}
}
break;
}
if (s < eob)
*s++ = ch;
else
overflow = 1;
}
if (overflow) {
rprintf(FERROR, "discarding over-long filter: %s...\n", line);
rprintf(FERROR, "discarding over-long filter: %s\n",
rule_text_len(NULL, line, 0));
s = line;
}
*s = '\0';
/* Skip an empty token and (when line parsing) comments. */
if (*line && (word_split || (*line != ';' && *line != '#')))
if (*line && (word_split || (*line != ';' && *line != '#'))) {
rule_src_file = named_by_file ? NULL : src_name;
parse_filter_str(listp, line, template, xflags);
}
if (ch == EOF)
break;
}
rule_src_in_file = save_src_in_file;
rule_src_file = save_src_file;
rule_src_line = save_src_line;
rule_src_named_at = save_src_named_at;
fclose(fp);
merge_depth--;
}
/* If the "for_xfer" flag is set, the prefix is made compatible with the
+96 -36
View File
@@ -45,17 +45,17 @@ int sparse_end(int f, OFF_T size, int updating_basis_or_equiv)
int ret = 0;
if (updating_basis_or_equiv) {
if (sparse_seek && do_punch_hole(f, sparse_past_write, sparse_seek) < 0)
if (sparse_seek && vfs_punch_hole(f, sparse_past_write, sparse_seek) < 0)
ret = -1;
#ifdef HAVE_FTRUNCATE /* A compilation formality -- in-place requires ftruncate() */
else /* Just in case the original file was longer */
ret = do_ftruncate(f, size);
ret = vfs_ftruncate(f, size);
#endif
} else if (sparse_seek) {
#ifdef HAVE_FTRUNCATE
ret = do_ftruncate(f, size);
ret = vfs_ftruncate(f, size);
#else
if (do_lseek(f, sparse_seek-1, SEEK_CUR) != size-1)
if (vfs_lseek(f, sparse_seek-1, SEEK_CUR) != size-1)
ret = -1;
else {
do {
@@ -75,11 +75,60 @@ int sparse_end(int f, OFF_T size, int updating_basis_or_equiv)
/* Note that the offset is just the caller letting us know where
* the current file position is in the file. The use_seek arg tells
* us that we should seek over matching data instead of writing it. */
/* Flush any deferred run of zero bytes as a hole, advancing the file
* position past it (both vfs_lseek() and vfs_punch_hole() move the offset). */
static int flush_sparse_hole(int f)
{
if (!sparse_seek)
return 0;
if (sparse_past_write >= preallocated_len) {
if (vfs_lseek(f, sparse_seek, SEEK_CUR) < 0) {
sparse_seek = 0;
return -1;
}
} else if (vfs_punch_hole(f, sparse_past_write, sparse_seek) < 0) {
sparse_seek = 0;
return -1;
}
sparse_seek = 0;
return 0;
}
static int full_sparse_write(int f, const char *buf, int len)
{
while (len > 0) {
int ret = write(f, buf, len);
if (ret <= 0) {
if (ret < 0 && errno == EINTR)
continue;
sparse_seek = 0;
return -1;
}
buf += ret;
len -= ret;
}
return 0;
}
/* Emit one span of data that is not being turned into a hole. For an in-place
* update (use_seek) the bytes on disk already match, so we only need to move
* past them; otherwise we write them out. Either way a deferred hole is
* flushed first so that the span lands at the right offset. */
static int emit_sparse_span(int f, int use_seek, const char *buf, int len)
{
if (flush_sparse_hole(f) < 0)
return -1;
if (use_seek)
return vfs_lseek(f, len, SEEK_CUR) < 0 ? -1 : 0;
return full_sparse_write(f, buf, len);
}
static int write_sparse(int f, int use_seek, OFF_T offset, const char *buf, int len)
{
int l1 = 0, l2 = 0;
int ret;
int l1, l2, i, start, end;
/* Always treat a leading and trailing run of zeros as a (deferred)
* hole, since they may merge with holes in the adjacent write calls. */
for (l1 = 0; l1 < len && buf[l1] == 0; l1++) {}
for (l2 = 0; l2 < len-l1 && buf[len-(l2+1)] == 0; l2++) {}
@@ -88,37 +137,46 @@ static int write_sparse(int f, int use_seek, OFF_T offset, const char *buf, int
if (l1 == len)
return len;
if (sparse_seek) {
if (sparse_past_write >= preallocated_len) {
if (do_lseek(f, sparse_seek, SEEK_CUR) < 0)
return -1;
} else if (do_punch_hole(f, sparse_past_write, sparse_seek) < 0) {
sparse_seek = 0;
return -1;
/* Scan the middle [l1, len-l2) for interior runs of zeros that are at
* least SPARSE_WRITE_SIZE long (the hole granularity rsync has always
* used) and defer those as holes. Everything in between -- which may
* include shorter zero runs not worth a hole -- is emitted in one go,
* rather than being chopped into SPARSE_WRITE_SIZE-byte pieces, which
* made copying a large non-sparse file cost ~one write() per KiB.
*
* The matched (use_seek) case runs through the same scan: its interior
* zero runs still have to be punched out, which is what --inplace
* --sparse relies on to keep a hole-y basis file sparse. */
start = l1;
end = len - l2;
for (i = l1; i < end; ) {
int z;
if (buf[i] != 0) {
i++;
continue;
}
for (z = 1; i + z < end && buf[i+z] == 0; z++) {}
if (z < SPARSE_WRITE_SIZE) {
i += z;
continue;
}
if (i > start) {
if (emit_sparse_span(f, use_seek, buf + start, i - start) < 0)
return -1;
sparse_past_write = offset + i;
}
sparse_seek += z;
i += z;
start = i;
}
if (end > start) {
if (emit_sparse_span(f, use_seek, buf + start, end - start) < 0)
return -1;
}
sparse_seek = l2;
sparse_past_write = offset + len - l2;
if (use_seek) {
/* The in-place data already matches. */
if (do_lseek(f, len - (l1+l2), SEEK_CUR) < 0)
return -1;
return len;
}
while ((ret = write(f, buf + l1, len - (l1+l2))) <= 0) {
if (ret < 0 && errno == EINTR)
continue;
sparse_seek = 0;
return ret;
}
if (ret != (int)(len - (l1+l2))) {
sparse_seek = 0;
return l1+ret;
}
return len;
}
@@ -153,8 +211,10 @@ int write_file(int f, int use_seek, OFF_T offset, const char *buf, int len)
while (len > 0) {
int r1;
if (sparse_files > 0) {
int len1 = MIN(len, SPARSE_WRITE_SIZE);
r1 = write_sparse(f, use_seek, offset, buf, len1);
/* write_sparse() handles the whole span itself, scanning
* for holes and coalescing the non-zero data into large
* write()s instead of SPARSE_WRITE_SIZE-byte dribbles. */
r1 = write_sparse(f, use_seek, offset, buf, len);
offset += r1;
} else {
if (!wf_writeBuf) {
@@ -202,7 +262,7 @@ int skip_matched(int fd, OFF_T offset, const char *buf, int len)
if (flush_write_file(fd) < 0)
return -1;
if ((pos = do_lseek(fd, len, SEEK_CUR)) != offset + len) {
if ((pos = vfs_lseek(fd, len, SEEK_CUR)) != offset + len) {
rsyserr(FERROR_XFER, errno, "lseek returned %s, not %s",
big_num(pos), big_num(offset));
return -1;
@@ -285,7 +345,7 @@ char *map_ptr(struct map_struct *map, OFF_T offset, int32 len)
}
if (map->p_fd_offset != read_start) {
OFF_T ret = do_lseek(map->fd, read_start, SEEK_SET);
OFF_T ret = vfs_lseek(map->fd, read_start, SEEK_SET);
if (ret != read_start) {
rsyserr(FERROR, errno, "lseek returned %s, not %s",
big_num(ret), big_num(read_start));
+386 -51
View File
@@ -29,6 +29,10 @@
extern int am_root;
extern int am_server;
extern int am_daemon;
extern int am_chrooted;
extern char *module_dir;
extern unsigned int module_dirlen;
extern int module_dirfd;
extern int am_sender;
extern int am_generator;
extern int inc_recurse;
@@ -64,6 +68,7 @@ extern int non_perishable_cnt;
extern int prune_empty_dirs;
extern int copy_links;
extern int copy_unsafe_links;
extern int insecure_links;
extern int protocol_version;
extern int sanitize_paths;
extern int munge_symlinks;
@@ -81,7 +86,6 @@ extern char *usermap, *groupmap;
extern struct name_num_item *file_sum_nni;
extern char curr_dir[MAXPATHLEN];
extern struct chmod_mode_struct *chmod_modes;
@@ -132,6 +136,18 @@ static int64 tmp_dev = -1, tmp_ino;
#endif
static char tmp_sum[MAX_DIGEST_LEN];
#ifdef ST_MTIME_NSEC
/* Return st_mtim nsec if it is in the wire-valid range, else 0. */
static inline uint32 wire_mtime_nsec_from_stat(const STRUCT_STAT *stp)
{
unsigned long nsec = (unsigned long)stp->ST_MTIME_NSEC;
if (nsec > MAX_WIRE_NSEC)
return 0;
return (uint32)nsec;
}
#endif
static char empty_sum[MAX_DIGEST_LEN];
static int flist_count_offset; /* for --delete --progress */
static int show_filelist_progress;
@@ -202,13 +218,47 @@ void show_flist_stats(void)
*
* The stat structure pointed to by stp will contain information about the
* link or the referent as appropriate, if they exist. */
/* Set by send_directory() to the fd of the directory it is currently scanning
* (and that dir's path prefix), so the per-entry stat can go through the
* already-open dir fd instead of re-resolving the full path for every entry.
* Pure performance and sender-side only -- the scanned dir is already open, so
* fstatat(scan_dirfd, basename) is identical to lstat(scandir/basename); no
* confinement is implied or needed. */
static int scan_dirfd = -1;
static const char *scan_dir_prefix;
static int scan_dir_prefix_len;
static int scan_link_stat(const char *path, STRUCT_STAT *stp, int follow_dirlinks)
{
/* Use the held scan fd only for a single component directly inside the
* scanned dir, and only when am_root >= 0 (link_stat_at folds in no
* fake-super %stat xattr; link_stat does so via get_stat_xattr, a no-op
* once am_root >= 0). */
if (scan_dirfd >= 0 && am_root >= 0
&& strncmp(path, scan_dir_prefix, scan_dir_prefix_len) == 0
&& path[scan_dir_prefix_len] == '/'
&& strchr(path + scan_dir_prefix_len + 1, '/') == NULL)
return link_stat_at(scan_dirfd, path + scan_dir_prefix_len + 1, stp, follow_dirlinks);
return link_stat(path, stp, follow_dirlinks);
}
static int scan_readlink(const char *path, char *linkbuf, size_t bufsiz)
{
if (scan_dirfd >= 0 && am_root >= 0
&& strncmp(path, scan_dir_prefix, scan_dir_prefix_len) == 0
&& path[scan_dir_prefix_len] == '/'
&& strchr(path + scan_dir_prefix_len + 1, '/') == NULL)
return vfs_readlink_atfd(scan_dirfd, path + scan_dir_prefix_len + 1, linkbuf, bufsiz);
return vfs_readlink(path, linkbuf, bufsiz);
}
static int readlink_stat(const char *path, STRUCT_STAT *stp, char *linkbuf)
{
#ifdef SUPPORT_LINKS
if (link_stat(path, stp, copy_dirlinks) < 0)
if (scan_link_stat(path, stp, copy_dirlinks) < 0)
return -1;
if (S_ISLNK(stp->st_mode)) {
int llen = do_readlink(path, linkbuf, MAXPATHLEN - 1);
int llen = scan_readlink(path, linkbuf, MAXPATHLEN - 1);
if (llen < 0)
return -1;
linkbuf[llen] = '\0';
@@ -217,7 +267,7 @@ static int readlink_stat(const char *path, STRUCT_STAT *stp, char *linkbuf)
rprintf(FINFO,"copying unsafe symlink \"%s\" -> \"%s\"\n",
path, linkbuf);
}
return x_stat(path, stp, NULL);
return x_stat(path, stp, NULL, 0);
}
if (munge_symlinks && am_sender && llen > SYMLINK_PREFIX_LEN
&& strncmp(linkbuf, SYMLINK_PREFIX, SYMLINK_PREFIX_LEN) == 0) {
@@ -227,7 +277,7 @@ static int readlink_stat(const char *path, STRUCT_STAT *stp, char *linkbuf)
}
return 0;
#else
return x_stat(path, stp, NULL);
return x_stat(path, stp, NULL, 0);
#endif
}
@@ -235,17 +285,41 @@ int link_stat(const char *path, STRUCT_STAT *stp, int follow_dirlinks)
{
#ifdef SUPPORT_LINKS
if (copy_links)
return x_stat(path, stp, NULL);
if (x_lstat(path, stp, NULL) < 0)
return x_stat(path, stp, NULL, 0);
if (x_lstat(path, stp, NULL, 0) < 0)
return -1;
if (follow_dirlinks && S_ISLNK(stp->st_mode)) {
STRUCT_STAT st;
if (x_stat(path, &st, NULL) == 0 && S_ISDIR(st.st_mode))
if (x_stat(path, &st, NULL, 0) == 0 && S_ISDIR(st.st_mode))
*stp = st;
}
return 0;
#else
return x_stat(path, stp, NULL);
return x_stat(path, stp, NULL, 0);
#endif
}
/* Held-dirfd variant of link_stat(): stat single-component `name` relative to
* directory fd `dfd`, instead of re-resolving a full path. Equivalent to
* link_stat() only when NOT in --fake-super mode -- x_stat/x_lstat fold the
* fake-super %stat xattr into the result via get_stat_xattr(), which is a
* path-based no-op once am_root >= 0. Callers therefore use this only when
* am_root >= 0 (and a valid dfd), falling back to link_stat() otherwise. */
int link_stat_at(int dfd, const char *name, STRUCT_STAT *stp, int follow_dirlinks)
{
#ifdef SUPPORT_LINKS
if (copy_links)
return vfs_stat(dfd, name, stp, 0);
if (vfs_lstat(dfd, name, stp, 0) < 0)
return -1;
if (follow_dirlinks && S_ISLNK(stp->st_mode)) {
STRUCT_STAT st;
if (vfs_stat(dfd, name, &st, 0) == 0 && S_ISDIR(st.st_mode))
*stp = st;
}
return 0;
#else
return vfs_stat(dfd, name, stp, 0);
#endif
}
@@ -291,17 +365,31 @@ static void flist_expand(struct file_list *flist, int extra)
{
struct file_struct **new_ptr;
/* Refuse BEFORE any int arithmetic below can overflow: used+extra (computed
* in the early-return and the cap below) and the malloced growth math. Only
* reachable past INT_MAX entries (my_alloc's --max-alloc cap normally stops
* the list growing anywhere near there). */
if (extra < 0 || flist->used < 0 || flist->used > INT_MAX - extra)
goto too_large;
if (flist->used + extra <= flist->malloced)
return;
if (flist->malloced < FLIST_START)
flist->malloced = FLIST_START;
else if (flist->malloced >= FLIST_LINEAR)
else if (flist->malloced >= FLIST_LINEAR) {
if (flist->malloced > INT_MAX - FLIST_LINEAR)
goto too_large;
flist->malloced += FLIST_LINEAR;
else if (flist->malloced < FLIST_START_LARGE/16)
} else if (flist->malloced < FLIST_START_LARGE/16) {
if (flist->malloced > INT_MAX/4)
goto too_large;
flist->malloced *= 4;
else
} else {
if (flist->malloced > INT_MAX/2)
goto too_large;
flist->malloced *= 2;
}
/* In case count jumped or we are starting the list
* with a known size just set it. */
@@ -318,6 +406,11 @@ static void flist_expand(struct file_list *flist, int extra)
}
flist->files = new_ptr;
return;
too_large:
rprintf(FERROR, "[%s] file list has grown too large to expand\n", who_am_i());
exit_cleanup(RERR_MALLOC);
}
static void flist_done_allocating(struct file_list *flist)
@@ -764,7 +857,7 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
if ((basename = strrchr(thisname, '/')) != NULL) {
int len = basename++ - thisname;
if (len != lastdir_len || memcmp(thisname, lastdir, len) != 0) {
if (len != lastdir_len || !lastdir || memcmp(thisname, lastdir, len) != 0) {
lastdir = new_array(char, len + 1);
memcpy(lastdir, thisname, len);
lastdir[len] = '\0';
@@ -813,9 +906,17 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
rdev_major = DEV_MAJOR(devp);
rdev = MAKEDEV(rdev_major, DEV_MINOR(devp));
extra_len += DEV_EXTRA_CNT * EXTRA_LEN;
} else if (IS_DEVICE(mode)) {
/* Abbrev-branch counterpart to the !preserve_devices
* stub-alloc below: zeroed F_RDEV_P slots. */
extra_len += DEV_EXTRA_CNT * EXTRA_LEN;
}
if (preserve_links && S_ISLNK(mode))
linkname_len = strlen(F_SYMLINK(first)) + 1;
else if (S_ISLNK(mode))
/* Abbrev-branch counterpart to the !preserve_links
* stub-alloc below: empty linkname. */
linkname_len = 1;
else
linkname_len = 0;
real_ISREG_entry = S_ISREG(mode) ? 1 : 0;
@@ -840,9 +941,9 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
}
if (xflags & XMIT_MOD_NSEC)
#ifndef CAN_SET_NSEC
(void)read_varint(f);
(void)read_varint_bounded(f, 0, MAX_WIRE_NSEC, "modtime_nsec");
#else
modtime_nsec = read_varint(f);
modtime_nsec = read_varint_bounded(f, 0, MAX_WIRE_NSEC, "modtime_nsec");
else
modtime_nsec = 0;
#endif
@@ -861,8 +962,24 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
#endif
}
#endif
if (!(xflags & XMIT_SAME_MODE))
if (!(xflags & XMIT_SAME_MODE)) {
mode = from_wire_mode(read_int(f));
/* Reject modes whose type bits are not one of the standard
* file types; otherwise garbage mode values propagate through
* the file-type checks below unpredictably. mode 0 is the one
* legitimate exception: --delete-missing-args (missing_args==2)
* sends a missing arg as a mode-0 entry (IS_MISSING_FILE), the
* generator's delete signal (#910). */
if (mode != 0 || missing_args != 2) {
if (!S_ISREG(mode) && !S_ISDIR(mode) && !S_ISLNK(mode)
&& !S_ISCHR(mode) && !S_ISBLK(mode)
&& !S_ISFIFO(mode) && !S_ISSOCK(mode)) {
rprintf(FERROR, "invalid file mode 0%o for %s [%s]\n",
(unsigned)mode, lastname, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
}
}
if (atimes_ndx && !S_ISDIR(mode) && !(xflags & XMIT_SAME_ATIME)) {
atime = read_varlong(f, 4);
#if SIZEOF_TIME_T < SIZEOF_INT64
@@ -921,6 +1038,15 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
if (IS_DEVICE(mode))
extra_len += DEV_EXTRA_CNT * EXTRA_LEN;
file_length = 0;
} else if (IS_DEVICE(mode)) {
/* Peer/batch sent an S_IFCHR/S_IFBLK entry but we are not
* preserving devices. A cooperating sender wouldn't do this;
* a crafted batch can. Allocate (and zero, via the memset
* below) the DEV_EXTRA_CNT slots so F_RDEV_P() callers
* (set_stat_xattr under --fake-super, generator IS_DEVICE
* paths) read {0,0} instead of the previous pool slot. */
extra_len += DEV_EXTRA_CNT * EXTRA_LEN;
file_length = 0;
} else if (protocol_version < 28)
rdev = MAKEDEV(0, 0);
@@ -941,6 +1067,14 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
#endif
if (munge_symlinks)
linkname_len += SYMLINK_PREFIX_LEN;
} else if (S_ISLNK(mode)) {
/* Peer/batch sent an S_IFLNK entry but we are not preserving
* links (no -l, and the batch stream-flags didn't set it). A
* cooperating sender wouldn't do this; a crafted batch can.
* Allocate one byte for an empty linkname so F_SYMLINK()
* callers (log.c %L, generator.c) read a valid "" instead of
* the next pool slot's redzone. */
linkname_len = 1;
}
else
#endif
@@ -988,6 +1122,15 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
exit_cleanup(RERR_UNSUPPORTED);
}
/* "." is the synthetic transfer root. Reinterpreting it as a file lets
* --force recursively remove the real destination directory before the
* receiver creates that file. */
if ((!strcmp(thisname, ".") || !strcmp(thisname, "/.")) && !S_ISDIR(mode)) {
rprintf(FERROR, "ERROR: rejecting non-directory transfer-root entry: %s\n",
thisname);
exit_cleanup(RERR_PROTOCOL);
}
if (*thisname == '/' ? thisname[1] != '.' || thisname[2] != '\0' : *thisname != '.' || thisname[1] != '\0') {
int filt_flags = S_ISDIR(mode) ? NAME_IS_DIR : NAME_IS_FILE;
if (!trust_sender_filter /* a per-dir filter rule means we must trust the sender's filtering */
@@ -1027,7 +1170,8 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
memcpy(bp, basename, basename_len);
#ifdef SUPPORT_HARD_LINKS
if (xflags & XMIT_HLINKED
if (preserve_hard_links && xflags & XMIT_HLINKED
&& !S_ISDIR(mode)
#ifndef CAN_HARDLINK_SYMLINK
&& !S_ISLNK(mode)
#endif
@@ -1082,6 +1226,26 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
if (basename_len == 1+1 && *basename == '.') /* +1 for '\0' */
F_DEPTH(file)--;
if (protocol_version >= 30) {
/* Stop a malicious sender expanding --delete scope by flagging
* an implied parent as a content dir: if we only allowed this
* entry as a parent of the requested leaf, force the flags back
* to the honest implied-parent encoding (XMIT_TOP_DIR |
* XMIT_NO_CONTENT_DIR) so it lands in FLAG_IMPLIED_DIR, not
* FLAG_CONTENT_DIR, and delete_in_dir() can't sweep siblings.
* Not gated on trust_sender_filter: implied_filter_list is
* receiver-owned state, so a per-dir filter must not be able to
* downgrade this defense. */
if (implied_filter_list.head
&& is_implied_parent_dir(thisname)
&& (!(xflags & XMIT_NO_CONTENT_DIR) || !(xflags & XMIT_TOP_DIR))) {
if (DEBUG_GTE(FILTER, 1)) {
rprintf(FINFO,
"[%s] receiver downgraded implied-parent dir %s "
"to non-content (sender xflags=0x%x)\n",
who_am_i(), thisname, xflags);
}
xflags |= XMIT_NO_CONTENT_DIR | XMIT_TOP_DIR;
}
if (!(xflags & XMIT_NO_CONTENT_DIR)) {
if (xflags & XMIT_TOP_DIR)
file->flags |= FLAG_TOP_DIR;
@@ -1089,13 +1253,17 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
} else if (xflags & XMIT_TOP_DIR)
file->flags |= FLAG_IMPLIED_DIR;
} else if (xflags & XMIT_TOP_DIR) {
in_del_hier = recurse;
del_hier_name_len = F_DEPTH(file) == 0 ? 0 : l1 + l2;
if (relative_paths && del_hier_name_len > 2
&& lastname[del_hier_name_len-1] == '.'
&& lastname[del_hier_name_len-2] == '/')
del_hier_name_len -= 2;
file->flags |= FLAG_TOP_DIR | FLAG_CONTENT_DIR;
if (implied_filter_list.head && is_implied_parent_dir(thisname))
file->flags |= FLAG_IMPLIED_DIR;
else {
in_del_hier = recurse;
del_hier_name_len = F_DEPTH(file) == 0 ? 0 : l1 + l2;
if (relative_paths && del_hier_name_len > 2
&& lastname[del_hier_name_len-1] == '.'
&& lastname[del_hier_name_len-2] == '/')
del_hier_name_len -= 2;
file->flags |= FLAG_TOP_DIR | FLAG_CONTENT_DIR;
}
} else if (in_del_hier) {
if (!relative_paths || !del_hier_name_len
|| (l1 >= del_hier_name_len
@@ -1115,7 +1283,11 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
#ifdef SUPPORT_LINKS
if (linkname_len) {
bp += basename_len;
if (first_hlink_ndx >= flist->ndx_start) {
if (!preserve_links) {
/* The empty-linkname case allocated above; nothing on
* the wire to read. Just terminate it. */
*bp = '\0';
} else if (first_hlink_ndx >= flist->ndx_start) {
struct file_struct *first = flist->files[first_hlink_ndx - flist->ndx_start];
memcpy(bp, F_SYMLINK(first), linkname_len);
} else {
@@ -1239,7 +1411,7 @@ struct file_struct *make_file(const char *fname, struct file_list *flist,
int extra_len = file_extra_cnt * EXTRA_LEN;
const char *basename;
alloc_pool_t *pool;
STRUCT_STAT st;
STRUCT_STAT st = {0};
char *bp;
if (strlcpy(thisname, fname, sizeof thisname) >= sizeof thisname) {
@@ -1275,7 +1447,7 @@ struct file_struct *make_file(const char *fname, struct file_list *flist,
* options was specified, so there's no need for the
* extra lstat() if one of these options isn't on. */
if ((copy_links || copy_unsafe_links || copy_dirlinks)
&& x_lstat(thisname, &st, NULL) == 0
&& x_lstat(thisname, &st, NULL, 0) == 0
&& S_ISLNK(st.st_mode)) {
io_error |= IOERR_GENERAL;
rprintf(FERROR_XFER, "symlink has no referent: %s\n",
@@ -1372,7 +1544,7 @@ struct file_struct *make_file(const char *fname, struct file_list *flist,
if ((basename = strrchr(thisname, '/')) != NULL) {
int len = basename++ - thisname;
if (len != lastdir_len || memcmp(thisname, lastdir, len) != 0) {
if (len != lastdir_len || !lastdir || memcmp(thisname, lastdir, len) != 0) {
lastdir = new_array(char, len + 1);
memcpy(lastdir, thisname, len);
lastdir[len] = '\0';
@@ -1390,7 +1562,7 @@ struct file_struct *make_file(const char *fname, struct file_list *flist,
if (copy_devices && am_sender && IS_DEVICE(st.st_mode)) {
if (st.st_size == 0) {
int fd = do_open_checklinks(fname);
int fd = vfs_open_checklinks(fname);
if (fd >= 0) {
st.st_size = get_device_size(fd, fname);
close(fd);
@@ -1401,8 +1573,12 @@ struct file_struct *make_file(const char *fname, struct file_list *flist,
}
#ifdef ST_MTIME_NSEC
if (st.ST_MTIME_NSEC && protocol_version >= 31)
extra_len += EXTRA_LEN;
{
uint32 nsec = wire_mtime_nsec_from_stat(&st);
if (nsec && protocol_version >= 31)
extra_len += EXTRA_LEN;
}
#endif
#if SIZEOF_CAPITAL_OFF_T >= 8
if (st.st_size > 0xFFFFFFFFu && S_ISREG(st.st_mode))
@@ -1415,6 +1591,18 @@ struct file_struct *make_file(const char *fname, struct file_list *flist,
extra_len += SUM_EXTRA_CNT * EXTRA_LEN;
}
#ifdef HAVE_STRUCT_STAT_ST_RDEV
/* The sender path historically passes rdev via the tmp_rdev static
* (read by send_file_entry()), so make_file() never reserved
* DEV_EXTRA_CNT in the file_struct itself. But receiver-side callers
* (recv_generator's --inplace --backup back_file, backup.c make_backup)
* hand this struct to set_file_attrs() -> set_stat_xattr(), which reads
* F_RDEV_P(file) under --fake-super. Reserve and populate the slots
* so the struct is self-contained, matching recv_file_entry(). */
if (IS_DEVICE(st.st_mode))
extra_len += DEV_EXTRA_CNT * EXTRA_LEN;
#endif
#if EXTRA_ROUNDING > 0
if (extra_len & (EXTRA_ROUNDING * EXTRA_LEN))
extra_len = (extra_len | (EXTRA_ROUNDING * EXTRA_LEN)) + EXTRA_LEN;
@@ -1448,7 +1636,10 @@ struct file_struct *make_file(const char *fname, struct file_list *flist,
#ifdef HAVE_STRUCT_STAT_ST_RDEV
if (IS_DEVICE(st.st_mode)) {
uint32 *devp = F_RDEV_P(file);
tmp_rdev = st.st_rdev;
DEV_MAJOR(devp) = major(st.st_rdev);
DEV_MINOR(devp) = minor(st.st_rdev);
st.st_size = 0;
} else if (IS_SPECIAL(st.st_mode))
st.st_size = 0;
@@ -1457,9 +1648,13 @@ struct file_struct *make_file(const char *fname, struct file_list *flist,
file->flags = flags;
file->modtime = st.st_mtime;
#ifdef ST_MTIME_NSEC
if (st.ST_MTIME_NSEC && protocol_version >= 31) {
file->flags |= FLAG_MOD_NSEC;
F_MOD_NSEC(file) = st.ST_MTIME_NSEC;
{
uint32 nsec = wire_mtime_nsec_from_stat(&st);
if (nsec && protocol_version >= 31) {
file->flags |= FLAG_MOD_NSEC;
F_MOD_NSEC(file) = nsec;
}
}
#endif
file->len32 = (uint32)st.st_size;
@@ -1480,7 +1675,7 @@ struct file_struct *make_file(const char *fname, struct file_list *flist,
F_ATIME(file) = st.st_atime;
#ifdef SUPPORT_CRTIMES
if (crtimes_ndx)
F_CRTIME(file) = get_create_time(fname, &st);
F_CRTIME(file) = vfs_get_create_time(fname, &st);
#endif
if (basename != thisname)
@@ -1629,6 +1824,7 @@ static struct file_struct *send_file_name(int f, struct file_list *flist,
sx.st.st_mode = file->mode;
if (get_acl(fname, &sx) < 0) {
io_error |= IOERR_GENERAL;
free_acl(&sx);
return NULL;
}
}
@@ -1636,8 +1832,11 @@ static struct file_struct *send_file_name(int f, struct file_list *flist,
#ifdef SUPPORT_XATTRS
if (preserve_xattrs) {
sx.st.st_mode = file->mode;
if (get_xattr(fname, &sx) < 0) {
if (get_xattr(fname, -1, &sx) < 0) {
io_error |= IOERR_GENERAL;
#ifdef SUPPORT_ACLS
free_acl(&sx); /* get_acl() above may have loaded one */
#endif
return NULL;
}
}
@@ -1812,6 +2011,77 @@ static void interpret_stat_error(const char *fname, int is_dir)
}
}
#if defined HAVE_FDOPENDIR && defined HAVE_DIRFD
/* Open a source directory for scanning confined beneath the transfer root.
* vfs_resolve_open() does a per-component O_NOFOLLOW walk that refuses a
* parent component raced into a symlink pointing out of the tree; fdopendir()
* then turns the held fd into the DIR* the scan reads. This mirrors the
* sender's confined content open (sender.c): the directory enumeration must be
* confined the same way, or a parent-symlink race (or, for a daemon following
* mode, an in-module symlink to outside) lets the scan enumerate an out-of-tree
* directory and leak its names/metadata/symlink targets. O_DIRECTORY without
* O_NOFOLLOW makes vfs_resolve_open() follow in-tree directory symlinks
* beneath the anchor and refuse escapes, so this serves both the default
* no-follow scan and a daemon's symlink-following scan (see the caller).
* Returns NULL with errno set on failure, like opendir(). */
static DIR *secure_opendir(const char *fbuf)
{
int dfd, fl;
DIR *d;
if (am_daemon && (!am_chrooted || module_dirlen)
&& module_dir && module_dir[0] == '/' && *fbuf != '/' && module_dirfd >= 0
&& vfs.curr_dir_len >= module_dirlen
&& strncmp(vfs.curr_dir, module_dir, module_dirlen) == 0
&& (vfs.curr_dir[module_dirlen] == '\0' || vfs.curr_dir[module_dirlen] == '/')) {
/* Daemon: anchor the confined scan at the module root pinned by identity
* at module setup (module_dirfd, opened while the daemon was positioned
* there and still privileged), and walk the module-relative path of the
* scan target beneath it. This re-follows the same in-module path -- so a
* legitimate in-module ".." climb (sub/climb -> ../sibling) or an in-module
* directory symlink is followed, and an escape refused -- without
* re-walking the absolute module path as the dropped uid (the privilege-
* drop EACCES), and without assuming the lexical vfs.curr_dir depth matches the
* real cwd (a followed in-module symlink can desync them; anchoring at the
* pinned module root and walking down the logical path is correct either
* way). */
const char *p = vfs.curr_dir + module_dirlen;
char modrel[MAXPATHLEN];
while (*p == '/')
p++;
if ((size_t)snprintf(modrel, sizeof modrel, "%s%s%s",
p, *p ? "/" : "", fbuf) >= sizeof modrel) {
errno = ENAMETOOLONG;
return NULL;
}
dfd = vfs_resolve_open_at(module_dirfd, *modrel ? modrel : ".",
O_RDONLY | O_DIRECTORY, 0);
} else if (*fbuf == '/') {
/* An absolute scan path (an absolute --relative / --files-from name, or a
* "/" transfer root): anchor at "/" -- operator-named, trusted. */
const char *relp = fbuf;
while (*relp == '/')
relp++;
dfd = vfs_resolve_open("/", relp, O_RDONLY | O_DIRECTORY, 0);
} else {
/* Non-daemon (or chrooted) sender: confine beneath the cwd the sender
* chdir'd into (the transfer root). */
dfd = vfs_resolve_open(NULL, fbuf, O_RDONLY | O_DIRECTORY, 0);
}
if (dfd < 0)
return NULL;
if ((fl = fcntl(dfd, F_GETFD)) >= 0)
fcntl(dfd, F_SETFD, fl | FD_CLOEXEC);
if (!(d = fdopendir(dfd))) {
int save = errno;
close(dfd);
errno = save;
}
return d;
}
#endif
/* This function is normally called by the sender, but the receiving side also
* calls it from get_dirlist() with f set to -1 so that we just construct the
* file list in memory without sending it over the wire. Also, get_dirlist()
@@ -1830,7 +2100,31 @@ static void send_directory(int f, struct file_list *flist, char *fbuf, int len,
assert(flist != NULL);
if (!(d = opendir(fbuf))) {
#if defined HAVE_FDOPENDIR && defined HAVE_DIRFD
/* Confine the enumeration beneath the transfer root. secure_opendir()
* follows in-tree directory symlinks (RESOLVE_BENEATH) and refuses one that
* escapes, so it serves both modes:
* - a daemon/hardened sender (vfs_relpath_active()) is confined to the
* module in EVERY mode -- including -L/--copy-dirlinks/--copy-unsafe-
* links, matching the content open (sender_open_copylinks_confined) --
* so a following mode cannot be lured to enumerate outside the module;
* - a non-daemon sender is confined in the default no-follow mode; its
* symlink-following modes intentionally dereference out of the
* operator's own tree, so they keep the legacy opendir().
* f >= 0 is the sender's outgoing scan; get_dirlist() passes f < 0 and keeps
* the legacy opendir(). A module opted out of confinement ("insecure links =
* yes", admin-only) -- or a non-daemon --insecure-links -- uses the legacy
* opendir() too, restoring the pre-hardening enumeration (re-opening the
* escape; documented). */
if (f >= 0 && !vfs_symlink_optout_allowed() && (vfs_relpath_active()
|| !(copy_links || copy_unsafe_links || copy_dirlinks || insecure_links)))
d = secure_opendir(fbuf);
else
d = opendir(fbuf);
#else
d = opendir(fbuf);
#endif
if (!d) {
if (errno == ENOENT) {
if (am_sender) /* Can abuse this for vanished error w/ENOENT: */
interpret_stat_error(fbuf, True);
@@ -1853,6 +2147,14 @@ static void send_directory(int f, struct file_list *flist, char *fbuf, int len,
} else
remainder = 0;
#ifdef HAVE_DIRFD
/* Let the per-entry stat (readlink_stat -> scan_link_stat) go through the
* already-open directory fd instead of re-resolving fbuf for each name. */
scan_dirfd = dirfd(d);
scan_dir_prefix = fbuf;
scan_dir_prefix_len = len;
#endif
for (errno = 0, di = readdir(d); di; errno = 0, di = readdir(d)) {
unsigned name_len;
char *dname = d_name(di);
@@ -1881,6 +2183,9 @@ static void send_directory(int f, struct file_list *flist, char *fbuf, int len,
send_file_name(f, flist, fbuf, NULL, flags, filter_level);
}
scan_dirfd = -1; /* fbuf is about to be reused / d closed */
scan_dir_prefix = NULL; /* and don't leave the global pointing into fbuf */
scan_dir_prefix_len = 0;
fbuf[len] = '\0';
if (errno) {
@@ -2014,7 +2319,8 @@ static void send1extra(int f, struct file_struct *file, struct file_list *flist)
int len, dlen, flags = FLAG_DIVERT_DIRS | FLAG_CONTENT_DIR;
size_t j;
f_name(file, fbuf);
if (!f_name(file, fbuf))
return;
dlen = strlen(fbuf);
if (!change_pathname(file, NULL, 0))
@@ -2059,10 +2365,9 @@ static void send1extra(int f, struct file_struct *file, struct file_list *flist)
}
if (name_type != NORMAL_NAME) {
STRUCT_STAT st;
if (name_type == MISSING_NAME)
memset(&st, 0, sizeof st);
else if (link_stat(fbuf, &st, 1) != 0) {
STRUCT_STAT st = {0};
if (name_type != MISSING_NAME && link_stat(fbuf, &st, 1) != 0) {
interpret_stat_error(fbuf, True);
continue;
}
@@ -2194,7 +2499,7 @@ struct file_list *send_file_list(int f, int argc, char *argv[])
static const char *lastdir;
static int lastdir_len = -1;
int len, dirlen;
STRUCT_STAT st;
STRUCT_STAT st = {0};
char *p, *dir;
struct file_list *flist;
struct timeval start_tv, end_tv;
@@ -2253,7 +2558,7 @@ struct file_list *send_file_list(int f, int argc, char *argv[])
}
if (!orig_dir)
orig_dir = strdup(curr_dir);
orig_dir = strdup(vfs.curr_dir);
while (1) {
char fbuf[MAXPATHLEN], *fn, name_type;
@@ -2585,11 +2890,33 @@ struct file_list *recv_file_list(int f, int dir_ndx)
#endif
if (inc_recurse && dir_ndx >= 0) {
if (!first_flist) {
/* All flists have already been freed via the NDX_DONE
* chain, so dir_flist is stale: its files[] entries
* point into a destroyed pool. A sub-flist marker now
* is a protocol violation (and would otherwise UAF the
* stale dir entry below, then deref an uninitialised
* slot in the freshly reset dir_flist further down). */
rprintf(FERROR_XFER,
"rsync: refusing sub-flist after final flist was freed\n");
exit_cleanup(RERR_PROTOCOL);
}
if (dir_ndx >= dir_flist->used) {
rprintf(FERROR_XFER, "rsync: refusing invalid dir_ndx %u >= %u\n", dir_ndx, dir_flist->used);
exit_cleanup(RERR_PROTOCOL);
}
struct file_struct *file = dir_flist->files[dir_ndx];
if (!F_IS_ACTIVE(file)) {
/* flist_sort_and_clean() can clear_file() a directory
* entry that was a duplicate or otherwise pruned, but
* the cleared file_struct stays in dir_flist. A peer
* that then sends a sub-flist for that slot would make
* f_name() return NULL into the dirname strcmp() below. */
rprintf(FERROR_XFER,
"rsync: refusing flist for cleared dir_ndx %d\n",
dir_ndx);
exit_cleanup(RERR_PROTOCOL);
}
if (file->flags & FLAG_GOT_DIR_FLIST) {
rprintf(FERROR_XFER, "rsync: refusing malicious duplicate flist for dir %d\n", dir_ndx);
exit_cleanup(RERR_PROTOCOL);
@@ -2618,7 +2945,7 @@ struct file_list *recv_file_list(int f, int dir_ndx)
if ((flags = read_varint(f)) == 0) {
int err = read_varint(f);
if (!ignore_errors)
io_error |= err;
io_error |= err & IOERR_VALID_MASK;
break;
}
} else {
@@ -2636,7 +2963,7 @@ struct file_list *recv_file_list(int f, int dir_ndx)
}
err = read_varint(f);
if (!ignore_errors)
io_error |= err;
io_error |= err & IOERR_VALID_MASK;
break;
}
}
@@ -2651,7 +2978,7 @@ struct file_list *recv_file_list(int f, int dir_ndx)
cur_dir++;
if (cur_dir != good_dirname) {
const char *d = dir_ndx >= 0 ? f_name(dir_flist->files[dir_ndx], NULL) : empty_dir;
if (strcmp(cur_dir, d) != 0) {
if (!d || strcmp(cur_dir, d) != 0) {
rprintf(FERROR,
"ABORTING due to invalid path from sender: %s/%s\n",
cur_dir, file->basename);
@@ -2739,9 +3066,17 @@ struct file_list *recv_file_list(int f, int dir_ndx)
/* Recv the io_error flag */
int err = read_int(f);
if (!ignore_errors)
io_error |= err;
io_error |= err & IOERR_VALID_MASK;
} else if (inc_recurse && flist->ndx_start == 1) {
if (!file_total || strcmp(flist->sorted[flist->low]->basename, ".") != 0)
/* The first inc_recurse flist has no parent in dir_flist; a
* malicious peer can send a "." entry whose mode is not a
* directory, so it never lands in dir_flist (used stays 0) yet
* the basename test below still passes. That left parent_ndx at
* its default 0 and the consumers dereferenced dir_flist->files[0]
* = uninitialised heap. Require dir_flist to actually hold an
* entry before trusting index 0. */
if (!file_total || !dir_flist->used
|| strcmp(flist->sorted[flist->low]->basename, ".") != 0)
flist->parent_ndx = -1;
}
@@ -3167,8 +3502,8 @@ static void output_flist(struct file_list *flist)
} else
*uidbuf = '\0';
if (gid_ndx) {
static char parens[] = "(\0)\0\0\0";
char *pp = parens + (file->flags & FLAG_SKIP_GROUP ? 0 : 3);
static const char parens[] = "(\0)\0\0\0";
const char *pp = parens + (file->flags & FLAG_SKIP_GROUP ? 0 : 3);
snprintf(gidbuf, sizeof gidbuf, " gid=%s%u%s",
pp, F_GROUP(file), pp + 2);
} else
+562 -76
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -57,5 +57,6 @@
printf("%lu", (unsigned long)gid);
printf("\n");
free(list);
return 0;
}
+25 -10
View File
@@ -19,7 +19,7 @@
#include "rsync.h"
#define HASH_LOAD_LIMIT(size) ((size)*3/4)
#define HASH_LOAD_LIMIT(size) ((size)/4*3) /* /4 first: never overflows int */
struct hashtable *hashtable_create(int size, int key64)
{
@@ -28,15 +28,25 @@ struct hashtable *hashtable_create(int size, int key64)
int node_size = key64 ? sizeof (struct ht_int64_node)
: sizeof (struct ht_int32_node);
/* Pick a power of 2 that can hold the requested size. */
if (size & (size-1) || size < 16) {
/* Pick a power of 2 that can hold the requested size. Test size < 16 first
* so a negative/zero req short-circuits before the size-1 (INT_MIN is UB). */
if (size < 16 || (size & (size-1))) {
size = 16;
while (size < req)
while (size < req) {
if (size > INT_MAX/2) { /* the next doubling would overflow int */
rprintf(FERROR, "[%s] hashtable_create: requested size %d is too large\n",
who_am_i(), req);
exit_cleanup(RERR_MALLOC);
}
size *= 2;
}
}
tbl = new(struct hashtable);
tbl->nodes = new_array0(char, size * node_size);
/* Pass size and node_size as SEPARATE factors so my_alloc's overflow /
* --max-alloc guard sees both; computing size*node_size as int would wrap to
* a tiny count and under-allocate (heap overflow on later node access). */
tbl->nodes = my_alloc(do_calloc, size, node_size, __FILE__, __LINE__);
tbl->size = size;
tbl->entries = 0;
tbl->node_size = node_size;
@@ -90,10 +100,15 @@ void *hashtable_find(struct hashtable *tbl, int64 key, void *data_when_new)
if (data_when_new && tbl->entries > HASH_LOAD_LIMIT(tbl->size)) {
void *old_nodes = tbl->nodes;
int size = tbl->size * 2;
int i;
int size, i;
tbl->nodes = new_array0(char, size * tbl->node_size);
if (tbl->size > INT_MAX/2) { /* doubling would overflow int */
rprintf(FERROR, "[%s] hashtable grow: size overflow\n", who_am_i());
exit_cleanup(RERR_MALLOC);
}
size = tbl->size * 2;
/* Separate factors so my_alloc's guard sees both (see hashtable_create). */
tbl->nodes = my_alloc(do_calloc, size, tbl->node_size, __FILE__, __LINE__);
tbl->size = size;
tbl->entries = 0;
@@ -120,7 +135,7 @@ void *hashtable_find(struct hashtable *tbl, int64 key, void *data_when_new)
if (!key64) {
/* Based on Jenkins One-at-a-time hash. */
uchar buf[4], *keyp = buf;
uchar buf[4] = {0}, *keyp = buf; /* {0} only to satisfy the analyzer (SIVALu fills buf) */
int i;
SIVALu(buf, 0, key);
@@ -351,7 +366,7 @@ void *hashtable_find(struct hashtable *tbl, int64 key, void *data_when_new)
*/
#define NON_ZERO_32(x) ((x) ? (x) : (uint32_t)1)
#define NON_ZERO_64(x, y) ((x) || (y) ? (y) | (int64)(x) << 32 | (y) : (int64)1)
#define NON_ZERO_64(x, y) ((x) || (y) ? (y) | (uint64_t)(x) << 32 | (y) : (int64)1)
uint32_t hashlittle(const void *key, size_t length)
{
+29 -5
View File
@@ -125,8 +125,22 @@ static void match_gnums(int32 *ndx_list, int ndx_count)
if (inc_recurse) {
node = hashtable_find(prior_hlinks, gnum, data_when_new);
if (node->data == data_when_new) {
if (gnum < hlink_flist->ndx_start) {
/* A non-first hard-link entry whose
* gnum points before this flist's
* ndx_start should already have been
* recorded in prior_hlinks by an
* earlier flist. A peer that sends
* such a back-reference on the first
* flist (or to a gnum that was never
* declared XMIT_HLINK_FIRST) is
* misbehaving. */
rprintf(FERROR,
"hard-link gnum %d precedes flist start %d\n",
(int)gnum, (int)hlink_flist->ndx_start);
exit_cleanup(RERR_PROTOCOL);
}
node->data = new_array0(char, 5);
assert(gnum >= hlink_flist->ndx_start);
file->flags |= FLAG_HLINK_FIRST;
prev = -1;
} else if (CVAL(node->data, 0) == 0) {
@@ -406,7 +420,14 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname,
}
break;
}
if (!quick_check_ok(FT_REG, cmpbuf, file, &alt_sx.st))
/* Content-based basis match only applies to regular
* files: for a hard-linked symlink/device/special the
* exact-inode check above is the only meaningful test,
* and quick_check_ok(FT_REG, ...) would read F_SUM()
* on a file_struct that has no SUM_EXTRA_CNT space
* (recv_file_entry only allocates it for S_ISREG). */
if (!S_ISREG(file->mode)
|| !quick_check_ok(FT_REG, cmpbuf, file, &alt_sx.st))
continue;
statret = 1;
if (unchanged_attrs(cmpbuf, file, &alt_sx))
@@ -430,7 +451,7 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname,
if (preserve_xattrs) {
free_xattr(sxp);
if (!XATTR_READY(alt_sx))
get_xattr(cmpbuf, sxp);
get_xattr(cmpbuf, -1, sxp);
else {
sxp->xattr = alt_sx.xattr;
alt_sx.xattr = NULL;
@@ -452,9 +473,12 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname,
}
int hard_link_one(struct file_struct *file, const char *fname,
const char *oldname, int terse)
const char *oldname, int terse, int vfs_flags)
{
if (do_link(oldname, fname) < 0) {
/* oldname is the link source (vfs_flags carries its policy -- VFS_OPERATOR_PATH
* for an alt-dest basis on a non-daemon receiver, else 0); fname is the
* transfer destination, always under the secure receiver resolve. */
if (vfs_link_at(oldname, fname, vfs_flags, 0) < 0) {
enum logcode code;
if (terse) {
if (!INFO_GTE(NAME, 1))
+339 -69
View File
@@ -31,7 +31,15 @@
#include "ifuncs.h"
#include "inums.h"
/** If no timeout is specified then use a 60 second select timeout */
#include <poll.h>
/* Readiness bits we act on. poll() can report POLLERR/POLLHUP/POLLNVAL even
* when they were not requested, and POLLPRI stands in for select()'s old
* exception set. */
#define POLL_RD_BITS (POLLIN | POLLPRI | POLLERR | POLLHUP)
#define POLL_WR_BITS (POLLOUT | POLLERR | POLLHUP)
/** If no timeout is specified then use a 60 second I/O timeout */
#define SELECT_TIMEOUT 60
extern int bwlimit;
@@ -59,6 +67,7 @@ extern int xfer_sum_len;
extern int daemon_connection;
extern int protocol_version;
extern int remove_source_files;
extern int write_batch;
extern int preserve_hard_links;
extern BOOL extra_flist_sending_enabled;
extern BOOL flush_ok_after_signal;
@@ -79,6 +88,7 @@ BOOL flist_receiving_enabled = False;
/* Ignore an EOF error if non-zero. See whine_about_eof(). */
int kluge_around_eof = 0;
int got_kill_signal = -1; /* is set to 0 only after multiplexed I/O starts */
volatile sig_atomic_t got_sigusr2 = 0; /* set by the async-signal-safe SIGUSR2 handler */
int sock_f_in = -1;
int sock_f_out = -1;
@@ -102,6 +112,11 @@ static struct {
static time_t last_io_in;
static time_t last_io_out;
/* Absolute wall-clock bound for peer-controlled daemon handshake reads.
* This is deliberately separate from io_timeout: the latter is an idle
* transfer timeout and may be supplied by the module or client. */
static time_t daemon_handshake_deadline;
static int write_batch_monitor_in = -1;
static int write_batch_monitor_out = -1;
@@ -113,11 +128,43 @@ static xbuf ff_xb = EMPTY_XBUF;
static xbuf iconv_buf = EMPTY_XBUF;
#endif
static int select_timeout = SELECT_TIMEOUT;
/* Turn select_timeout (in seconds) into a poll() millisecond count, keeping it
* positive and bounded. A negative count means "wait forever" to poll(), which
* would bypass our keepalives and timeout enforcement entirely. */
static int poll_timeout_ms(void)
{
int secs = select_timeout;
if (secs <= 0 || secs > SELECT_TIMEOUT)
secs = SELECT_TIMEOUT;
return secs * 1000;
}
static int handshake_poll_timeout_ms(void)
{
time_t now, left;
int timeout = poll_timeout_ms();
if (!daemon_handshake_deadline)
return timeout;
now = time(NULL);
left = daemon_handshake_deadline - now;
if (left <= 0) {
rprintf(FERROR, "[%s] daemon handshake timeout -- exiting\n", who_am_i());
exit_cleanup(RERR_TIMEOUT);
}
if (left <= INT_MAX / 1000 && left * 1000 < timeout)
timeout = (int)left * 1000;
return timeout;
}
static int active_filecnt = 0;
static OFF_T active_bytecnt = 0;
static int first_message = 1;
static char int_byte_extra[64] = {
static const char int_byte_extra[64] = {
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* (00 - 3F)/4 */
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* (40 - 7F)/4 */
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, /* (80 - BF)/4 */
@@ -220,9 +267,15 @@ static NORETURN void whine_about_eof(BOOL allow_kluge)
int i;
if (kluge_around_eof > 0)
exit_cleanup(0);
/* If we're still here after 10 seconds, exit with an error. */
for (i = 10*1000/20; i--; )
/* The receiver is waiting here for the generator's SIGUSR2; act on it
* (exit cleanly) the moment it arrives rather than sleeping the full
* 10s and then erroring. The async-signal-safe handler only sets the
* flag, so this loop must poll it. */
for (i = 10*1000/20; i--; ) {
if (got_sigusr2)
receive_sigusr2();
msleep(20);
}
}
rprintf(FERROR, RSYNC_NAME ": connection unexpectedly closed "
@@ -243,31 +296,35 @@ static size_t safe_read(int fd, char *buf, size_t len)
assert(fd != iobuf.in_fd);
while (1) {
struct timeval tv;
fd_set r_fds, e_fds;
struct pollfd pfd;
int cnt;
FD_ZERO(&r_fds);
FD_SET(fd, &r_fds);
FD_ZERO(&e_fds);
FD_SET(fd, &e_fds);
tv.tv_sec = select_timeout;
tv.tv_usec = 0;
if (got_sigusr2) /* receiver told to wrap up (e.g. a --read-batch fd) */
receive_sigusr2();
cnt = select(fd+1, &r_fds, NULL, &e_fds, &tv);
/* We use poll() rather than select() so that a high-numbered fd
* (>= FD_SETSIZE) cannot overflow an fd_set bitmap. */
pfd.fd = fd;
pfd.events = POLLIN | POLLPRI;
pfd.revents = 0;
cnt = poll(&pfd, 1, handshake_poll_timeout_ms());
if (cnt <= 0) {
if (cnt < 0 && errno == EBADF) {
rsyserr(FERROR, errno, "safe_read select failed");
if (cnt < 0 && errno != EINTR && errno != EAGAIN) {
rsyserr(FERROR, errno, "safe_read poll failed");
exit_cleanup(RERR_FILEIO);
}
check_timeout(1, MSK_ALLOW_FLUSH);
continue;
}
/*if (FD_ISSET(fd, &e_fds))
rprintf(FINFO, "select exception on fd %d\n", fd); */
/* An invalid fd is reported here rather than via poll()'s return. */
if (pfd.revents & POLLNVAL) {
rsyserr(FERROR, EBADF, "safe_read poll failed");
exit_cleanup(RERR_FILEIO);
}
if (FD_ISSET(fd, &r_fds)) {
if (pfd.revents & POLL_RD_BITS) {
ssize_t n = read(fd, buf + got, len - got);
if (DEBUG_GTE(IO, 2)) {
rprintf(FINFO, "[%s] safe_read(%d)=%" SIZE_T_FMT_MOD "d\n",
@@ -315,6 +372,9 @@ static void safe_write(int fd, const char *buf, size_t len)
assert(fd != iobuf.out_fd);
if (got_sigusr2) /* receiver told to wrap up before this (batch) write */
receive_sigusr2();
n = write(fd, buf, len);
if ((size_t)n == len)
return;
@@ -332,19 +392,21 @@ static void safe_write(int fd, const char *buf, size_t len)
}
while (len) {
struct timeval tv;
fd_set w_fds;
struct pollfd pfd;
int cnt;
FD_ZERO(&w_fds);
FD_SET(fd, &w_fds);
tv.tv_sec = select_timeout;
tv.tv_usec = 0;
if (got_sigusr2) /* receiver told to wrap up (e.g. a --write-batch fd) */
receive_sigusr2();
cnt = select(fd + 1, NULL, &w_fds, NULL, &tv);
/* poll() avoids the FD_SETSIZE limit that select() imposes. */
pfd.fd = fd;
pfd.events = POLLOUT;
pfd.revents = 0;
cnt = poll(&pfd, 1, poll_timeout_ms());
if (cnt <= 0) {
if (cnt < 0 && errno == EBADF) {
rsyserr(FERROR, errno, "safe_write select failed on %s", what_fd_is(fd));
if (cnt < 0 && errno != EINTR && errno != EAGAIN) {
rsyserr(FERROR, errno, "safe_write poll failed on %s", what_fd_is(fd));
exit_cleanup(RERR_FILEIO);
}
if (io_timeout)
@@ -352,7 +414,12 @@ static void safe_write(int fd, const char *buf, size_t len)
continue;
}
if (FD_ISSET(fd, &w_fds)) {
if (pfd.revents & POLLNVAL) {
rsyserr(FERROR, EBADF, "safe_write poll failed on %s", what_fd_is(fd));
exit_cleanup(RERR_FILEIO);
}
if (pfd.revents & POLL_WR_BITS) {
n = write(fd, buf, len);
if (n < 0) {
if (errno == EINTR)
@@ -561,9 +628,8 @@ static void handle_kill_signal(BOOL flush_ok)
* unused raw data in the buf would prevent the reading of socket data. */
static char *perform_io(size_t needed, int flags)
{
fd_set r_fds, e_fds, w_fds;
struct timeval tv;
int cnt, max_fd;
struct pollfd pfds[3];
int cnt, max_fd, npfds, poll_timeout, in_pollpos, out_pollpos, ff_pollpos;
size_t empty_buf_len = 0;
xbuf *out;
char *data;
@@ -656,13 +722,15 @@ static char *perform_io(size_t needed, int flags)
}
max_fd = -1;
npfds = 0;
in_pollpos = out_pollpos = ff_pollpos = -1;
FD_ZERO(&r_fds);
FD_ZERO(&e_fds);
if (iobuf.in_fd >= 0 && iobuf.in.size - iobuf.in.len) {
if (!read_batch || batch_fd >= 0) {
FD_SET(iobuf.in_fd, &r_fds);
FD_SET(iobuf.in_fd, &e_fds);
pfds[npfds].fd = iobuf.in_fd;
pfds[npfds].events = POLLIN | POLLPRI;
pfds[npfds].revents = 0;
in_pollpos = npfds++;
}
if (iobuf.in_fd > max_fd)
max_fd = iobuf.in_fd;
@@ -670,12 +738,14 @@ static char *perform_io(size_t needed, int flags)
/* Only do more filesfrom processing if there is enough room in the out buffer. */
if (ff_forward_fd >= 0 && iobuf.out.size - iobuf.out.len > FILESFROM_BUFLEN*2) {
FD_SET(ff_forward_fd, &r_fds);
pfds[npfds].fd = ff_forward_fd;
pfds[npfds].events = POLLIN;
pfds[npfds].revents = 0;
ff_pollpos = npfds++;
if (ff_forward_fd > max_fd)
max_fd = ff_forward_fd;
}
FD_ZERO(&w_fds);
if (iobuf.out_fd >= 0) {
if (iobuf.raw_flushing_ends_before
|| (!iobuf.msg.len && iobuf.out.len > iobuf.out_empty_len && !(flags & PIO_NEED_MSGROOM))) {
@@ -715,7 +785,18 @@ static char *perform_io(size_t needed, int flags)
} else
out = NULL;
if (out) {
FD_SET(iobuf.out_fd, &w_fds);
/* A direct daemon connection uses one fd for both
* directions; give it a single row with both events
* rather than two rows carrying different masks. */
if (in_pollpos >= 0 && iobuf.out_fd == iobuf.in_fd) {
pfds[in_pollpos].events |= POLLOUT;
out_pollpos = in_pollpos;
} else {
pfds[npfds].fd = iobuf.out_fd;
pfds[npfds].events = POLLOUT;
pfds[npfds].revents = 0;
out_pollpos = npfds++;
}
if (iobuf.out_fd > max_fd)
max_fd = iobuf.out_fd;
}
@@ -749,19 +830,20 @@ static char *perform_io(size_t needed, int flags)
if (got_kill_signal > 0)
handle_kill_signal(True);
if (got_sigusr2)
receive_sigusr2();
if (extra_flist_sending_enabled) {
if (file_total - file_old_total < MAX_FILECNT_LOOKAHEAD && IN_MULTIPLEXED_AND_READY)
tv.tv_sec = 0;
poll_timeout = 0;
else {
extra_flist_sending_enabled = False;
tv.tv_sec = select_timeout;
poll_timeout = poll_timeout_ms();
}
} else
tv.tv_sec = select_timeout;
tv.tv_usec = 0;
poll_timeout = poll_timeout_ms();
cnt = select(max_fd + 1, &r_fds, &w_fds, &e_fds, &tv);
cnt = poll(pfds, npfds, poll_timeout);
if (cnt <= 0) {
if (cnt < 0 && errno == EBADF) {
@@ -774,11 +856,29 @@ static char *perform_io(size_t needed, int flags)
extra_flist_sending_enabled = !flist_eof;
} else
check_timeout((flags & PIO_NEED_INPUT) != 0, 0);
FD_ZERO(&r_fds); /* Just in case... */
FD_ZERO(&w_fds);
/* Just in case... */
if (in_pollpos >= 0)
pfds[in_pollpos].revents = 0;
if (ff_pollpos >= 0)
pfds[ff_pollpos].revents = 0;
if (out_pollpos >= 0)
pfds[out_pollpos].revents = 0;
}
if (iobuf.in_fd >= 0 && FD_ISSET(iobuf.in_fd, &r_fds)) {
if (cnt > 0) {
/* poll() reports a bad fd here, not via its return value. */
int p;
for (p = 0; p < npfds; p++) {
if (pfds[p].revents & POLLNVAL) {
msgs2stderr = 1;
rsyserr(FERROR, EBADF, "perform_io: poll reported an invalid fd");
exit_cleanup(RERR_SOCKETIO);
}
}
}
if (iobuf.in_fd >= 0 && in_pollpos >= 0
&& pfds[in_pollpos].revents & POLL_RD_BITS) {
size_t len, pos = iobuf.in.pos + iobuf.in.len;
ssize_t n;
if (pos >= iobuf.in.size) {
@@ -827,7 +927,7 @@ static char *perform_io(size_t needed, int flags)
exit_cleanup(RERR_TIMEOUT);
}
if (out && FD_ISSET(iobuf.out_fd, &w_fds)) {
if (out && out_pollpos >= 0 && pfds[out_pollpos].revents & POLL_WR_BITS) {
size_t len = iobuf.raw_flushing_ends_before ? iobuf.raw_flushing_ends_before - out->pos : out->len;
ssize_t n;
@@ -878,6 +978,8 @@ static char *perform_io(size_t needed, int flags)
if (got_kill_signal > 0)
handle_kill_signal(True);
if (got_sigusr2)
receive_sigusr2();
/* We need to help prevent deadlock by doing what reading
* we can whenever we are here trying to write. */
@@ -888,7 +990,8 @@ static char *perform_io(size_t needed, int flags)
wait_for_receiver(); /* generator only */
}
if (ff_forward_fd >= 0 && FD_ISSET(ff_forward_fd, &r_fds)) {
if (ff_forward_fd >= 0 && ff_pollpos >= 0
&& pfds[ff_pollpos].revents & POLL_RD_BITS) {
/* This can potentially flush all output and enable
* multiplexed output, so keep this last in the loop
* and be sure to not cache anything that would break
@@ -900,6 +1003,8 @@ static char *perform_io(size_t needed, int flags)
if (got_kill_signal > 0)
handle_kill_signal(True);
if (got_sigusr2)
receive_sigusr2();
data = iobuf.in.buf + iobuf.in.pos;
@@ -1070,17 +1175,30 @@ void send_msg_int(enum msgcode code, int num)
void send_msg_success(const char *fname, int num)
{
/* Batch-only mode has not duplicated anything on the receiving side yet.
* The receiver still reports success to the generator for file-list and
* hard-link bookkeeping, but the generator must not turn that status into
* sender-side removal. */
if (am_generator && write_batch < 0 && remove_source_files)
return;
if (local_server) {
STRUCT_STAT st;
if (DEBUG_GTE(IO, 1))
rprintf(FINFO, "[%s] send_msg_success(%d)\n", who_am_i(), num);
if (stat(fname, &st) < 0)
memset(&st, 0, sizeof (STRUCT_STAT));
/* The dev/ino is consumed only by the sender's --remove-source-files
* same-file safety check (successful_send), so skip the per-file
* stat entirely otherwise -- it's sent but never read. */
if (remove_source_files && stat(fname, &st) == 0) {
SIVAL64(num_dev_ino_buf, 4, st.st_dev);
SIVAL64(num_dev_ino_buf, 4+8, st.st_ino);
} else {
SIVAL64(num_dev_ino_buf, 4, 0);
SIVAL64(num_dev_ino_buf, 4+8, 0);
}
SIVAL(num_dev_ino_buf, 0, num);
SIVAL64(num_dev_ino_buf, 4, st.st_dev);
SIVAL64(num_dev_ino_buf, 4+8, st.st_ino);
send_msg(MSG_SUCCESS, num_dev_ino_buf, sizeof num_dev_ino_buf, -1);
} else
send_msg_int(MSG_SUCCESS, num);
@@ -1090,6 +1208,9 @@ static void got_flist_entry_status(enum festatus status, int ndx)
{
struct file_list *flist = flist_for_ndx(ndx, "got_flist_entry_status");
if (ndx < flist->ndx_start)
exit_cleanup(RERR_PROTOCOL);
if (remove_source_files) {
active_filecnt--;
active_bytecnt -= F_LENGTH(flist->files[ndx - flist->ndx_start]);
@@ -1100,7 +1221,7 @@ static void got_flist_entry_status(enum festatus status, int ndx)
switch (status) {
case FES_SUCCESS:
if (remove_source_files) {
if (remove_source_files && write_batch >= 0) {
if (local_server)
send_msg(MSG_SUCCESS, num_dev_ino_buf, sizeof num_dev_ino_buf, -1);
else
@@ -1144,8 +1265,26 @@ void io_set_sock_fds(int f_in, int f_out)
void set_io_timeout(int secs)
{
/* A negative timeout is meaningless; treat it as "no timeout" rather than
* letting it drive allowed_lull / select_timeout negative (a tight loop).
* (--timeout is parsed by options.c as a plain int, so it can be negative.) */
if (secs < 0)
secs = 0;
io_timeout = secs;
allowed_lull = (io_timeout + 1) / 2;
/* Compute ceil(io_timeout/2) in a wider type: io_timeout can be INT_MAX
* (a peer's MSG_IO_TIMEOUT -- now capped in read_a_msg() -- or an operator
* --timeout, which options.c parses unbounded), and a plain "io_timeout + 1"
* would overflow to a negative allowed_lull / select_timeout. poll() now
* takes a millisecond count where negative means "wait forever", so this
* would hang the process rather than spin it -- and it still fires a
* keepalive flood. poll_timeout_ms() clamps as well; keep both. */
allowed_lull = (int)(((int64)io_timeout + 1) / 2);
/* The generator and sender derive an int loop-check limit as
* allowed_lull * 5; keep allowed_lull small enough that that product can't
* overflow either. The cap is invisible to real use -- allowed_lull is the
* keep-alive half-interval and INT_MAX/5 seconds is over 13 years. */
if (allowed_lull > INT_MAX / 5)
allowed_lull = INT_MAX / 5;
if (!io_timeout || allowed_lull > SELECT_TIMEOUT)
select_timeout = SELECT_TIMEOUT;
@@ -1156,10 +1295,18 @@ void set_io_timeout(int secs)
allowed_lull = 0;
}
void set_daemon_handshake_timeout(int secs)
{
if (secs > 0)
daemon_handshake_deadline = time(NULL) + secs;
else
daemon_handshake_deadline = 0;
}
static void check_for_d_option_error(const char *msg)
{
static char rsync263_opts[] = "BCDHIKLPRSTWabceghlnopqrtuvxz";
char *colon;
static const char rsync263_opts[] = "BCDHIKLPRSTWabceghlnopqrtuvxz";
const char *colon;
int saw_d = 0;
if (*msg != 'r'
@@ -1289,8 +1436,23 @@ int read_line(int fd, char *buf, size_t bufsiz, int flags)
return s - buf;
}
/* Reverse safe_arg()'s backslash escaping of a daemon option arg, the way a
* remote shell un-escapes args for the ssh transport. In place; \X -> X. */
static void unbackslash_arg(char *s)
{
char *f = s, *t = s;
while (*f) {
if (*f == '\\' && f[1])
f++;
*t++ = *f++;
}
*t = '\0';
}
#define MAX_DAEMON_ARGS (MAX_ARGS * 16)
void read_args(int f_in, char *mod_name, char *buf, size_t bufsiz, int rl_nulls,
char ***argv_p, int *argc_p, char **request_p)
int unescape, char ***argv_p, int *argc_p, char **request_p)
{
int maxargs = MAX_ARGS;
int dot_pos = 0, argc = 0, request_len = 0;
@@ -1312,6 +1474,11 @@ void read_args(int f_in, char *mod_name, char *buf, size_t bufsiz, int rl_nulls,
if (read_line(f_in, buf, bufsiz, rl_flags) == 0)
break;
if (mod_name && argc >= MAX_DAEMON_ARGS - 1) {
rprintf(FERROR, "too many daemon arguments\n");
exit_cleanup(RERR_PROTOCOL);
}
if (argc == maxargs-1) {
maxargs += MAX_ARGS;
argv = realloc_array(argv, char *, maxargs);
@@ -1332,11 +1499,23 @@ void read_args(int f_in, char *mod_name, char *buf, size_t bufsiz, int rl_nulls,
glob_expand(buf, &argv, &argc, &maxargs);
} else {
p = strdup(buf);
/* An option arg the client escaped with safe_arg() (no
* remote shell un-escapes it for a daemon). File args
* after the dot are handled by glob_expand() below. */
if (unescape)
unbackslash_arg(p);
argv[argc++] = p;
if (*p == '.' && p[1] == '\0')
dot_pos = argc;
}
}
/* glob_expand()/glob_match() reserve glob.argc+1 slots -- room for the
* entry being added but not for this trailing NULL. A post-dot line
* whose " mod/" splits land argc on exactly maxargs (or any later
* ENSURE_MEMSPACE doubling boundary) would otherwise make the next
* store an 8-byte NULL write one slot past the argv allocation. */
if (argc >= maxargs)
argv = realloc_array(argv, char *, maxargs = argc + 1);
argv[argc] = NULL;
glob_expand(NULL, NULL, NULL, NULL);
@@ -1353,8 +1532,9 @@ BOOL io_start_buffering_out(int f_out)
if (iobuf.out.buf) {
if (iobuf.out_fd == -1)
iobuf.out_fd = f_out;
else
else if (iobuf.out_fd >= 0)
assert(f_out == iobuf.out_fd);
/* else out_fd == -2: peer already gone; leave it dead. */
return False;
}
@@ -1372,8 +1552,9 @@ BOOL io_start_buffering_in(int f_in)
if (iobuf.in.buf) {
if (iobuf.in_fd == -1)
iobuf.in_fd = f_in;
else
else if (iobuf.in_fd >= 0)
assert(f_in == iobuf.in_fd);
/* else in_fd == -2: peer already EOF'd; leave it dead. */
return False;
}
@@ -1522,16 +1703,26 @@ static void read_a_msg(void)
if (msg_bytes != 4)
goto invalid_msg;
val = raw_read_int();
iobuf.in_multiplexed = 1;
val &= IOERR_VALID_MASK;
io_error |= val;
if (am_receiver)
send_msg_int(MSG_IO_ERROR, val);
iobuf.in_multiplexed = 1;
break;
case MSG_IO_TIMEOUT:
if (msg_bytes != 4 || am_server || am_generator)
goto invalid_msg;
val = raw_read_int();
iobuf.in_multiplexed = 1;
/* The peer may only ask us to use a SHORTER timeout (a stricter cap); a
* non-positive value would disable our --timeout entirely, letting a
* malicious server hang the client indefinitely, so ignore it. A very
* large value (near INT_MAX) would overflow the (io_timeout + 1) / 2
* computation in set_io_timeout(), wrapping allowed_lull and
* select_timeout negative -- which poll() reads as "wait forever",
* hanging the client. Cap at 24 hours. */
if (val <= 0 || val > 86400)
break;
if (!io_timeout || io_timeout > val) {
if (INFO_GTE(MISC, 2))
rprintf(FINFO, "Setting --timeout=%d to match server\n", val);
@@ -1542,17 +1733,17 @@ static void read_a_msg(void)
/* Support protocol-30 keep-alive method. */
if (msg_bytes != 0)
goto invalid_msg;
iobuf.in_multiplexed = 1;
if (am_sender)
maybe_send_keepalive(time(NULL), MSK_ALLOW_FLUSH);
iobuf.in_multiplexed = 1;
break;
case MSG_DELETED:
if (msg_bytes >= sizeof data)
goto overflow;
if (am_generator) {
raw_read_buf(data, msg_bytes);
iobuf.in_multiplexed = 1;
send_msg(MSG_DELETED, data, msg_bytes, 1);
iobuf.in_multiplexed = 1;
break;
}
#ifdef ICONV_OPTION
@@ -1590,7 +1781,6 @@ static void read_a_msg(void)
} else
#endif
raw_read_buf(data, msg_bytes);
iobuf.in_multiplexed = 1;
/* A directory name was sent with the trailing null */
if (msg_bytes > 0 && !data[msg_bytes-1])
log_delete(data, S_IFDIR);
@@ -1598,6 +1788,7 @@ static void read_a_msg(void)
data[msg_bytes] = '\0';
log_delete(data, S_IFREG);
}
iobuf.in_multiplexed = 1;
break;
case MSG_SUCCESS:
if (msg_bytes != (local_server ? 4+8+8 : 4)) {
@@ -1619,11 +1810,11 @@ static void read_a_msg(void)
if (msg_bytes != 4)
goto invalid_msg;
val = raw_read_int();
iobuf.in_multiplexed = 1;
if (am_generator)
got_flist_entry_status(FES_NO_SEND, val);
else
send_msg_int(MSG_NO_SEND, val);
iobuf.in_multiplexed = 1;
break;
case MSG_ERROR_SOCKET:
case MSG_ERROR_UTF8:
@@ -1865,6 +2056,45 @@ int64 read_varlong(int f, uchar min_bytes)
return u.x;
}
/* Read an int32 and verify lo <= v <= hi. On out-of-range, abort with a
* protocol error naming "what". The bound is co-located with the read so it
* cannot be forgotten by a downstream user. */
int32 read_int_bounded(int f, int32 lo, int32 hi, const char *what)
{
int32 v = read_int(f);
if (v < lo || v > hi) {
rprintf(FERROR, "wire value %s out of range: %ld not in [%ld,%ld] [%s]\n",
what, (long)v, (long)lo, (long)hi, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
return v;
}
/* As read_int_bounded but for varint-encoded values. */
int32 read_varint_bounded(int f, int32 lo, int32 hi, const char *what)
{
int32 v = read_varint(f);
if (v < lo || v > hi) {
rprintf(FERROR, "wire value %s out of range: %ld not in [%ld,%ld] [%s]\n",
what, (long)v, (long)lo, (long)hi, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
return v;
}
/* Read a varint that will be used as a size_t. Rejects negative values
* (which would wrap to ~SIZE_MAX) and values exceeding the supplied max. */
size_t read_varint_size(int f, size_t max, const char *what)
{
int32 v = read_varint(f);
if (v < 0 || (size_t)v > max) {
rprintf(FERROR, "wire size %s out of range: %ld > %lu [%s]\n",
what, (long)v, (unsigned long)max, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
return (size_t)v;
}
int64 read_longint(int f)
{
#if SIZEOF_INT64 >= 8
@@ -1971,12 +2201,42 @@ void read_sum_head(int f, struct sum_struct *sum)
(long)sum->count, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
/* Guard against integer overflow in downstream allocations sized by
* count*element_size. my_alloc uses divide-not-multiply so it is
* already wraparound-safe, but checking here gives a clearer error
* and also covers the (size_t)count * xfer_sum_len arithmetic that
* is performed *before* reaching my_alloc. */
if (xfer_sum_len > 0 && (size_t)sum->count > SIZE_MAX / (size_t)xfer_sum_len) {
rprintf(FERROR, "Invalid checksum count %ld (too large) [%s]\n",
(long)sum->count, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
if ((size_t)sum->count > SIZE_MAX / sizeof(struct sum_buf)) {
rprintf(FERROR, "Invalid checksum count %ld (sum_buf overflow) [%s]\n",
(long)sum->count, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
sum->blength = read_int(f);
if (sum->blength < 0 || sum->blength > max_blength) {
rprintf(FERROR, "Invalid block length %ld [%s]\n",
(long)sum->blength, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
if (sum->count && sum->blength == 0) {
rprintf(FERROR, "Invalid zero block length [%s]\n",
who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
#if SIZEOF_CAPITAL_OFF_T < 8
/* The append-mode callers compute (OFF_T)count * blength; on a 32-bit
* OFF_T that product can wrap even though both factors are individually
* in range, corrupting the lseek/loop bounds. Reject it early. */
if (sum->blength > 0 && sum->count > MAX_INT32 / sum->blength) {
rprintf(FERROR, "checksum count*blength overflows OFF_T [%s]\n",
who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
#endif
sum->s2length = protocol_version < 27 ? csum_length : (int)read_int(f);
if (sum->s2length < 0 || sum->s2length > xfer_sum_len) {
rprintf(FERROR, "Invalid checksum length %d [%s]\n",
@@ -2088,7 +2348,7 @@ void write_int(int f, int32 x)
void write_varint(int f, int32 x)
{
char b[5];
char b[5] = {0}; /* {0} only to satisfy the analyzer: it doesn't model SIVAL initialising b[1..4] */
uchar bit;
int cnt;
@@ -2110,7 +2370,7 @@ void write_varint(int f, int32 x)
void write_varlong(int f, int64 x, uchar min_bytes)
{
char b[9];
char b[9] = {0}; /* {0} only to satisfy the analyzer: it doesn't model SIVAL64 initialising b[1..8] */
uchar bit;
int cnt = 8;
@@ -2291,6 +2551,7 @@ int32 read_ndx(int f)
{
static int32 prev_positive = -1, prev_negative = 1;
int32 *prev_ptr, num;
uint32 unum;
char b[4];
if (protocol_version < 30)
@@ -2310,11 +2571,20 @@ int32 read_ndx(int f)
b[3] = CVAL(b, 0) & ~0x80;
b[0] = b[1];
read_buf(f, b+1, 2);
num = IVAL(b, 0);
unum = IVAL(b, 0);
} else
num = (UVAL(b,0)<<8) + UVAL(b,1) + *prev_ptr;
unum = (UVAL(b,0)<<8) + UVAL(b,1) + (uint32)*prev_ptr;
} else
num = UVAL(b, 0) + *prev_ptr;
unum = UVAL(b, 0) + (uint32)*prev_ptr;
/* A peer-supplied index that overflows a signed int32 (used unchecked as a
* file-list index) is a protocol violation -- reject it here rather than
* relying on every downstream consumer to bounds-check. */
if (unum > (uint32)MAX_INT32) {
rprintf(FERROR, "Invalid file index: %lu [%s]\n",
(unsigned long)unum, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
num = (int32)unum;
*prev_ptr = num;
if (prev_ptr == &prev_negative)
num = -num;
+1 -1
View File
@@ -1 +1 @@
#define LATEST_YEAR "2025"
#define LATEST_YEAR "2026"
+451
View File
@@ -0,0 +1,451 @@
/*
* POSIX ACL get/set/delete via the generic xattr syscalls.
*
* POSIX ACLs are stored by the kernel as the "system.posix_acl_access" and
* "system.posix_acl_default" extended attributes, in a fixed little-endian
* wire format (see include/acl_ea.h in the acl package). By serializing that
* format ourselves and using fgetxattr/fsetxattr on a held O_NOFOLLOW fd -- or
* getxattrat/setxattrat(AT_SYMLINK_NOFOLLOW) on a dirfd+leaf -- we get a
* symlink-race-safe ACL primitive that also covers the *default* ACL, which
* libacl's fd API (acl_get_fd/acl_set_fd, access-only) cannot.
*
* This file knows nothing about rsync's globals or its internal ACL form: it
* speaks a neutral (tag, perm, id) entry array, which makes it directly
* comparable against the system libacl in the t_acl unit test.
*
* Copyright (C) 2026 Wayne Davison & the rsync project
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, visit the http://fsf.org website.
*/
#include "rsync.h"
#include "acl.h"
#ifdef SUPPORT_ACL_FD
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h> /* AT_SYMLINK_NOFOLLOW */
#if defined HAVE_SYS_XATTR_H
#include <sys/xattr.h>
#elif defined HAVE_ATTR_XATTR_H
#include <attr/xattr.h>
#endif
#ifdef HAVE_XATTRAT_SYSCALLS
#include <sys/syscall.h>
/* Self-contained copy of the kernel's struct xattr_args (stable ABI: an
* 8-byte-aligned u64 pointer, then two u32s). Defined locally to avoid
* pulling <linux/xattr.h>, whose XATTR_* macros clash with <sys/xattr.h>. */
struct rsync_xattr_args {
uint64_t value __attribute__((aligned(8)));
uint32_t size;
uint32_t flags;
};
#endif
/* Linux 2.4 didn't have a distinct ENOATTR. */
#ifndef ENOATTR
#define ENOATTR ENODATA
#endif
#define ACL_XATTR_ACCESS "system.posix_acl_access"
#define ACL_XATTR_DEFAULT "system.posix_acl_default"
/* On-disk layout: a 4-byte LE version header followed by 8-byte LE entries. */
#define ACL_EA_VERSION 0x0002
#define ACL_EA_HDR_LEN 4
#define ACL_EA_ENT_LEN 8
/* === little-endian (de)serialization (host-endianness independent) === */
static void put_le16(unsigned char *p, uint16_t v)
{
p[0] = (unsigned char)(v & 0xff);
p[1] = (unsigned char)((v >> 8) & 0xff);
}
static void put_le32(unsigned char *p, uint32_t v)
{
p[0] = (unsigned char)(v & 0xff);
p[1] = (unsigned char)((v >> 8) & 0xff);
p[2] = (unsigned char)((v >> 16) & 0xff);
p[3] = (unsigned char)((v >> 24) & 0xff);
}
static uint16_t get_le16(const unsigned char *p)
{
return (uint16_t)(p[0] | (p[1] << 8));
}
static uint32_t get_le32(const unsigned char *p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static int is_named_tag(uint16_t tag)
{
return tag == RACL_USER || tag == RACL_GROUP;
}
/* Canonical order: tag ascending, then id ascending within a tag. This is
* the order libacl's __acl_reorder_obj_p() produces and what the kernel's
* validator expects (USER_OBJ, USER*, GROUP_OBJ, GROUP*, MASK, OTHER). */
static int ent_compare(const void *a, const void *b)
{
const rsync_acl_ent *x = a, *y = b;
if (x->tag != y->tag)
return x->tag < y->tag ? -1 : 1;
if (x->id != y->id)
return x->id < y->id ? -1 : 1;
return 0;
}
/* Serialize entries into a freshly-malloc'd xattr buffer (canonical order). */
static unsigned char *acl_to_xattr(const rsync_acl_ent *ents, int count, size_t *len_out)
{
size_t len = ACL_EA_HDR_LEN + (size_t)count * ACL_EA_ENT_LEN;
unsigned char *buf = malloc(len);
rsync_acl_ent *sorted = NULL;
unsigned char *p;
int i;
if (!buf)
return NULL;
if (count > 1) {
sorted = malloc((size_t)count * sizeof sorted[0]);
if (!sorted) {
free(buf);
return NULL;
}
memcpy(sorted, ents, (size_t)count * sizeof sorted[0]);
qsort(sorted, count, sizeof sorted[0], ent_compare);
ents = sorted;
}
put_le32(buf, ACL_EA_VERSION);
p = buf + ACL_EA_HDR_LEN;
for (i = 0; i < count; i++, p += ACL_EA_ENT_LEN) {
put_le16(p, ents[i].tag);
put_le16(p + 2, ents[i].perm);
put_le32(p + 4, is_named_tag(ents[i].tag) ? ents[i].id : RACL_UNDEFINED_ID);
}
if (sorted)
free(sorted);
*len_out = len;
return buf;
}
/* Parse an xattr buffer into a malloc'd entry array (canonical order). */
static int xattr_to_acl(const unsigned char *buf, size_t len,
rsync_acl_ent **out, int *count_out)
{
rsync_acl_ent *ents;
const unsigned char *p;
int n, i;
if (len < ACL_EA_HDR_LEN || (len - ACL_EA_HDR_LEN) % ACL_EA_ENT_LEN != 0
|| get_le32(buf) != ACL_EA_VERSION) {
errno = EINVAL;
return -1;
}
n = (int)((len - ACL_EA_HDR_LEN) / ACL_EA_ENT_LEN);
ents = n ? malloc((size_t)n * sizeof ents[0]) : NULL;
if (n && !ents)
return -1;
p = buf + ACL_EA_HDR_LEN;
for (i = 0; i < n; i++, p += ACL_EA_ENT_LEN) {
ents[i].tag = get_le16(p);
ents[i].perm = get_le16(p + 2);
ents[i].id = is_named_tag(ents[i].tag) ? get_le32(p + 4) : RACL_UNDEFINED_ID;
}
if (n > 1)
qsort(ents, n, sizeof ents[0], ent_compare);
*out = ents;
*count_out = n;
return 0;
}
/* === syscall dispatchers (fd-variant vs at-variant) === */
/* Pre-6.13 fallback for the dirfd+leaf at-variants: address the leaf as
* /proc/self/fd/<dirfd>/<leaf> and use the l*xattr (no-follow-leaf) calls. The
* /proc/self/fd/<dirfd> magic symlink resolves to the pinned parent inode -- a
* raced parent symlink cannot redirect it -- and l*xattr does not follow a raced
* leaf symlink, so this is race-safe without the Linux 6.13 *xattrat syscalls, as
* long as procfs is mounted. (`leaf` is a single component, <= NAME_MAX.)
* Returns 0 and fills `buf`, or -1 with ENAMETOOLONG. */
static int proc_fd_leaf_path(char *buf, size_t buflen, int dirfd, const char *leaf)
{
int n = snprintf(buf, buflen, "/proc/self/fd/%d/%s", dirfd, leaf);
if (n < 0 || (size_t)n >= buflen) {
errno = ENAMETOOLONG;
return -1;
}
return 0;
}
static ssize_t do_getxattr(int fd, int dirfd, const char *leaf,
const char *name, void *val, size_t size)
{
char p[MAXPATHLEN];
if (fd >= 0)
return fgetxattr(fd, name, val, size);
#ifdef HAVE_XATTRAT_SYSCALLS
{
struct rsync_xattr_args args;
ssize_t ret;
args.value = (uint64_t)(uintptr_t)val;
args.size = (uint32_t)size;
args.flags = 0;
ret = syscall(SYS_getxattrat, dirfd, leaf, AT_SYMLINK_NOFOLLOW,
name, &args, sizeof args);
if (ret != -1 || errno != ENOSYS)
return ret;
/* ENOSYS: kernel < 6.13 -- fall through to the /proc compat. */
}
#endif
if (proc_fd_leaf_path(p, sizeof p, dirfd, leaf) < 0)
return -1;
return lgetxattr(p, name, val, size);
}
static int do_setxattr(int fd, int dirfd, const char *leaf,
const char *name, const void *val, size_t size)
{
char p[MAXPATHLEN];
if (fd >= 0)
return fsetxattr(fd, name, val, size, 0);
#ifdef HAVE_XATTRAT_SYSCALLS
{
struct rsync_xattr_args args;
int ret;
args.value = (uint64_t)(uintptr_t)val;
args.size = (uint32_t)size;
args.flags = 0; /* replace */
ret = syscall(SYS_setxattrat, dirfd, leaf, AT_SYMLINK_NOFOLLOW,
name, &args, sizeof args);
if (ret != -1 || errno != ENOSYS)
return ret;
}
#endif
if (proc_fd_leaf_path(p, sizeof p, dirfd, leaf) < 0)
return -1;
return lsetxattr(p, name, val, size, 0);
}
static int do_removexattr(int fd, int dirfd, const char *leaf, const char *name)
{
char p[MAXPATHLEN];
if (fd >= 0)
return fremovexattr(fd, name);
#ifdef HAVE_XATTRAT_SYSCALLS
{
int ret = syscall(SYS_removexattrat, dirfd, leaf, AT_SYMLINK_NOFOLLOW, name);
if (ret != -1 || errno != ENOSYS)
return ret;
}
#endif
if (proc_fd_leaf_path(p, sizeof p, dirfd, leaf) < 0)
return -1;
return lremovexattr(p, name);
}
/* Read the whole named xattr into a malloc'd buffer, growing as needed. */
static int read_full_xattr(int fd, int dirfd, const char *leaf,
const char *name, unsigned char **buf_out, size_t *len_out)
{
unsigned char *buf = NULL;
size_t size = 0;
int tries;
for (tries = 0; tries < 8; tries++) {
ssize_t n = do_getxattr(fd, dirfd, leaf, name, size ? buf : NULL, size);
if (n >= 0) {
if (size == 0) {
/* First call just learned the length. */
size = n ? (size_t)n : 1;
buf = malloc(size);
if (!buf)
return -1;
continue;
}
*buf_out = buf;
*len_out = (size_t)n;
return 0;
}
if (errno == ERANGE) { /* grew under us: re-probe the size */
if (buf)
free(buf);
buf = NULL;
size = 0;
continue;
}
if (buf)
free(buf);
return -1; /* ENODATA / EOPNOTSUPP / ENOSYS / ... in errno */
}
if (buf)
free(buf);
errno = ERANGE;
return -1;
}
/* === public API === */
static int acl_get_common(int fd, int dirfd, const char *leaf,
int want_default, rsync_acl_ent **entries, int *count)
{
const char *name = want_default ? ACL_XATTR_DEFAULT : ACL_XATTR_ACCESS;
unsigned char *buf;
size_t len;
int rc;
*entries = NULL;
*count = 0;
if (read_full_xattr(fd, dirfd, leaf, name, &buf, &len) < 0) {
if (errno == ENODATA || errno == ENOATTR)
return 0; /* no explicit ACL present */
return -1; /* EOPNOTSUPP / ENOSYS / real error */
}
rc = xattr_to_acl(buf, len, entries, count);
free(buf);
return rc;
}
int xacl_get_fd(int fd, int want_default, rsync_acl_ent **entries, int *count)
{
return acl_get_common(fd, -1, NULL, want_default, entries, count);
}
int xacl_get_at(int dirfd, const char *leaf, int want_default,
rsync_acl_ent **entries, int *count)
{
return acl_get_common(-1, dirfd, leaf, want_default, entries, count);
}
static int acl_set_common(int fd, int dirfd, const char *leaf,
int want_default, const rsync_acl_ent *ents, int count)
{
const char *name = want_default ? ACL_XATTR_DEFAULT : ACL_XATTR_ACCESS;
unsigned char *buf;
size_t len;
int rc, save_errno;
buf = acl_to_xattr(ents, count, &len);
if (!buf) {
errno = ENOMEM;
return -1;
}
rc = do_setxattr(fd, dirfd, leaf, name, buf, len);
save_errno = errno;
free(buf);
errno = save_errno;
return rc < 0 ? -1 : 0;
}
int xacl_set_fd(int fd, int want_default, const rsync_acl_ent *ents, int count)
{
return acl_set_common(fd, -1, NULL, want_default, ents, count);
}
int xacl_set_at(int dirfd, const char *leaf, int want_default,
const rsync_acl_ent *ents, int count)
{
return acl_set_common(-1, dirfd, leaf, want_default, ents, count);
}
static int acl_del_default_common(int fd, int dirfd, const char *leaf)
{
if (do_removexattr(fd, dirfd, leaf, ACL_XATTR_DEFAULT) < 0) {
if (errno == ENODATA || errno == ENOATTR)
return 0; /* already absent: success, like acl_delete_def_file */
return -1;
}
return 0;
}
int xacl_del_default_fd(int fd)
{
return acl_del_default_common(fd, -1, NULL);
}
int xacl_del_default_at(int dirfd, const char *leaf)
{
return acl_del_default_common(-1, dirfd, leaf);
}
/* True iff /proc/self/fd magic symlinks are usable, so the dirfd+leaf at-variants
* work race-safely via the /proc compat on a pre-6.13 kernel. */
static int proc_self_fd_usable(void)
{
int dfd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
char p[64];
int usable = 0;
if (dfd < 0)
return 0;
if (snprintf(p, sizeof p, "/proc/self/fd/%d/.", dfd) < (int)sizeof p) {
/* The probe attr is absent; the path resolving (any errno but
* ENOENT/ENOTDIR -- e.g. ENODATA/ENOTSUP/EACCES) means procfs gives us
* the magic fd-symlink we need. */
errno = 0;
lgetxattr(p, "user.rsync_acl_probe", NULL, 0);
usable = !(errno == ENOENT || errno == ENOTDIR);
}
close(dfd);
return usable;
}
int xacl_at_available(void)
{
static int avail = -1;
if (avail < 0) {
#ifdef HAVE_XATTRAT_SYSCALLS
/* Probe the *xattrat syscall directly (not via do_getxattr's /proc
* fallback): any errno other than ENOSYS means it is present (6.13+). */
struct rsync_xattr_args args;
args.value = 0;
args.size = 0;
args.flags = 0;
errno = 0;
syscall(SYS_getxattrat, AT_FDCWD, ".", AT_SYMLINK_NOFOLLOW,
"user.rsync_acl_probe", &args, sizeof args);
if (errno != ENOSYS) {
avail = 1;
return avail;
}
#endif
/* No *xattrat syscalls (pre-6.13, or a kernel built without them): the dirfd+leaf ACL ops
* are still race-safe via /proc/self/fd if procfs is mounted, closing
* the parent-symlink-race gap that otherwise forces the path-based set. */
avail = proc_self_fd_usable();
}
return avail;
}
#endif /* SUPPORT_ACL_FD */
+74
View File
@@ -0,0 +1,74 @@
/*
* POSIX ACL get/set/delete via the generic xattr syscalls, addressing the
* kernel "system.posix_acl_{access,default}" attributes directly so that the
* operation can be confined to a held O_NOFOLLOW fd (fsetxattr) or a
* dirfd+leaf with AT_SYMLINK_NOFOLLOW (setxattrat). This replaces the path-
* based libacl acl_*_file() calls on Linux, where those would re-resolve the
* path and could be redirected by a parent-component symlink race.
*
* Copyright (C) 2026 Wayne Davison & the rsync project
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, visit the http://fsf.org website.
*/
#ifdef SUPPORT_ACL_FD
#include <stdint.h>
/* A single logical POSIX ACL entry in host-native form. The tag values are
* the stable kernel ABI numbers (== the libacl ACL_* constants), so they map
* straight onto the on-disk e_tag without translation. */
typedef struct {
uint16_t tag; /* RACL_USER_OBJ / USER / GROUP_OBJ / GROUP / MASK / OTHER */
uint16_t perm; /* permission bits: read=4, write=2, execute=1 */
uint32_t id; /* uid/gid for USER/GROUP entries; RACL_UNDEFINED_ID otherwise */
} rsync_acl_ent;
#define RACL_USER_OBJ 0x01
#define RACL_USER 0x02
#define RACL_GROUP_OBJ 0x04
#define RACL_GROUP 0x08
#define RACL_MASK 0x10
#define RACL_OTHER 0x20
#define RACL_UNDEFINED_ID ((uint32_t)-1)
/* Read the access (want_default==0) or default (want_default!=0) ACL.
*
* On success returns 0 and sets *entries to a malloc()ed array of *count
* entries (the caller frees it with free(); *entries may be NULL when
* *count==0, which means "no explicit ACL present" -- e.g. ENODATA).
*
* On failure returns -1 with errno set. Callers distinguish:
* ENOTSUP/EOPNOTSUPP - this filesystem has no ACL support (may differ per fs)
* ENOSYS - the at-variant syscalls are unavailable on this kernel
* The fd-variant operates on a held, already-NOFOLLOW-opened descriptor. The
* at-variant resolves leaf relative to dirfd and never follows a leaf symlink. */
int xacl_get_fd(int fd, int want_default, rsync_acl_ent **entries, int *count);
int xacl_get_at(int dirfd, const char *leaf, int want_default, rsync_acl_ent **entries, int *count);
/* Write the given entries as the access/default ACL. The entries are emitted
* in canonical order; the kernel validates them (a malformed set -> EINVAL). */
int xacl_set_fd(int fd, int want_default, const rsync_acl_ent *entries, int count);
int xacl_set_at(int dirfd, const char *leaf, int want_default, const rsync_acl_ent *entries, int count);
/* Delete a directory's default ACL. A missing default ACL is success. */
int xacl_del_default_fd(int fd);
int xacl_del_default_at(int dirfd, const char *leaf);
/* Cached runtime probe: are the *xattrat syscalls usable on this kernel?
* Returns 0 when they are absent (so callers can fall back) or unbuilt. */
int xacl_at_available(void);
#endif /* SUPPORT_ACL_FD */
+3 -1
View File
@@ -34,7 +34,9 @@
#endif
.text
.align 16
/* .balign = N bytes everywhere; bare .align means 2^N on Mach-O (would ask
* for 64KB alignment and trip a macOS linker warning). */
.balign 16
.globl md5_process_asm
md5_process_asm:
+1 -1
View File
@@ -197,7 +197,7 @@ void md5_update(md_context *ctx, const uchar *input, uint32 length)
memcpy(ctx->buffer + left, input, length);
}
static uchar md5_padding[CSUM_CHUNK] = { 0x80 };
static const uchar md5_padding[CSUM_CHUNK] = { 0x80 };
void md5_result(md_context *ctx, uchar digest[MD5_DIGEST_LEN])
{
+2 -2
View File
@@ -89,8 +89,8 @@ static void copy64(uint32 *M, const uchar *in)
int i;
for (i = 0; i < MD4_DIGEST_LEN; i++) {
M[i] = (in[i*4+3] << 24) | (in[i*4+2] << 16)
| (in[i*4+1] << 8) | (in[i*4+0] << 0);
M[i] = ((uint32)in[i*4+3] << 24) | ((uint32)in[i*4+2] << 16)
| ((uint32)in[i*4+1] << 8) | ((uint32)in[i*4+0] << 0);
}
}
+44 -1
View File
@@ -44,6 +44,32 @@ struct align_test {
#define PTR_ADD(b,o) ( (void*) ((char*)(b) + (o)) )
#define PTR_SUB(b,o) ( (void*) ((char*)(b) - (o)) )
/* Under AddressSanitizer, fence each pool_alloc() chunk with a poisoned
* redzone just below it (allocations grow downward from the top of an extent).
* A bump allocator hands out chunks from one big malloc, so ASan cannot see a
* write that underflows one chunk into its neighbour -- e.g. a miscomputed
* F_SUM() reaching before a file_struct's extras. The redzone turns that into
* a hard ASan report. We unpoison a whole extent whenever its space is reused
* (reset/reclaim), so legitimate later allocations never trip over old
* redzones; ASan unpoisons freed extents itself via free(). */
#if defined(__SANITIZE_ADDRESS__)
# define POOL_ASAN 1
#elif defined(__has_feature)
# if __has_feature(address_sanitizer)
# define POOL_ASAN 1
# endif
#endif
#ifdef POOL_ASAN
# include <sanitizer/asan_interface.h>
# define POOL_REDZONE 16 /* >= the largest pool-relative underflow we guard */
# define POOL_POISON(p,n) ASAN_POISON_MEMORY_REGION((p), (n))
# define POOL_UNPOISON(p,n) ASAN_UNPOISON_MEMORY_REGION((p), (n))
#else
# define POOL_POISON(p,n) ((void)0)
# define POOL_UNPOISON(p,n) ((void)0)
#endif
alloc_pool_t
pool_create(size_t size, size_t quantum, void (*bomb)(const char*, const char*, int), int flags)
{
@@ -165,7 +191,18 @@ pool_alloc(alloc_pool_t p, size_t len, const char *bomb_msg)
pool->extents->free -= len;
return PTR_ADD(pool->extents->start, pool->extents->free);
{
void *ret = PTR_ADD(pool->extents->start, pool->extents->free);
#ifdef POOL_ASAN
size_t rz = pool->extents->free < POOL_REDZONE
? pool->extents->free : POOL_REDZONE;
if (rz) {
pool->extents->free -= rz;
POOL_POISON(PTR_ADD(pool->extents->start, pool->extents->free), rz);
}
#endif
return ret;
}
bomb_out:
if (pool->bomb)
@@ -215,6 +252,10 @@ pool_free(alloc_pool_t p, size_t len, void *addr)
if (!cur)
return;
/* This extent's space may be reused (and POOL_CLEAR may memset it)
* below, so drop any redzones in it first. */
POOL_UNPOISON(cur->start, pool->size);
if (!prev) {
/* The "live" extent is kept ready for more allocations. */
if (cur->free + cur->bound + len >= pool->size) {
@@ -272,6 +313,8 @@ pool_free_old(alloc_pool_t p, void *addr)
if (!cur)
return;
POOL_UNPOISON(cur->start, pool->size);
if (addr == PTR_ADD(cur->start, cur->free)) {
if (prev) {
prev->next = NULL;
+151 -7
View File
@@ -180,6 +180,26 @@ int sys_acl_free_acl(SMB_ACL_T the_acl)
return acl_free(the_acl);
}
#ifdef HAVE_LIBACL_AT
/* Dirfd/AT-flag ACL ops via the new libacl,
* race-safe on every Linux kernel. at_flags is AT_SYMLINK_NOFOLLOW (dirfd+leaf)
* or AT_EMPTY_PATH (operate on an open fd passed as dirfd, path ""). */
SMB_ACL_T sys_acl_get_file_at(int dirfd, const char *path_p, int at_flags, SMB_ACL_TYPE_T type)
{
return acl_get_file_at(dirfd, path_p, at_flags, type);
}
int sys_acl_set_file_at(int dirfd, const char *path_p, int at_flags, SMB_ACL_TYPE_T type, SMB_ACL_T theacl)
{
return acl_set_file_at(dirfd, path_p, at_flags, type, theacl);
}
int sys_acl_delete_def_file_at(int dirfd, const char *path_p, int at_flags)
{
return acl_delete_def_file_at(dirfd, path_p, at_flags);
}
#endif /* HAVE_LIBACL_AT */
#elif defined(HAVE_TRU64_ACLS) /*--------------------------------------------*/
/*
* The interface to DEC/Compaq Tru64 UNIX ACLs
@@ -479,12 +499,20 @@ SMB_ACL_T sys_acl_get_file(const char *path_p, SMB_ACL_TYPE_T type)
return acl_d;
}
#if 0
SMB_ACL_T sys_acl_get_fd(int fd)
#ifdef HAVE_SOLARIS_ACLS
/* facl(2)-based ACL read on a held fd (no path re-resolution). Solaris stores
* the access and default ACLs as one combined ACL; split out the requested half. */
SMB_ACL_T sys_acl_get_fd_type(int fd, SMB_ACL_TYPE_T type)
{
SMB_ACL_T acl_d;
int count; /* # of ACL entries allocated */
int naccess; /* # of access ACL entries */
int ndefault; /* # of default ACL entries */
if (type != SMB_ACL_TYPE_ACCESS && type != SMB_ACL_TYPE_DEFAULT) {
errno = EINVAL;
return NULL;
}
count = INITIAL_ACL_SIZE;
if ((acl_d = sys_acl_init(count)) == NULL) {
@@ -511,17 +539,39 @@ SMB_ACL_T sys_acl_get_fd(int fd)
}
/*
* calculate the number of access ACL entries
* calculate the number of access and default ACL entries
*/
for (naccess = 0; naccess < count; naccess++) {
if (acl_d->acl[naccess].a_type & ACL_DEFAULT)
break;
}
ndefault = count - naccess;
acl_d->count = naccess;
if (type == SMB_ACL_TYPE_DEFAULT) {
int i, j;
/*
* Default ACL entries follow the access entries in the combined
* Solaris ACL; move them to the front of the wrapper and clear
* ACL_DEFAULT so the caller sees a plain default ACL.
*/
for (i = 0, j = naccess; i < ndefault; i++, j++) {
acl_d->acl[i] = acl_d->acl[j];
acl_d->acl[i].a_type &= ~ACL_DEFAULT;
}
acl_d->count = ndefault;
} else {
acl_d->count = naccess;
}
return acl_d;
}
SMB_ACL_T sys_acl_get_fd(int fd)
{
return sys_acl_get_fd_type(fd, SMB_ACL_TYPE_ACCESS);
}
#endif
int sys_acl_get_info(SMB_ACL_ENTRY_T entry, SMB_ACL_TAG_T *tag_type_p, uint32 *bits_p, id_t *u_g_id_p)
@@ -728,14 +778,108 @@ int sys_acl_set_file(const char *name, SMB_ACL_TYPE_T type, SMB_ACL_T acl_d)
return ret;
}
#if 0
int sys_acl_set_fd(int fd, SMB_ACL_T acl_d)
#ifdef HAVE_SOLARIS_ACLS
/* facl(2)-based ACL write on a held fd (no path re-resolution). Setting an ACL
* on a directory replaces the combined access+default set, so for a dir read the
* other half through the fd, merge, and write the combined ACL back. Mirrors the
* path-based sys_acl_set_file() below. */
int sys_acl_set_fd_type(int fd, SMB_ACL_TYPE_T type, SMB_ACL_T acl_d)
{
struct stat s;
struct acl *acl_p;
int acl_count;
struct acl *acl_buf = NULL;
int ret;
if (type != SMB_ACL_TYPE_ACCESS && type != SMB_ACL_TYPE_DEFAULT) {
errno = EINVAL;
return -1;
}
if (acl_sort(acl_d) != 0) {
return -1;
}
return facl(fd, SETACL, acl_d->count, &acl_d->acl[0]);
acl_p = &acl_d->acl[0];
acl_count = acl_d->count;
if (fstat(fd, &s) != 0) {
return -1;
}
if (S_ISDIR(s.st_mode)) {
SMB_ACL_T acc_acl;
SMB_ACL_T def_acl;
SMB_ACL_T tmp_acl;
int i;
if (type == SMB_ACL_TYPE_ACCESS) {
acc_acl = acl_d;
def_acl = tmp_acl = sys_acl_get_fd_type(fd, SMB_ACL_TYPE_DEFAULT);
} else {
def_acl = acl_d;
acc_acl = tmp_acl = sys_acl_get_fd_type(fd, SMB_ACL_TYPE_ACCESS);
}
if (tmp_acl == NULL) {
return -1;
}
acl_count = acc_acl->count + def_acl->count;
acl_p = acl_buf = SMB_MALLOC_ARRAY(struct acl, acl_count);
if (acl_buf == NULL) {
sys_acl_free_acl(tmp_acl);
errno = ENOMEM;
return -1;
}
/* Concatenate access + default, then mark the default half. */
memcpy(&acl_buf[0], &acc_acl->acl[0],
acc_acl->count * sizeof acl_buf[0]);
memcpy(&acl_buf[acc_acl->count], &def_acl->acl[0],
def_acl->count * sizeof acl_buf[0]);
for (i = acc_acl->count; i < acl_count; i++) {
acl_buf[i].a_type |= ACL_DEFAULT;
}
sys_acl_free_acl(tmp_acl);
} else if (type != SMB_ACL_TYPE_ACCESS) {
errno = EINVAL;
return -1;
}
ret = facl(fd, SETACL, acl_count, acl_p);
SAFE_FREE(acl_buf);
return ret;
}
int sys_acl_set_fd(int fd, SMB_ACL_T acl_d)
{
return sys_acl_set_fd_type(fd, SMB_ACL_TYPE_ACCESS, acl_d);
}
int sys_acl_delete_def_fd(int fd)
{
SMB_ACL_T acl_d;
int ret;
/*
* Fetching the access ACL through the fd and rewriting it deletes the
* default ACL, without re-resolving the path.
*/
if ((acl_d = sys_acl_get_fd_type(fd, SMB_ACL_TYPE_ACCESS)) == NULL) {
return -1;
}
ret = facl(fd, SETACL, acl_d->count, acl_d->acl);
sys_acl_free_acl(acl_d);
return ret;
}
#endif
+11
View File
@@ -301,7 +301,18 @@ int sys_acl_valid(SMB_ACL_T theacl);
int sys_acl_set_file(const char *name, SMB_ACL_TYPE_T acltype, SMB_ACL_T theacl);
int sys_acl_set_fd(int fd, SMB_ACL_T theacl);
int sys_acl_delete_def_file(const char *name);
#ifdef HAVE_SOLARIS_ACLS
SMB_ACL_T sys_acl_get_fd_type(int fd, SMB_ACL_TYPE_T type);
int sys_acl_set_fd_type(int fd, SMB_ACL_TYPE_T type, SMB_ACL_T theacl);
int sys_acl_delete_def_fd(int fd);
#endif
int sys_acl_free_acl(SMB_ACL_T the_acl);
int no_acl_syscall_error(int err);
#ifdef HAVE_LIBACL_AT
SMB_ACL_T sys_acl_get_file_at(int dirfd, const char *path_p, int at_flags, SMB_ACL_TYPE_T type);
int sys_acl_set_file_at(int dirfd, const char *path_p, int at_flags, SMB_ACL_TYPE_T type, SMB_ACL_T theacl);
int sys_acl_delete_def_file_at(int dirfd, const char *path_p, int at_flags);
#endif
#endif /* SUPPORT_ACLS */
+152 -27
View File
@@ -45,16 +45,31 @@ int sys_lsetxattr(const char *path, const char *name, const void *value, size_t
return lsetxattr(path, name, value, size, 0);
}
int sys_fsetxattr(int filedes, const char *name, const void *value, size_t size)
{
return fsetxattr(filedes, name, value, size, 0);
}
int sys_lremovexattr(const char *path, const char *name)
{
return lremovexattr(path, name);
}
int sys_fremovexattr(int filedes, const char *name)
{
return fremovexattr(filedes, name);
}
ssize_t sys_llistxattr(const char *path, char *list, size_t size)
{
return llistxattr(path, list, size);
}
ssize_t sys_flistxattr(int filedes, char *list, size_t size)
{
return flistxattr(filedes, list, size);
}
#elif HAVE_OSX_XATTRS
ssize_t sys_lgetxattr(const char *path, const char *name, void *value, size_t size)
@@ -89,16 +104,31 @@ int sys_lsetxattr(const char *path, const char *name, const void *value, size_t
return setxattr(path, name, value, size, 0, XATTR_NOFOLLOW);
}
int sys_fsetxattr(int filedes, const char *name, const void *value, size_t size)
{
return fsetxattr(filedes, name, value, size, 0, 0);
}
int sys_lremovexattr(const char *path, const char *name)
{
return removexattr(path, name, XATTR_NOFOLLOW);
}
int sys_fremovexattr(int filedes, const char *name)
{
return fremovexattr(filedes, name, 0);
}
ssize_t sys_llistxattr(const char *path, char *list, size_t size)
{
return listxattr(path, list, size, XATTR_NOFOLLOW);
}
ssize_t sys_flistxattr(int filedes, char *list, size_t size)
{
return flistxattr(filedes, list, size, 0);
}
#elif HAVE_FREEBSD_XATTRS
ssize_t sys_lgetxattr(const char *path, const char *name, void *value, size_t size)
@@ -116,27 +146,46 @@ int sys_lsetxattr(const char *path, const char *name, const void *value, size_t
return extattr_set_link(path, EXTATTR_NAMESPACE_USER, name, value, size);
}
int sys_fsetxattr(int filedes, const char *name, const void *value, size_t size)
{
return extattr_set_fd(filedes, EXTATTR_NAMESPACE_USER, name, value, size);
}
int sys_lremovexattr(const char *path, const char *name)
{
return extattr_delete_link(path, EXTATTR_NAMESPACE_USER, name);
}
ssize_t sys_llistxattr(const char *path, char *list, size_t size)
int sys_fremovexattr(int filedes, const char *name)
{
return extattr_delete_fd(filedes, EXTATTR_NAMESPACE_USER, name);
}
/* Turn the FreeBSD extattr_list_xx() output (a single length byte before each
* name, no '\0' terminator) into the series of null-terminated strings that the
* rest of rsync expects. Since the size is unchanged, transform in place.
* Shared by the path and fd list variants. */
static ssize_t freebsd_list_finish(char *list, size_t size, ssize_t len)
{
unsigned char keylen;
ssize_t off, len = extattr_list_link(path, EXTATTR_NAMESPACE_USER, list, size);
ssize_t off;
if (len <= 0 || (size_t)len > size)
if (len <= 0 || size == 0)
return len;
/* FreeBSD puts a single-byte length before each string, with no '\0'
* terminator. We need to change this into a series of null-terminted
* strings. Since the size is the same, we can simply transform the
* output in place. */
if ((size_t)len >= size) {
/* FreeBSD extattr_list_xx() returns 'size' as 'len' in case there are
more data available, truncating the output, we solve this by signalling
ERANGE in case len == size so that the code in xattrs.c will retry with
a bigger buffer */
errno = ERANGE;
return -1;
}
for (off = 0; off < len; off += keylen + 1) {
keylen = ((unsigned char*)list)[off];
if (off + keylen >= len) {
/* Should be impossible, but kernel bugs happen! */
/* Should be impossible, but bugs happen! */
errno = EINVAL;
return -1;
}
@@ -147,6 +196,18 @@ ssize_t sys_llistxattr(const char *path, char *list, size_t size)
return len;
}
ssize_t sys_llistxattr(const char *path, char *list, size_t size)
{
return freebsd_list_finish(list, size,
extattr_list_link(path, EXTATTR_NAMESPACE_USER, list, size));
}
ssize_t sys_flistxattr(int filedes, char *list, size_t size)
{
return freebsd_list_finish(list, size,
extattr_list_fd(filedes, EXTATTR_NAMESPACE_USER, list, size));
}
#elif HAVE_SOLARIS_XATTRS
static ssize_t read_xattr(int attrfd, void *buf, size_t buflen)
@@ -208,29 +269,59 @@ ssize_t sys_fgetxattr(int filedes, const char *name, void *value, size_t size)
return read_xattr(attrfd, value, size);
}
int sys_lsetxattr(const char *path, const char *name, const void *value, size_t size)
/* Write a datum to the already-opened attribute fd, closing it. Shared by the
* path- and fd-keyed setters below. */
static int write_xattr(int attrfd, const void *value, size_t size)
{
int attrfd;
size_t bufpos;
mode_t mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP;
if ((attrfd = attropen(path, name, O_CREAT|O_TRUNC|O_WRONLY, mode)) < 0)
return -1;
int ret = 0, saved_errno = 0;
for (bufpos = 0; bufpos < size; ) {
ssize_t cnt = write(attrfd, (char*)value + bufpos, size);
if (cnt <= 0) {
if (cnt < 0 && errno == EINTR)
ssize_t cnt = write(attrfd, (const char *)value + bufpos, size - bufpos);
if (cnt < 0) {
if (errno == EINTR)
continue;
bufpos = -1;
ret = -1;
saved_errno = errno;
break;
}
if (cnt == 0) {
ret = -1;
saved_errno = EIO;
break;
}
bufpos += cnt;
}
close(attrfd);
/* Don't let close() clobber the write error; do report a close() failure. */
if (close(attrfd) < 0 && ret == 0)
return -1;
if (ret < 0 && saved_errno)
errno = saved_errno;
return bufpos > 0 ? 0 : -1;
return ret;
}
int sys_lsetxattr(const char *path, const char *name, const void *value, size_t size)
{
int attrfd;
mode_t mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP;
if ((attrfd = attropen(path, name, O_CREAT|O_TRUNC|O_WRONLY, mode)) < 0)
return -1;
return write_xattr(attrfd, value, size);
}
int sys_fsetxattr(int filedes, const char *name, const void *value, size_t size)
{
int attrfd;
mode_t mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP;
if ((attrfd = openat(filedes, name, O_CREAT|O_TRUNC|O_WRONLY|O_XATTR, mode)) < 0)
return -1;
return write_xattr(attrfd, value, size);
}
int sys_lremovexattr(const char *path, const char *name)
@@ -248,18 +339,29 @@ int sys_lremovexattr(const char *path, const char *name)
return ret;
}
ssize_t sys_llistxattr(const char *path, char *list, size_t size)
int sys_fremovexattr(int filedes, const char *name)
{
int attrdirfd;
int ret;
if ((attrdirfd = openat(filedes, ".", O_RDONLY|O_XATTR, 0)) < 0)
return -1;
ret = unlinkat(attrdirfd, name, 0);
close(attrdirfd);
return ret;
}
/* List the names in an already-opened attribute-dir fd, consuming it. Shared
* by the path- and fd-keyed listers below. */
static ssize_t list_xattr(int attrdirfd, char *list, size_t size)
{
DIR *dirp;
struct dirent *dp;
ssize_t ret = 0;
if ((attrdirfd = attropen(path, ".", O_RDONLY)) < 0) {
errno = ENOTSUP;
return -1;
}
if ((dirp = fdopendir(attrdirfd)) == NULL) {
close(attrdirfd);
return -1;
@@ -287,11 +389,34 @@ ssize_t sys_llistxattr(const char *path, char *list, size_t size)
}
closedir(dirp);
close(attrdirfd);
return ret;
}
ssize_t sys_llistxattr(const char *path, char *list, size_t size)
{
int attrdirfd;
if ((attrdirfd = attropen(path, ".", O_RDONLY)) < 0) {
errno = ENOTSUP;
return -1;
}
return list_xattr(attrdirfd, list, size);
}
ssize_t sys_flistxattr(int filedes, char *list, size_t size)
{
int attrdirfd;
if ((attrdirfd = openat(filedes, ".", O_RDONLY|O_XATTR, 0)) < 0) {
errno = ENOTSUP;
return -1;
}
return list_xattr(attrdirfd, list, size);
}
#else
#error You need to create xattr compatibility functions.
+3
View File
@@ -16,8 +16,11 @@
ssize_t sys_lgetxattr(const char *path, const char *name, void *value, size_t size);
ssize_t sys_fgetxattr(int filedes, const char *name, void *value, size_t size);
int sys_lsetxattr(const char *path, const char *name, const void *value, size_t size);
int sys_fsetxattr(int filedes, const char *name, const void *value, size_t size);
int sys_lremovexattr(const char *path, const char *name);
int sys_fremovexattr(int filedes, const char *name);
ssize_t sys_llistxattr(const char *path, char *list, size_t size);
ssize_t sys_flistxattr(int filedes, char *list, size_t size);
#else
+15 -2
View File
@@ -89,6 +89,11 @@ static int dowild(const uchar *p, const uchar *text, const uchar*const *a)
p_ch = *++p;
/* FALLTHROUGH */
default:
/* iwildmatch() folds the text to lower case above; fold the pattern
* char too so matching is truly case-insensitive (not just text-side).
* Without this an upper-case "hosts deny" token fails OPEN. */
if (force_lower_case && ISUPPER(p_ch))
p_ch = tolower(p_ch);
if (t_ch != p_ch)
return FALSE;
continue;
@@ -150,6 +155,8 @@ static int dowild(const uchar *p, const uchar *text, const uchar*const *a)
p_ch = *++p;
if (!p_ch)
return ABORT_ALL;
if (force_lower_case && ISUPPER(p_ch))
p_ch = tolower(p_ch);
if (t_ch == p_ch)
matched = TRUE;
} else if (p_ch == '-' && prev_ch && p[1] && p[1] != ']') {
@@ -159,6 +166,8 @@ static int dowild(const uchar *p, const uchar *text, const uchar*const *a)
if (!p_ch)
return ABORT_ALL;
}
if (force_lower_case && ISUPPER(p_ch))
p_ch = tolower(p_ch);
if (t_ch <= p_ch && t_ch >= prev_ch)
matched = TRUE;
p_ch = 0; /* This makes "prev_ch" get set to 0. */
@@ -216,8 +225,12 @@ static int dowild(const uchar *p, const uchar *text, const uchar*const *a)
} else /* malformed [:class:] string */
return ABORT_ALL;
p_ch = 0; /* This makes "prev_ch" get set to 0. */
} else if (t_ch == p_ch)
matched = TRUE;
} else {
if (force_lower_case && ISUPPER(p_ch))
p_ch = tolower(p_ch);
if (t_ch == p_ch)
matched = TRUE;
}
} while (prev_ch = p_ch, (p_ch = *++p) != ']');
if (matched == special || t_ch == '/')
return FALSE;
+128 -9
View File
@@ -65,7 +65,7 @@ typedef enum {
struct enum_list {
int value;
char *name;
const char *name;
};
struct parm_struct {
@@ -73,7 +73,7 @@ struct parm_struct {
parm_type type;
parm_class class;
void *ptr;
struct enum_list *enum_list;
const struct enum_list *enum_list;
unsigned flags;
};
@@ -95,7 +95,7 @@ static item_list section_list = EMPTY_ITEM_LIST;
static int iSectionIndex = -1;
static BOOL bInGlobalSection = True;
static struct enum_list enum_syslog_facility[] = {
static const struct enum_list enum_syslog_facility[] = {
#ifdef LOG_AUTH
{ LOG_AUTH, "auth" },
#endif
@@ -164,11 +164,81 @@ static struct enum_list enum_syslog_facility[] = {
/* Expand %VAR% references. Any unknown vars or unrecognized
* syntax leaves the raw chars unchanged. */
static char *expand_vars(const char *str)
enum shell_quote_context {
SHELL_UNQUOTED,
SHELL_SINGLE_QUOTED,
SHELL_DOUBLE_QUOTED
};
/* Characters that can turn a substituted value into shell syntax rather than
* data, in any quoting context. Quoting alone cannot be relied on here:
* context-aware escaping is correct for exactly one level of shell parsing,
* and a hook such as `sh -c '... %RSYNC_USER_NAME% ...'` re-parses the word in
* a second shell that sees the value bare. Peer-supplied values carrying any
* of these are refused instead. */
static int shell_unsafe_value(const char *val)
{
const char *s;
for (s = val; *s; s++) {
/* '!' negates in command position (a hook `sh -c '%VAR% false'`
* becomes `! false` and reports success, inverting an access
* check); '~' is tilde-expanded; '{' and '}' brace-expand in
* bash and zsh. None of them execute anything on their own,
* which is why a set built from the obvious metacharacters
* missed them. */
if (strchr("'\"`$\\;&|<>()*?[]# !~{}", *s)
|| (unsigned char)*s < 0x20 || (unsigned char)*s == 0x7f)
return 1;
}
return 0;
}
static char *expand_vars_shell_escape(const char *val, int quote_context)
{
const char *s;
char *ret, *t;
/* A double-quoted value is deliberately BOTH backslash-escaped and
* wrapped in single quotes. The wrap is redundant for one level of
* shell parsing (and shows up as literal quotes in the value), but a
* hook such as `sh -c "... %RSYNC_USER_NAME% ..."` re-parses the word
* in a second shell, where the backslashes are already gone and only
* the quotes still protect it. */
size_t len = quote_context == SHELL_SINGLE_QUOTED ? 0 : 2;
for (s = val; *s; s++) {
if (quote_context == SHELL_DOUBLE_QUOTED
&& strchr("\\\"`$", *s))
len += 2;
else
len += *s == '\'' ? 4 : 1;
}
ret = new_array(char, len + 1);
t = ret;
if (quote_context != SHELL_SINGLE_QUOTED)
*t++ = '\'';
for (s = val; *s; s++) {
if (quote_context == SHELL_DOUBLE_QUOTED
&& strchr("\\\"`$", *s)) {
*t++ = '\\';
*t++ = *s;
} else if (*s == '\'') {
memcpy(t, "'\\''", 4);
t += 4;
} else
*t++ = *s;
}
if (quote_context != SHELL_SINGLE_QUOTED)
*t++ = '\'';
*t = '\0';
return ret;
}
static char *expand_vars(const char *str, int shell_escape)
{
char *buf, *t;
const char *f;
int bufsize;
int bufsize, quote_context = SHELL_UNQUOTED, escaped_char = 0;
if (!str || !strchr(str, '%'))
return (char *)str; /* TODO change return value to const char* at some point. */
@@ -178,13 +248,35 @@ static char *expand_vars(const char *str)
for (t = buf, f = str; bufsize && *f; ) {
if (*f == '%' && isUpper(f+1)) {
char *percent = strchr(f+1, '%');
const char *percent = strchr(f+1, '%');
if (percent && percent - f < bufsize) {
char *val;
strlcpy(t, f+1, percent - f);
val = getenv(t);
if (val) {
int len = strlcpy(t, val, bufsize+1);
char *escaped = NULL;
int len;
/* %RSYNC_*% values originate from the peer request/args.
* When the result is fed to a shell-executed hook, escape it
* for the template's current shell quote context so a value
* containing shell metacharacters can't inject. For ordinary string
* params (path, uid, gid, ...) leave them verbatim --
* quoting there would corrupt the value (e.g. a documented
* `path = /home/%RSYNC_USER_NAME%` would become /home/'x'). */
if (shell_escape && strncmp(t, "RSYNC_", 6) == 0) {
if (shell_unsafe_value(val)) {
/* Fail closed: the hook may be an access
* check, so skipping it is not an option. */
rprintf(FLOG,
"refusing to run shell hook: %%%s%% holds a shell metacharacter\n",
t);
exit_cleanup(RERR_UNSUPPORTED);
}
val = escaped = expand_vars_shell_escape(val, quote_context);
}
len = strlcpy(t, val, bufsize+1);
if (escaped)
free(escaped);
if (len > bufsize)
break;
bufsize -= len;
@@ -194,6 +286,28 @@ static char *expand_vars(const char *str)
}
}
}
if (shell_escape) {
if (quote_context == SHELL_SINGLE_QUOTED) {
/* Nothing is special inside '...', not even a backslash;
* only the closing quote ends it. */
if (*f == '\'')
quote_context = SHELL_UNQUOTED;
} else if (escaped_char)
escaped_char = 0;
else if (*f == '\\')
escaped_char = 1;
else if (quote_context == SHELL_DOUBLE_QUOTED) {
/* A single quote inside "..." is literal and must not be
* taken as opening a single-quoted run -- doing so would
* de-sync the tracker and escape a later value for the
* wrong context. */
if (*f == '"')
quote_context = SHELL_UNQUOTED;
} else if (*f == '\'')
quote_context = SHELL_SINGLE_QUOTED;
else if (*f == '"')
quote_context = SHELL_DOUBLE_QUOTED;
}
*t++ = *f++;
bufsize--;
}
@@ -213,7 +327,10 @@ static char *expand_vars(const char *str)
/* Each "char* foo" has an associated "BOOL foo_EXP" that tracks if the string has been expanded yet or not. */
/* NOTE: use this function and all the FN_{GLOBAL,LOCAL} ones WITHOUT a trailing semicolon! */
#define RETURN_EXPANDED(val) {if (!val ## _EXP) {val = expand_vars(val); val ## _EXP = True;} return val ? val : "";}
#define RETURN_EXPANDED(val) {if (!val ## _EXP) {val = expand_vars(val, 0); val ## _EXP = True;} return val ? val : "";}
/* Variant for params whose expansion is fed to a shell-executed hook: quote
* %RSYNC_*% peer-controlled values to prevent shell injection. */
#define RETURN_EXPANDED_SHELL(val) {if (!val ## _EXP) {val = expand_vars(val, 1); val ## _EXP = True;} return val ? val : "";}
/* In this section all the functions that are used to access the
* parameters from the rest of the program are defined. */
@@ -229,6 +346,8 @@ static char *expand_vars(const char *str)
#define FN_LOCAL_STRING(fn_name, val) \
char *fn_name(int i) {if (LP_SNUM_OK(i) && iSECTION(i).val) RETURN_EXPANDED(iSECTION(i).val) else RETURN_EXPANDED(Vars.l.val)}
#define FN_LOCAL_STRING_SHELL(fn_name, val) \
char *fn_name(int i) {if (LP_SNUM_OK(i) && iSECTION(i).val) RETURN_EXPANDED_SHELL(iSECTION(i).val) else RETURN_EXPANDED_SHELL(Vars.l.val)}
#define FN_LOCAL_BOOL(fn_name, val) \
BOOL fn_name(int i) {return LP_SNUM_OK(i)? iSECTION(i).val : Vars.l.val;}
#define FN_LOCAL_CHAR(fn_name, val) \
@@ -410,7 +529,7 @@ static BOOL do_parameter(char *parmname, char *parmvalue)
break;
default:
/* expand any %VAR% strings now */
parmvalue = expand_vars(parmvalue);
parmvalue = expand_vars(parmvalue, 0);
break;
}
+70 -18
View File
@@ -22,6 +22,7 @@
#include "rsync.h"
#include "itypes.h"
#include "inums.h"
#include "rounding.h" /* EXTRA_ROUNDING, so log_delete() aligns its file_struct */
extern int dry_run;
extern int am_daemon;
@@ -54,7 +55,6 @@ extern iconv_t ic_chck;
#ifdef ICONV_OPTION
extern iconv_t ic_recv;
#endif
extern char curr_dir[MAXPATHLEN];
extern char *full_module_path;
extern unsigned int module_dirlen;
extern char sender_file_sum[MAX_DIGEST_LEN];
@@ -119,12 +119,20 @@ static char const *rerr_name(int code)
return NULL;
}
static void filtered_fwrite(FILE *f, const char *in_buf, int in_len, int use_isprint, int escape_c1, char end_char);
static void logit(int priority, const char *buf)
{
if (logfile_was_closed)
logfile_reopen();
if (logfile_fp) {
fprintf(logfile_fp, "%s [%d] %s", timestring(time(NULL)), (int)getpid(), buf);
/* Escape control chars in the message so an attacker-controlled
* filename can't inject terminal escapes into the log an admin later
* cat's (CWE-117); keep the trailing newline raw via end_char. */
int len = strlen(buf);
char trailing = len && (buf[len-1] == '\n' || buf[len-1] == '\r') ? buf[--len] : '\0';
fprintf(logfile_fp, "%s [%d] ", timestring(time(NULL)), (int)getpid());
filtered_fwrite(logfile_fp, buf, len, 0, 1, trailing);
fflush(logfile_fp);
} else {
syslog(priority, "%s", buf);
@@ -153,7 +161,15 @@ static void syslog_init()
static void logfile_open(void)
{
mode_t old_umask = umask(022 | orig_umask);
logfile_fp = fopen(logfile_name, "a");
/* --log-file/`log file =` are operator-supplied paths that may transit
* attacker-writable dirs; a planted symlink could redirect root's log
* into e.g. /root/.ssh/authorized_keys. Refuse symlinks not owned by
* uid 0 or our euid. */
int fd = vfs_open_owner_walk(logfile_name,
O_WRONLY | O_APPEND | O_CREAT, 0644, 0);
logfile_fp = fd >= 0 ? fdopen(fd, "a") : NULL;
if (!logfile_fp && fd >= 0)
close(fd);
umask(old_umask);
if (!logfile_fp) {
int fopen_errno = errno;
@@ -222,7 +238,7 @@ void logfile_reopen(void)
}
}
static void filtered_fwrite(FILE *f, const char *in_buf, int in_len, int use_isprint, char end_char)
static void filtered_fwrite(FILE *f, const char *in_buf, int in_len, int use_isprint, int escape_c1, char end_char)
{
char outbuf[1024], *ob = outbuf;
const char *end = in_buf + in_len;
@@ -234,7 +250,8 @@ static void filtered_fwrite(FILE *f, const char *in_buf, int in_len, int use_isp
}
if ((in_buf < end - 4 && *in_buf == '\\' && in_buf[1] == '#'
&& isDigit(in_buf + 2) && isDigit(in_buf + 3) && isDigit(in_buf + 4))
|| (*in_buf != '\t' && ((use_isprint && !isPrint(in_buf)) || *(uchar*)in_buf < ' ')))
|| (*in_buf != '\t' && ((use_isprint && !isPrint(in_buf)) || *(uchar*)in_buf < ' '
|| (escape_c1 && *(uchar*)in_buf >= 0x80 && *(uchar*)in_buf <= 0x9f))))
ob += snprintf(ob, 6, "\\#%03o", *(uchar*)in_buf++);
else
*ob++ = *in_buf++;
@@ -272,8 +289,12 @@ void rwrite(enum logcode code, const char *buf, int len, int is_utf8)
if (am_daemon > 0 && code != FCLIENT)
code = FLOG;
} else if (send_msgs_to_gen) {
assert(!is_utf8);
/* Pass the message to our sibling in native charset. */
/* Pass the message to our sibling in native charset. is_utf8
* may be set here if a malicious peer sends MSG_INFO/MSG_ERROR
* to a daemon receiver (read_a_msg passes !am_generator); the
* old assert(!is_utf8) made that a remotely-reachable abort.
* Forwarding the bytes raw is safe -- the generator's rwrite()
* gets is_utf8=0 and filtered_fwrite escapes non-printables. */
send_msg((enum msgcode)code, buf, len, 0);
return;
}
@@ -297,7 +318,12 @@ void rwrite(enum logcode code, const char *buf, int len, int is_utf8)
in_block = 1;
if (!log_initialised)
log_init(0);
strlcpy(msg, buf, MIN((int)sizeof msg, len + 1));
/* buf holds exactly len bytes and is not necessarily NUL-terminated
* (e.g. a forwarded MSG_* payload from read_a_msg), so copy by length
* rather than strlcpy(), which would strlen() past the end of buf. */
int mlen = MIN((int)sizeof msg - 1, len);
memcpy(msg, buf, mlen);
msg[mlen] = '\0';
logit(priority, msg);
in_block = 0;
@@ -372,7 +398,7 @@ void rwrite(enum logcode code, const char *buf, int len, int is_utf8)
ierrno = errno;
if (outbuf.len) {
char trailing = inbuf.len ? '\0' : trailing_CR_or_NL;
filtered_fwrite(f, convbuf, outbuf.len, 0, trailing);
filtered_fwrite(f, convbuf, outbuf.len, 0, 0, trailing);
if (trailing) {
trailing_CR_or_NL = '\0';
fflush(f);
@@ -395,7 +421,7 @@ void rwrite(enum logcode code, const char *buf, int len, int is_utf8)
} else
#endif
{
filtered_fwrite(f, buf, len, !allow_8bit_chars, trailing_CR_or_NL);
filtered_fwrite(f, buf, len, !allow_8bit_chars, 0, trailing_CR_or_NL);
if (trailing_CR_or_NL)
fflush(f);
}
@@ -456,11 +482,17 @@ void rsyserr(enum logcode code, int errcode, const char *format, ...)
char buf[BIGPATHBUFLEN];
size_t len;
/* snprintf returns the would-have-been length on truncation, so
* each cumulative call must be guarded; if not, sizeof buf - len
* can underflow when promoted to size_t and the next call writes
* past the buffer. */
len = snprintf(buf, sizeof buf, RSYNC_NAME ": [%s] ", who_am_i());
va_start(ap, format);
len += vsnprintf(buf + len, sizeof buf - len, format, ap);
va_end(ap);
if (len < sizeof buf) {
va_start(ap, format);
len += vsnprintf(buf + len, sizeof buf - len, format, ap);
va_end(ap);
}
if (len < sizeof buf) {
len += snprintf(buf + len, sizeof buf - len,
@@ -493,12 +525,17 @@ void remember_initial_stats(void)
initial_data_written = total_data_written;
}
/* Size of log_formatted()'s per-escape "fmt" scratch buffer. log_format_has()
* must bound its width-digit scan to the same limit so the two parsers agree on
* where an escape letter falls (see the digit loop in each). */
#define LOG_FMT_SIZE 32
/* A generic logging routine for send/recv, with parameter substitiution. */
static void log_formatted(enum logcode code, const char *format, const char *op,
struct file_struct *file, const char *fname, int iflags,
const char *hlink)
{
char buf[MAXPATHLEN+1024], buf2[MAXPATHLEN], fmt[32];
char buf[MAXPATHLEN+1024], buf2[MAXPATHLEN], fmt[LOG_FMT_SIZE];
char *p, *s, *c;
const char *n;
size_t len, total;
@@ -610,7 +647,7 @@ static void log_formatted(enum logcode code, const char *format, const char *op,
n = buf2;
} else if (am_daemon && *c != '/') {
pathjoin(buf2, sizeof buf2,
curr_dir + module_dirlen, c);
vfs.curr_dir + module_dirlen, c);
clean_fname(buf2, 0);
if (fmt[1]) {
strlcpy(c, buf2, MAXPATHLEN);
@@ -679,7 +716,7 @@ static void log_formatted(enum logcode code, const char *format, const char *op,
case 'C':
n = NULL;
if (S_ISREG(file->mode)) {
if (always_checksum)
if (always_checksum && !(iflags & ITEM_DELETED))
n = sum_as_hex(file_sum_nni->num, F_SUM(file), 1);
else if (iflags & ITEM_TRANSFER)
n = sum_as_hex(xfer_sum_nni->num, sender_file_sum, 0);
@@ -744,6 +781,9 @@ static void log_formatted(enum logcode code, const char *format, const char *op,
}
}
break;
case '%':
n = "%";
break;
}
/* "n" is the string to be inserted in place of this % code. */
@@ -787,21 +827,33 @@ static void log_formatted(enum logcode code, const char *format, const char *op,
int log_format_has(const char *format, char esc)
{
const char *p;
int width;
if (!format)
return 0;
for (p = format; (p = strchr(p, '%')) != NULL; ) {
for (p++; *p == '\''; p++) {} /*SHARED ITERATOR*/
if (*p == '-')
/* Mirror log_formatted()'s width-digit scan exactly (c starts at
* fmt+1, so width starts at 1): both must stop at the same digit
* or they disagree on where the escape letter is, which for %C
* can leave sender_keeps_checksum unset and over-read F_SUM. */
width = 1;
if (*p == '-') {
p++;
while (isDigit(p))
width++;
}
while (isDigit(p) && width < LOG_FMT_SIZE - 8) {
p++;
width++;
}
while (*p == '\'') p++;
if (!*p)
break;
if (*p == esc)
return 1;
if (*p == '%') /* %% is a literal '%', not the start of an escape */
p++;
}
return 0;
}
+149 -45
View File
@@ -31,6 +31,9 @@
#ifdef __TANDEM
#include <floss.h(floss_execlp)>
#endif
#ifdef HAVE_SYS_RESOURCE_H
#include <sys/resource.h>
#endif
extern int dry_run;
extern int list_only;
@@ -48,6 +51,7 @@ extern int called_from_signal_handler;
extern int need_messages_from_generator;
extern int kluge_around_eof;
extern int got_xfer_error;
extern volatile sig_atomic_t got_sigusr2;
extern int old_style_args;
extern int msgs2stderr;
extern int module_id;
@@ -66,7 +70,6 @@ extern int protect_args;
extern int relative_paths;
extern int sanitize_paths;
extern int curr_dir_depth;
extern unsigned int curr_dir_len;
extern int module_id;
extern int rsync_port;
extern int whole_file;
@@ -102,7 +105,6 @@ extern char *password_file;
extern char *backup_dir;
extern char *copy_as;
extern char *tmpdir;
extern char curr_dir[MAXPATHLEN];
extern char backup_dir_buf[MAXPATHLEN];
extern char *basis_dir[MAX_BASIS_DIRS+1];
extern struct file_list *first_flist;
@@ -239,11 +241,11 @@ void write_del_stats(int f)
void read_del_stats(int f)
{
stats.deleted_files = read_varint(f);
stats.deleted_files += stats.deleted_dirs = read_varint(f);
stats.deleted_files += stats.deleted_symlinks = read_varint(f);
stats.deleted_files += stats.deleted_devices = read_varint(f);
stats.deleted_files += stats.deleted_specials = read_varint(f);
stats.deleted_files = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_files");
stats.deleted_files += stats.deleted_dirs = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_dirs");
stats.deleted_files += stats.deleted_symlinks = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_symlinks");
stats.deleted_files += stats.deleted_devices = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_devices");
stats.deleted_files += stats.deleted_specials = read_varint_bounded(f, 0, MAX_WIRE_DEL_STAT, "deleted_specials");
}
static void become_copy_as_user()
@@ -386,7 +388,7 @@ static void handle_stats(int f)
static void output_itemized_counts(const char *prefix, int *counts)
{
static char *labels[] = { "reg", "dir", "link", "dev", "special" };
static char *const labels[] = { "reg", "dir", "link", "dev", "special" };
char buf[1024], *pre = " (";
int j, len = 0;
int total = counts[0];
@@ -394,9 +396,18 @@ static void output_itemized_counts(const char *prefix, int *counts)
counts[0] -= counts[1] + counts[2] + counts[3] + counts[4];
for (j = 0; j < 5; j++) {
if (counts[j]) {
/* snprintf can return more than its size arg
* on truncation; keep len <= sizeof buf - 2 so
* the closing ')' and trailing NUL always
* have room and the next iteration's
* sizeof buf - len - 2 cannot underflow. */
if (len >= (int)sizeof buf - 2)
break;
len += snprintf(buf+len, sizeof buf - len - 2,
"%s%s: %s",
pre, labels[j], comma_num(counts[j]));
if (len > (int)sizeof buf - 2)
len = (int)sizeof buf - 2;
pre = ", ";
}
}
@@ -703,41 +714,45 @@ static char *get_local_name(struct file_list *flist, char *dest_path)
dest_path = dot_dir_or_error();
if (daemon_filter_list.head) {
char *slash = strrchr(dest_path, '/');
/* Collapse ".." for the NAME-based daemon filter check so a "../excluded"
* destination is matched by name, as stock rsync does on its sanitized
* arg. Done on a copy: the daemon exclude/filter is name-based (a symlink
* whose own name is not excluded is still followed -- see rsyncd.conf(5)
* "munge symlinks"), and the real dest_path is left for the resolver. */
char cleaned[MAXPATHLEN], *slash;
if (!sanitize_path(cleaned, dest_path, NULL, 0, SP_KEEP_DOT_DIRS))
strlcpy(cleaned, dest_path, sizeof cleaned);
slash = strrchr(cleaned, '/');
if (slash && (slash[1] == '\0' || (slash[1] == '.' && slash[2] == '\0')))
*slash = '\0';
else
slash = NULL;
if ((*dest_path != '.' || dest_path[1] != '\0')
&& (check_filter(&daemon_filter_list, FLOG, dest_path, 0) < 0
|| check_filter(&daemon_filter_list, FLOG, dest_path, 1) < 0)) {
if ((*cleaned != '.' || cleaned[1] != '\0')
&& (check_filter(&daemon_filter_list, FLOG, cleaned, 0) < 0
|| check_filter(&daemon_filter_list, FLOG, cleaned, 1) < 0)) {
rprintf(FERROR, "ERROR: daemon has excluded destination \"%s\"\n",
dest_path);
exit_cleanup(RERR_FILESELECT);
}
if (slash)
*slash = '/';
}
/* See what currently exists at the destination. */
statret = do_stat(dest_path, &st);
statret = vfs_stat(VFS_AT_FDCWD, dest_path, &st, VFS_ALLOW_SYMLINK);
cp = strrchr(dest_path, '/');
trailing_slash = cp && !cp[1];
if (mkpath_dest_arg && statret < 0 && (cp || file_total > 1)) {
int save_errno = errno;
int ret = make_path(dest_path, file_total > 1 && !trailing_slash ? 0 : MKP_DROP_NAME);
int ret = vfs_make_path(dest_path, file_total > 1 && !trailing_slash ? 0 : MKP_DROP_NAME, 0);
if (ret < 0)
goto mkdir_error;
if (ret && (INFO_GTE(NAME, 1) || stdout_format_has_i)) {
if (file_total == 1 || trailing_slash)
if (cp && (file_total == 1 || trailing_slash))
*cp = '\0';
rprintf(FINFO, "created %d director%s for %s\n", ret, ret == 1 ? "y" : "ies", dest_path);
if (file_total == 1 || trailing_slash)
if (cp && (file_total == 1 || trailing_slash))
*cp = '/';
}
if (ret)
statret = do_stat(dest_path, &st);
statret = vfs_stat(VFS_AT_FDCWD, dest_path, &st, VFS_ALLOW_SYMLINK);
else
errno = save_errno;
}
@@ -784,7 +799,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path)
exit_cleanup(RERR_SYNTAX);
}
if (do_mkdir(dest_path, ACCESSPERMS) != 0) {
if (vfs_mkdir(VFS_AT_FDCWD, dest_path, ACCESSPERMS, VFS_ALLOW_SYMLINK) != 0) {
mkdir_error:
rsyserr(FERROR, errno, "mkdir %s failed",
full_fname(dest_path));
@@ -823,7 +838,16 @@ static char *get_local_name(struct file_list *flist, char *dest_path)
dest_path = "/";
*cp = '\0';
if (!change_dir(dest_path, CD_NORMAL)) {
if (dry_run && mkpath_dest_arg && vfs_stat(VFS_AT_FDCWD, dest_path, &st, VFS_ALLOW_SYMLINK) < 0) {
/* --mkpath would have created this parent dir, but a dry run did
* not, so don't chdir into it; flag the destination as not yet
* present (as the dir-creation path above does) so the generator
* doesn't try to compare against the missing tree (#880). Only
* the missing-parent case is touched, so an ordinary file-to-file
* dry run still itemizes against an existing destination. */
dry_run++;
change_dir(dest_path, CD_SKIP_CHDIR);
} else if (!change_dir(dest_path, CD_NORMAL)) {
rsyserr(FERROR, errno, "change_dir#3 %s failed",
full_fname(dest_path));
exit_cleanup(RERR_FILESELECT);
@@ -836,35 +860,42 @@ static char *get_local_name(struct file_list *flist, char *dest_path)
/* This function checks on our alternate-basis directories. If we're in
* dry-run mode and the destination dir does not yet exist, we'll try to
* tweak any dest-relative paths to make them work for a dry-run (the
* destination dir must be in curr_dir[] when this function is called).
* destination dir must be in vfs.curr_dir[] when this function is called).
* We also warn about any arg that is non-existent or not a directory. */
static void check_alt_basis_dirs(void)
{
STRUCT_STAT st;
char *slash = strrchr(curr_dir, '/');
char *slash = strrchr(vfs.curr_dir, '/');
int j;
for (j = 0; j < basis_dir_cnt; j++) {
char *bdir = basis_dir[j];
assert(bdir != NULL); /* option-supplied root; never NULL */
int bd_len = strlen(bdir);
if (bd_len > 1 && bdir[bd_len-1] == '/')
bdir[--bd_len] = '\0';
if (dry_run > 1 && *bdir != '/') {
int len = curr_dir_len + 1 + bd_len + 1;
/* Make a relative --link-dest/--copy-dest/--compare-dest absolute
* (vs the destination vfs.curr_dir). These are operator-trusted roots, so
* an absolute path makes the do_*_at() wrappers use plain resolution
* rather than reject an operator '..' outside the dest tree (e.g.
* --copy-dest=../to). Skipped when sanitize_paths already confined
* them; the dry_run>1 case keeps its leading-"../"-strip. */
if (*bdir != '/' && (dry_run > 1 || !sanitize_paths)) {
int len = vfs.curr_dir_len + 1 + bd_len + 1;
char *new = new_array(char, len);
if (slash && strncmp(bdir, "../", 3) == 0) {
if (dry_run > 1 && slash && strncmp(bdir, "../", 3) == 0) {
/* We want to remove only one leading "../" prefix for
* the directory we couldn't create in dry-run mode:
* this ensures that any other ".." references get
* evaluated the same as they would for a live copy. */
*slash = '\0';
pathjoin(new, len, curr_dir, bdir + 3);
pathjoin(new, len, vfs.curr_dir, bdir + 3);
*slash = '/';
} else
pathjoin(new, len, curr_dir, bdir);
pathjoin(new, len, vfs.curr_dir, bdir);
basis_dir[j] = bdir = new;
}
if (do_stat(bdir, &st) < 0)
if (vfs_stat(VFS_AT_FDCWD, bdir, &st, VFS_ALLOW_SYMLINK) < 0)
rprintf(FWARNING, "%s arg does not exist: %s\n", alt_dest_opt(0), bdir);
else if (!S_ISDIR(st.st_mode))
rprintf(FWARNING, "%s arg is not a dir: %s\n", alt_dest_opt(0), bdir);
@@ -992,7 +1023,7 @@ static int do_recv(int f_in, int f_out, char *local_name)
int ret;
if (backup_dir_len > 1)
backup_dir_buf[backup_dir_len-1] = '\0';
ret = do_stat(backup_dir_buf, &st);
ret = vfs_stat(VFS_AT_FDCWD, backup_dir_buf, &st, VFS_ALLOW_SYMLINK);
if (ret != 0 || !S_ISDIR(st.st_mode)) {
if (ret == 0) {
rprintf(FERROR, "The backup-dir is not a directory: %s\n", backup_dir_buf);
@@ -1012,7 +1043,7 @@ static int do_recv(int f_in, int f_out, char *local_name)
if (tmpdir) {
STRUCT_STAT st;
int ret = do_stat(tmpdir, &st);
int ret = vfs_stat(VFS_AT_FDCWD, tmpdir, &st, VFS_ALLOW_SYMLINK);
if (ret < 0 || !S_ISDIR(st.st_mode)) {
if (ret == 0) {
rprintf(FERROR, "The temp-dir is not a directory: %s\n", tmpdir);
@@ -1079,11 +1110,13 @@ static int do_recv(int f_in, int f_out, char *local_name)
exit_cleanup(RERR_PROTOCOL);
}
/* Finally, we go to sleep until our parent kills us with a
* USR2 signal. We sleep for a short time, as on some OSes
* a signal won't interrupt a sleep! */
while (1)
/* Finally, we go to sleep until our parent tells us to wrap up
* with a USR2 signal. We sleep for a short time, as on some OSes
* a signal won't interrupt a sleep, then act on the flag the
* (async-signal-safe) handler set. */
while (!got_sigusr2)
msleep(20);
receive_sigusr2();
}
am_generator = 1;
@@ -1209,15 +1242,25 @@ static void do_server_recv(int f_in, int f_out, int argc, char *argv[])
char **dir_p;
filter_rule_list *elp = &daemon_filter_list;
/* Collapse ".." and strip the module-dir prefix to get the module-relative
* name, but keep a leading "/" for a "path = /" module (module_dirlen <= 1)
* so an absolute (module-rooted) filter rule still matches. */
char clean[MAXPATHLEN], *dir;
for (dir_p = basis_dir; *dir_p; dir_p++) {
char *dir = *dir_p;
if (*dir == '/')
dir += module_dirlen;
if (!sanitize_path(clean, *dir_p, "/", 0, SP_DEFAULT))
strlcpy(clean, *dir_p, sizeof clean);
dir = clean + (*clean == '/' && module_dirlen > 1 ? module_dirlen : 0);
if (check_filter(elp, FLOG, dir, 1) < 0)
goto options_rejected;
}
if (partial_dir && *partial_dir == '/'
&& check_filter(elp, FLOG, partial_dir + module_dirlen, 1) < 0) {
if (partial_dir && *partial_dir == '/') {
if (!sanitize_path(clean, partial_dir, "/", 0, SP_DEFAULT))
strlcpy(clean, partial_dir, sizeof clean);
dir = clean + (*clean == '/' && module_dirlen > 1 ? module_dirlen : 0);
if (check_filter(elp, FLOG, dir, 1) < 0)
goto options_rejected;
}
if (0) {
options_rejected:
rprintf(FERROR, "Your options have been rejected by the server.\n");
exit_cleanup(RERR_SYNTAX);
@@ -1251,6 +1294,17 @@ void start_server(int f_in, int f_out, int argc, char *argv[])
if (am_sender) {
keep_dirlinks = 0; /* Must be disabled on the sender. */
/* Mirror client_run()'s sender_keeps_checksum check: a daemon-
* as-sender with -c and a `log format` containing %C will read
* F_SUM(file) in log_formatted(), so make_file() must allocate
* SUM_EXTRA_CNT. Without this, F_SUM() reads past the pool slot
* and hex-encodes adjacent heap into the transfer log. */
if (always_checksum
&& (log_format_has(stdout_format, 'C')
|| log_format_has(logfile_format, 'C')))
sender_keeps_checksum = 1;
if (need_messages_from_generator)
io_start_multiplex_in(f_in);
else
@@ -1314,7 +1368,7 @@ int client_run(int f_in, int f_out, pid_t pid, int argc, char *argv[])
become_copy_as_user();
flist = send_file_list(f_out, argc, argv);
send_file_list(f_out, argc, argv);
if (DEBUG_GTE(FLIST, 3))
rprintf(FINFO,"file list sent\n");
@@ -1559,6 +1613,10 @@ static int start_client(int argc, char *argv[])
shell_user = shell_machine;
shell_machine = p+1;
}
if (*shell_machine == '-') {
rprintf(FERROR, "Invalid remote host: hostnames may not start with '-'.\n");
exit_cleanup(RERR_SYNTAX);
}
}
if (DEBUG_GTE(CMD, 2)) {
@@ -1600,11 +1658,26 @@ static void sigusr1_handler(UNUSED(int val))
exit_cleanup(RERR_SIGNAL1);
}
/* SIGUSR2 tells the receiver child to wrap up. A signal handler must be
* async-signal-safe, so it only sets a flag here; receive_sigusr2() does the
* actual summary + shutdown (which use stdio/malloc/close) at a safe point in
* the receiver's post-transfer wait loops (read_final_goodbye via perform_io,
* and the trailing sleep). */
static void sigusr2_handler(UNUSED(int val))
{
got_sigusr2 = 1;
}
void receive_sigusr2(void)
{
if (!am_server)
output_summary();
close_all();
#ifdef GCOV_COVERAGE
/* The receiver child exits with _exit() here, bypassing the gcov atexit
* flush; without this it writes no .gcda. */
{ extern void __gcov_dump(void); __gcov_dump(); }
#endif
if (got_xfer_error)
_exit(RERR_PARTIAL);
_exit(0);
@@ -1707,6 +1780,31 @@ static void unset_env_var(const char *var)
}
/* The symlink-race-safe path resolver (vfs_resolve_open) holds one open
* dirfd per path component while it walks a path, plus an ancestor-dirfd cache
* -- far more descriptors than legacy rsync's single open(). On a host with a
* low default soft limit (e.g. OpenBSD's 128) a deep tree can hit EMFILE.
* Raise the soft RLIMIT_NOFILE toward the hard limit (unprivileged, per
* process; inherited by the sender/generator/receiver forks and daemon
* children), but cap it: some systems set an enormous hard limit (2^20+) that
* we don't want to adopt wholesale. */
static void raise_fd_limit(void)
{
#if defined HAVE_GETRLIMIT && defined HAVE_SETRLIMIT && defined RLIMIT_NOFILE
struct rlimit rl;
rlim_t want = 4096; /* covers a MAXPATHLEN-deep walk + cache + headroom */
if (getrlimit(RLIMIT_NOFILE, &rl) < 0)
return;
if (want > rl.rlim_max)
want = rl.rlim_max; /* never exceed the (admin-set) hard limit */
if (rl.rlim_cur < want) { /* only ever raise, never lower an inherited limit */
rl.rlim_cur = want;
(void)setrlimit(RLIMIT_NOFILE, &rl); /* best-effort */
}
#endif
}
int main(int argc,char *argv[])
{
int ret;
@@ -1714,6 +1812,10 @@ int main(int argc,char *argv[])
raw_argc = argc;
raw_argv = argv;
vfs_init();
raise_fd_limit();
#ifdef HAVE_SIGACTION
# ifdef HAVE_SIGPROCMASK
sigset_t sigmask;
@@ -1743,7 +1845,9 @@ int main(int argc,char *argv[])
our_gid = MY_GID();
am_root = our_uid == ROOT_UID;
unset_env_var("DISPLAY");
// DISPLAY should not be emptied unconditionally
if (!getenv("SSH_ASKPASS"))
unset_env_var("DISPLAY");
#if defined USE_OPENSSL && defined SET_OPENSSL_CONF
#define TO_STR2(x) #x
@@ -1825,7 +1929,7 @@ int main(int argc,char *argv[])
if (am_server && protect_args) {
char buf[MAXPATHLEN];
protect_args = 2;
read_args(STDIN_FILENO, NULL, buf, sizeof buf, 1, &argv, &argc, NULL);
read_args(STDIN_FILENO, NULL, buf, sizeof buf, 1, 0, &argv, &argc, NULL);
if (!parse_arguments(&argc, (const char ***) &argv)) {
option_error();
exit_cleanup(RERR_SYNTAX);
+41
View File
@@ -44,6 +44,29 @@ extern struct stats stats;
#define TRADITIONAL_TABLESIZE (1<<16)
/* The maximum number of same-weak-checksum candidates we will compare
* against at a single file offset before giving up and rolling forward a
* byte. A weak checksum that collides thousands of times (very common in
* disk/VM images, which contain large runs of identical blocks) would
* otherwise turn hash_search()'s inner loop into an O(file_size *
* chain_length) scan, pegging a CPU at 100% for hours with no apparent
* progress (issue #217).
*
* Concretely, a synthetic 40000-block basis whose blocks all share one weak
* checksum took ~18.4s to sync a 60KB source on a modern x86_64 box before
* this cap and ~0.7s after it -- and the unbounded cost grows with the
* square of the file size, which is what produced the multi-hour "hangs"
* reported against real multi-GB images.
*
* Capping the per-offset work keeps the search bounded; any block we skip
* over is simply sent as literal data, so the result is always correct --
* only the transfer size is (slightly) affected. This is purely a
* sender-side search limit: it changes no checksum, emitted byte, or
* protocol field, so a capped sender interoperates with any receiver. */
#ifndef MAX_CHAIN_LEN
#define MAX_CHAIN_LEN 1024
#endif
static uint32 tablesize;
static int32 *hash_table;
@@ -182,6 +205,7 @@ static void hash_search(int f,struct sum_struct *s,
int done_csum2 = 0;
uint32 hash_entry;
int32 i, *prev;
int32 chain_len = 0;
if (DEBUG_GTE(DELTASUM, 4)) {
rprintf(FINFO, "offset=%s sum=%04x%04x\n",
@@ -218,6 +242,14 @@ static void hash_search(int f,struct sum_struct *s,
if (sum != s->sums[i].sum1)
continue;
/* Bound the work spent on a single pathological hash
* bucket. If this weak checksum matches more than
* MAX_CHAIN_LEN records, stop scanning and treat this
* offset as a non-match (issue #217). The skipped data
* is sent literally, never corrupted. */
if (++chain_len > MAX_CHAIN_LEN)
break;
/* also make sure the two blocks are the same length */
l = (int32)MIN((OFF_T)s->blength, len-offset);
if (l != s->sums[i].len)
@@ -293,6 +325,7 @@ static void hash_search(int f,struct sum_struct *s,
&& (!updating_basis_file || s->sums[want_i].offset >= offset
|| s->sums[want_i].flags & SUMFLG_SAME_OFFSET)
&& sum == s->sums[want_i].sum1
&& l == s->sums[want_i].len
&& memcmp(sum2, sum2_at(s, want_i), s->s2length) == 0) {
/* we've found an adjacent match - the RLL coder
* will be happy */
@@ -370,6 +403,14 @@ void match_sums(int f, struct sum_struct *s, struct map_struct *buf, OFF_T len)
sum_init(xfer_sum_nni, checksum_seed);
if (append_mode > 0) {
if (s->flength > len) {
/* A hostile or confused peer can claim a verified-prefix
* length that exceeds what we have on disk -- including
* for an empty local file, where buf is NULL and the
* map_ptr() calls below would dereference it. Clamp to
* what we can actually read. */
s->flength = len;
}
if (append_mode == 2) {
OFF_T j = 0;
for (j = CHUNK_SIZE; j < s->flength; j += CHUNK_SIZE) {
+1 -1
View File
@@ -15,7 +15,7 @@ if [ ! -f "$flagfile" ]; then
if "$srcdir/md-convert" --test "$srcdir/rsync-ssl.1.md" >/dev/null 2>&1; then
touch $flagfile
else
outname=`echo "$inname" | sed 's/\.md$//'`
outname=`basename "$inname" .md`
if [ -f "$outname" ]; then
exit 0
elif [ -f "$srcdir/$outname" ]; then
+5
View File
@@ -120,6 +120,7 @@ TZ_RE = re.compile(r'^#define\s+MAINTAINER_TZ_OFFSET\s+(-?\d+(\.\d+)?)', re.M)
VAR_REF_RE = re.compile(r'\$\{(\w+)\}')
VERSION_RE = re.compile(r' (\d[.\d]+)[, ]')
BIN_CHARS_RE = re.compile(r'[\1-\7]+')
LONG_OPT_DASH_RE = re.compile(r'(--\w[-\w]+)')
SPACE_DOUBLE_DASH_RE = re.compile(r'\s--(\s)')
NON_SPACE_SINGLE_DASH_RE = re.compile(r'(^|\W)-')
WHITESPACE_RE = re.compile(r'\s')
@@ -247,6 +248,9 @@ def find_man_substitutions():
env_subs['date'] = time.strftime('%d %b %Y', time.gmtime(mtime + tz_offset)).lstrip('0')
if 'SOURCE_DATE_EPOCH' in os.environ:
env_subs['date'] = time.strftime('%d %b %Y', time.gmtime(int(os.environ.get('SOURCE_DATE_EPOCH', time.time()))))
def html_via_commonmark(txt):
return commonmark.HtmlRenderer().render(commonmark.Parser().parse(txt))
@@ -540,6 +544,7 @@ class TransformHtml(HTMLParser):
if st.in_pre:
html = htmlify(txt)
else:
txt = LONG_OPT_DASH_RE.sub(lambda x: x.group(1).replace('-', NBR_DASH[0]), txt)
txt = SPACE_DOUBLE_DASH_RE.sub(NBR_SPACE[0] + r'--\1', txt).replace('--', NBR_DASH[0]*2)
txt = NON_SPACE_SINGLE_DASH_RE.sub(r'\1' + NBR_DASH[0], txt)
html = htmlify(txt)
+14 -4
View File
@@ -7,10 +7,20 @@ if [ ! -f git-version.h ]; then
fi
if test -d "$srcdir/.git" || test -f "$srcdir/.git"; then
gitver=`git describe --abbrev=8 2>/dev/null`
# NOTE: I'm avoiding "|" in sed since I'm not sure if sed -r is portable and "\|" fails on some OSes.
verchk=`echo "$gitver-" | sed -n '/^v3\.[0-9][0-9]*\.[0-9][0-9]*\(pre[0-9]*\)*-/p'`
if [ -n "$verchk" ]; then
# Identify a git build by the development version from version.h plus the
# exact commit (e.g. "3.5.0dev-g1234abcd"), rather than the nearest release
# tag that `git describe` would pick: that tag can sit far behind a rebased
# development branch and then misnames the line you are actually on (showing,
# say, 3.4.3 for a 3.5.0dev tree). This also works in a shallow/tag-less
# clone. A release tarball has no .git, so git-version.h stays empty and
# rsync prints the plain RSYNC_VERSION.
# cd into the subshell rather than "git -C" (avoids needing a newer git).
gitsha=`(cd "$srcdir" && git rev-parse --short=8 HEAD) 2>/dev/null`
# Tolerate any preprocessor spacing and a trailing comment; capture only the
# quoted value. Empty (define missing/unmatched) -> leave RSYNC_GITVER unset.
rsyncver=`sed -n 's/^[[:space:]]*#[[:space:]]*define[[:space:]][[:space:]]*RSYNC_VERSION[[:space:]][[:space:]]*"\([^"]*\)".*/\1/p' "$srcdir/version.h"`
if [ -n "$gitsha" ] && [ -n "$rsyncver" ]; then
gitver="$rsyncver-g$gitsha"
echo "#define RSYNC_GITVER \"$gitver\"" >git-version.h.new
if ! diff git-version.h.new git-version.h >/dev/null; then
echo "Updating git-version.h"
+1
View File
@@ -18,6 +18,7 @@ inheader {
sub(/^CHAR\(/, "char ")
sub(/^INTEGER\(/, "int ")
sub(/^STRING\(/, "char *")
sub(/^STRING_SHELL\(/, "char *")
protos = protos "\n" $0 (local ? "(int module_id);" : "(void);")
next
}
+87
View File
@@ -0,0 +1,87 @@
# Old rsync version archive
Static rsync binaries built from historical release tags. Two uses:
1. **Cross-version behaviour checks** — confirming whether a behaviour a user
reported on an old release is version-specific or option-driven.
2. **The version-mixing test suite**`runtests.py --rsync-bin2=...` runs the
current code against one of these as the daemon / remote-shell peer; CI
(`.github/workflows/ubuntu-version-mix.yml`) does this for every binary
here against the per-version manifests in `testsuite/expect/`.
Binaries are **statically linked** so they run regardless of the host's
shared libraries, and named `rsync_<version>`:
| Binary | Version | Protocol | Notes |
|----------------|---------|----------|-----------------------------------------|
| `rsync_2.6.0` | 2.6.0 | 27 | 2004; needs autoconf regen (see below) |
| `rsync_3.0.0` | 3.0.0 | 30 | 2008 |
| `rsync_3.1.0` | 3.1.0 | 31 | 2013 |
| `rsync_3.1.3` | 3.1.3 | 31 | Ubuntu 18.04 / Debian buster era (2018) |
| `rsync_3.2.0` | 3.2.0 | 31 | 2020 (zstd/lz4/xxhash negotiation added)|
| `rsync_3.2.7` | 3.2.7 | 31 | 2022 |
| `rsync_3.3.0` | 3.3.0 | 31 | 2024 |
| `rsync_3.4.0` | 3.4.0 | 32 | 2025 |
| `rsync_3.4.1` | 3.4.1 | 32 | 2025 |
These are every `x.y.0` release from 2.6.0 (2004) onward plus a few point
releases. 2.6.0 is the practical floor: older tags need progressively more
porting to build on a current toolchain.
All built `--disable-openssl` and with `_FORTIFY_SOURCE` disabled (see below);
xxhash/zstd/lz4 are compiled in where the version supports them.
## Adding a version
```bash
./build_static.sh 3.2.7 # uses git tag v3.2.7
./build_static.sh 3.0.9 v3.0.9 # explicit tag if naming differs
```
The script checks out the tag into a throwaway `git worktree`, applies the
minimal patches needed to compile old sources on a modern toolchain, links
statically, verifies the result is static and reports the requested version,
then installs `rsync_<version>` here and removes the worktree.
Override the source repo with `RSYNC_REPO=/path/to/rsync ./build_static.sh ...`
(defaults to `../rsync.4`).
## Why the patches?
Modern GCC (>= 14, C23 default) and glibc reject things old rsync relied on.
`build_static.sh` handles these, each guarded so it's a no-op when not needed:
1. **K&R `lseek64()` redeclaration** in `syscall.c` clashes with glibc's real
prototype — removed.
2. **`gettimeofday()`** — glibc only has the 2-arg form; configure misdetects
the 1-arg form, so `HAVE_GETTIMEOFDAY_TZ` is forced on in `config.h`.
3. **C23 `()` == `(void)`** breaks K&R prototypes called with arguments
(`qsort` comparator, `pool->bomb`, etc.) — built with `-std=gnu11`.
4. Assorted modern `-Werror` promotions (incompatible pointer types, implicit
declarations) downgraded to warnings; bundled zlib/popt used to keep the
static link self-contained.
5. **OpenSSL (3.2+)** is disabled with `--disable-openssl`: linking
`libcrypto.a` statically drags in jitterentropy (`jent_*`) and zlib's
`uncompress` (OpenSSL's COMP module), which don't resolve here. OpenSSL only
provided optional MD4/MD5, which rsync implements natively, so checksum
behaviour is unaffected.
6. **`_FORTIFY_SOURCE` disabled** (`-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0`):
modern Ubuntu defaults it to `=3`, whose stricter object-size checks turn
latent (historically benign) over-reads in OLD rsync into hard
`*** buffer overflow detected ***` aborts when the binary runs as a
server/daemon — which made e.g. 3.1.3 and 3.2.7 unusable as peers. Disabling
it makes the archival binaries behave as the released versions did.
7. **Pre-3.0 tags (e.g. 2.6.0)** ship `configure.in`, not a generated
`configure`. The script runs `autoheader`/`autoconf` to generate it, after
neutralizing the `AC_CHECK_FUNCS(fn,,AC_LIBOBJ(lib/...))` fallbacks for
`inet_ntop`/`inet_pton`/`getaddrinfo`/`getnameinfo` — modern autoconf emits
broken shell for those never-taken branches (the funcs exist in glibc). It
also generates `proto.h` (no make rule in that era) and stubs the vendored
`lib/addrinfo.h` the tag dropped (modern glibc supplies `struct addrinfo`).
All guarded so they no-op on 3.x.
Newer versions may need fewer or different tweaks; if a build fails, the
script prints the first compiler errors from its log.
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
# Build a static rsync binary from a historical git tag, for cross-version
# behaviour testing. Produces ./rsync_<version> in this directory.
#
# Usage: ./build_static.sh <version> [git-tag]
# Example: ./build_static.sh 3.1.3 # uses tag v3.1.3
# ./build_static.sh 3.2.7 v3.2.7
#
# Old rsync releases don't compile cleanly on a modern toolchain (GCC >= 14
# defaults to C23, where an empty () prototype means (void); glibc dropped the
# 1-arg gettimeofday; lseek64 K&R redeclarations clash). This script applies
# the minimal, best-effort workarounds and links statically so the result is
# self-contained and reproducible regardless of the host's shared libraries.
#
# Each workaround is guarded so it's a no-op on versions that don't need it.
set -euo pipefail
VERSION="${1:?usage: build_static.sh <version> [git-tag]}"
TAG="${2:-v$VERSION}"
ARCHIVE_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO="${RSYNC_REPO:-/home/tridge/project/rsync/rsync.4}" # any rsync worktree
WORKTREE="$(mktemp -d /tmp/rsync-build-XXXXXX)"
OUT="$ARCHIVE_DIR/rsync_$VERSION"
# C standard restores K&R () semantics; permissive flags downgrade the pile of
# modern -Werror promotions (incompatible pointers, implicit decls) to warnings.
# _FORTIFY_SOURCE is forced OFF: modern Ubuntu defaults it to =3, whose stricter
# object-size checks turn latent (historically benign) over-reads in OLD rsync
# into hard "*** buffer overflow detected ***" aborts when the binary acts as a
# server/daemon. Disabling it makes these archival binaries behave the way the
# released versions did, which is the whole point of the archive.
CFLAGS_OLD="-I. -I./zlib -O2 -g -std=gnu11 -fcommon -DHAVE_CONFIG_H -Wno-error \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-Wno-incompatible-pointer-types -Wno-implicit-function-declaration -Wno-int-conversion"
cleanup() {
cd "$REPO"
git worktree remove --force "$WORKTREE" 2>/dev/null || true
git worktree prune 2>/dev/null || true
}
trap cleanup EXIT
echo ">>> checking out $TAG into $WORKTREE"
# prefer an exact tag to avoid ambiguity with similarly-named branches
REF="$TAG"
if git -C "$REPO" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
REF="refs/tags/$TAG"
fi
git -C "$REPO" worktree add --detach "$WORKTREE" "$REF"
cd "$WORKTREE"
# --- workaround 1: K&R lseek64 redeclaration clashes with glibc's prototype ---
if grep -q 'off64_t lseek64();' syscall.c 2>/dev/null; then
echo ">>> patching syscall.c lseek64 redeclaration"
perl -0pi -e 's/#ifdef HAVE_LSEEK64\n#if !SIZEOF_OFF64_T\n\tOFF_T lseek64\(\);\n#else\n\toff64_t lseek64\(\);\n#endif\n\treturn lseek64/#ifdef HAVE_LSEEK64\n\treturn lseek64/' syscall.c
fi
# --- workaround 0: pre-3.0 tags ship configure.in, not a generated configure.
# Generate it. Modern autoconf emits broken shell for their
# AC_CHECK_FUNCS(fn,,AC_LIBOBJ(lib/...)) fallbacks -- but those branches are
# dead on a modern host (glibc has inet_ntop/inet_pton/getaddrinfo/getnameinfo),
# so neutralize the AC_LIBOBJ replacements before regenerating.
OLD_TREE=0
if [ ! -f ./configure ] && { [ -f configure.in ] || [ -f configure.ac ]; }; then
OLD_TREE=1
acsrc=configure.ac; [ -f configure.in ] && acsrc=configure.in
echo ">>> generating configure for an old tag (autoheader/autoconf)"
sed -i 's#AC_LIBOBJ(lib/[a-zA-Z_]*)#:#g' "$acsrc"
autoheader 2>/dev/null || true
autoconf 2>/dev/null || { echo "autoconf failed"; exit 1; }
fi
CONF_ARGS=(--disable-md2man --with-included-zlib=yes --with-included-popt=yes)
# OpenSSL (3.2+) only adds optional MD4/MD5 that rsync already implements, but
# linking libcrypto.a statically drags in jitterentropy + zlib's uncompress,
# which aren't resolvable here. Drop it when the flag exists.
if ./configure --help 2>/dev/null | grep -q -- '--disable-openssl'; then
echo ">>> disabling openssl for self-contained static link"
CONF_ARGS+=(--disable-openssl)
fi
echo ">>> configure (bundled zlib + popt, static-friendly)"
./configure "${CONF_ARGS[@]}" \
>"$WORKTREE/conf.log" 2>&1 || { tail -20 "$WORKTREE/conf.log"; exit 1; }
# --- workaround 2: modern glibc only has the 2-arg gettimeofday ---------------
if grep -q '/\* #undef HAVE_GETTIMEOFDAY_TZ \*/' config.h; then
echo ">>> forcing HAVE_GETTIMEOFDAY_TZ (configure misdetects it)"
sed -i 's|/\* #undef HAVE_GETTIMEOFDAY_TZ \*/|#define HAVE_GETTIMEOFDAY_TZ 1|' config.h
fi
# --- workaround 4 (old trees only): generate proto.h if the tree has no make
# rule for it, and stub a vendored lib/addrinfo.h that the git tag dropped
# (modern glibc supplies struct addrinfo / sockaddr_storage, so empty is right).
if [ "$OLD_TREE" = 1 ]; then
if [ ! -f proto.h ] && [ -f mkproto.awk ]; then
echo ">>> generating proto.h"
cat ./*.c ./lib/compat.c 2>/dev/null | awk -f ./mkproto.awk > proto.h
fi
if grep -q 'include "lib/addrinfo.h"' rsync.h 2>/dev/null && [ ! -f lib/addrinfo.h ]; then
echo ">>> stubbing lib/addrinfo.h"
echo '/* emptied: modern glibc provides struct addrinfo */' > lib/addrinfo.h
fi
fi
echo ">>> building (static)"
make -j"$(nproc)" CFLAGS="$CFLAGS_OLD" LDFLAGS="-static" \
>"$WORKTREE/make.log" 2>&1 || { grep -E 'error:|\*\*\*' "$WORKTREE/make.log" | head; exit 1; }
# verify it's actually static before we keep it
if ldd ./rsync 2>&1 | grep -qv 'not a dynamic executable'; then
echo "ERROR: binary is not statically linked:" >&2
ldd ./rsync >&2
exit 1
fi
GOT="$(./rsync --version | head -1 | awk '{print $3}')"
if [ "$GOT" != "$VERSION" ]; then
echo "ERROR: built version '$GOT' != requested '$VERSION'" >&2
exit 1
fi
cp ./rsync "$OUT"
strip "$OUT"
echo ">>> installed $OUT"
"$OUT" --version | head -1
file "$OUT"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+215 -30
View File
@@ -59,6 +59,9 @@ int preserve_perms = 0;
int preserve_executability = 0;
int preserve_devices = 0;
int preserve_specials = 0;
int drop_devices = 0;
char *confine_root = NULL; /* --confine-root: see vfs/dirstack.c */
unsigned int confine_rootlen = 0;
int preserve_uid = 0;
int preserve_gid = 0;
int preserve_mtimes = 0;
@@ -86,6 +89,8 @@ int sparse_files = 0;
int preallocate_files = 0;
int do_compression = 0;
int do_compression_level = CLVL_NOT_SPECIFIED;
int do_compression_threads = 0; /*n = 0 use rsync thread, n >= 1 spawn n threads for compression */
#define MAX_DAEMON_COMPRESSION_THREADS 8
int am_root = 0; /* 0 = normal, 1 = root, 2 = --super, -1 = --fake-super */
int am_server = 0;
int am_sender = 0;
@@ -113,11 +118,21 @@ int mkpath_dest_arg = 0;
int allow_inc_recurse = 1;
int xfer_dirs = -1;
int am_daemon = 0;
/* Set after a successful per-module chroot ("use chroot = yes") in
* clientserver.c. NOT set for the daemon-level "daemon chroot = /X"
* chroot: that confines path resolution to /X, but module paths
* /X/modA, /X/modB, etc. are not chroot boundaries, so the per-module
* symlink-race defenses (vfs_resolve_open() / do_*_at() in
* syscall.c, gated by `am_daemon && !am_chrooted`) must still fire
* even when the daemon is inside a daemon chroot. */
int am_chrooted = 0;
int connect_timeout = 0;
int keep_partial = 0;
int safe_symlinks = 0;
int copy_unsafe_links = 0;
int insecure_links = 0;
int munge_symlinks = 0;
int use_secure_symlinks = 0;
int size_only = 0;
int daemon_bwlimit = 0;
int bwlimit = 0;
@@ -225,7 +240,7 @@ char *iconv_opt =
struct chmod_mode_struct *chmod_modes = NULL;
static const char *debug_verbosity[] = {
static const char *const debug_verbosity[] = {
/*0*/ NULL,
/*1*/ NULL,
/*2*/ "BIND,CMD,CONNECT,DEL,DELTASUM,DUP,FILTER,FLIST,ICONV",
@@ -236,7 +251,7 @@ static const char *debug_verbosity[] = {
#define MAX_VERBOSITY ((int)(sizeof debug_verbosity / sizeof debug_verbosity[0]) - 1)
static const char *info_verbosity[1+MAX_VERBOSITY] = {
static const char *const info_verbosity[1+MAX_VERBOSITY] = {
/*0*/ "NONREG",
/*1*/ "COPY,DEL,FLIST,MISC,NAME,STATS,SYMSAFE",
/*2*/ "BACKUP,MISC2,MOUNT,NAME2,REMOVE,SKIP",
@@ -315,7 +330,7 @@ static struct output_struct debug_words[COUNT_DEBUG+1] = {
};
static int verbose = 0;
static int do_stats = 0;
static int vfs_stats = 0;
static int do_progress = 0;
static int daemon_opt; /* sets am_daemon after option error-reporting */
static int F_option_cnt = 0;
@@ -441,7 +456,10 @@ static void parse_output_words(struct output_struct *words, short *levels, const
len--;
}
lev = isDigit(str+len) ? atoi(str+len) : 1;
if (lev > MAX_OUT_LEVEL)
/* atoi() of an overflowing positive digit string can return a
* negative int (LONG_MAX truncated on LP64); a negative lev
* here later indexes counts[lev] in make_output_option(). */
if (lev > MAX_OUT_LEVEL || lev < 0)
lev = MAX_OUT_LEVEL;
if (len == 4 && strncasecmp(str, "help", 4) == 0) {
output_item_help(words);
@@ -474,7 +492,7 @@ static void parse_output_words(struct output_struct *words, short *levels, const
static void output_item_help(struct output_struct *words)
{
short *levels = words == info_words ? info_levels : debug_levels;
const char **verbosity = words == info_words ? info_verbosity : debug_verbosity;
const char *const*verbosity = words == info_words ? info_verbosity : debug_verbosity;
char buf[128], *opt, *fmt = "%-10s %s\n";
int j;
@@ -602,7 +620,7 @@ static struct poptOption long_options[] = {
{"quiet", 'q', POPT_ARG_NONE, 0, 'q', 0, 0 },
{"motd", 0, POPT_ARG_VAL, &output_motd, 1, 0, 0 },
{"no-motd", 0, POPT_ARG_VAL, &output_motd, 0, 0, 0 },
{"stats", 0, POPT_ARG_NONE, &do_stats, 0, 0, 0 },
{"stats", 0, POPT_ARG_NONE, &vfs_stats, 0, 0, 0 },
{"human-readable", 'h', POPT_ARG_NONE, 0, 'h', 0, 0},
{"no-human-readable",0, POPT_ARG_VAL, &human_readable, 0, 0, 0},
{"no-h", 0, POPT_ARG_VAL, &human_readable, 0, 0, 0},
@@ -666,12 +684,17 @@ static struct poptOption long_options[] = {
{"no-write-devices", 0, POPT_ARG_VAL, &write_devices, 0, 0, 0 },
{"specials", 0, POPT_ARG_VAL, &preserve_specials, 1, 0, 0 },
{"no-specials", 0, POPT_ARG_VAL, &preserve_specials, 0, 0, 0 },
{"drop-D", 0, POPT_ARG_VAL, &drop_devices, 1, 0, 0 },
{"no-drop-D", 0, POPT_ARG_VAL, &drop_devices, 0, 0, 0 },
{"confine-root", 0, POPT_ARG_STRING, &confine_root, 0, 0, 0 },
{"links", 'l', POPT_ARG_VAL, &preserve_links, 1, 0, 0 },
{"no-links", 0, POPT_ARG_VAL, &preserve_links, 0, 0, 0 },
{"no-l", 0, POPT_ARG_VAL, &preserve_links, 0, 0, 0 },
{"copy-links", 'L', POPT_ARG_NONE, &copy_links, 0, 0, 0 },
{"copy-unsafe-links",0, POPT_ARG_NONE, &copy_unsafe_links, 0, 0, 0 },
{"safe-links", 0, POPT_ARG_NONE, &safe_symlinks, 0, 0, 0 },
{"insecure-links", 0, POPT_ARG_VAL, &insecure_links, 1, 0, 0 },
{"no-insecure-links",0, POPT_ARG_VAL, &insecure_links, 0, 0, 0 },
{"munge-links", 0, POPT_ARG_VAL, &munge_symlinks, 1, 0, 0 },
{"no-munge-links", 0, POPT_ARG_VAL, &munge_symlinks, 0, 0, 0 },
{"copy-dirlinks", 'k', POPT_ARG_NONE, &copy_dirlinks, 0, 0, 0 },
@@ -756,6 +779,8 @@ static struct poptOption long_options[] = {
{"skip-compress", 0, POPT_ARG_STRING, &skip_compress, 0, 0, 0 },
{"compress-level", 0, POPT_ARG_INT, &do_compression_level, 0, 0, 0 },
{"zl", 0, POPT_ARG_INT, &do_compression_level, 0, 0, 0 },
{"compress-threads", 0, POPT_ARG_INT, &do_compression_threads, 0, 0, 0 },
{"zt", 0, POPT_ARG_INT, &do_compression_threads, 0, 0, 0 },
{0, 'P', POPT_ARG_NONE, 0, 'P', 0, 0 },
{"progress", 0, POPT_ARG_VAL, &do_progress, 1, 0, 0 },
{"no-progress", 0, POPT_ARG_VAL, &do_progress, 0, 0, 0 },
@@ -844,7 +869,7 @@ static struct poptOption long_options[] = {
{0,0,0,0, 0, 0, 0}
};
static struct poptOption long_daemon_options[] = {
static const struct poptOption long_daemon_options[] = {
/* longName, shortName, argInfo, argPtr, value, descrip, argDesc */
{"address", 0, POPT_ARG_STRING, &bind_address, 0, 0, 0 },
{"bwlimit", 0, POPT_ARG_INT, &daemon_bwlimit, 0, 0, 0 },
@@ -892,9 +917,54 @@ void option_error(void)
}
/* Does this row store a compile-time constant, and if so which?
*
* popt's `val` is not comparable across argInfo kinds. For POPT_ARG_VAL it IS
* the value stored in `arg`; for the others a nonzero `val` is an action code
* handed to the parser's switch, and POPT_ARG_NONE with a destination stores 1
* regardless. Comparing the raw field therefore misses aliases spelled with
* different table shapes -- --del is POPT_ARG_NONE/&delete_during/0 and
* --delete-during is POPT_ARG_VAL/&delete_during/1, and both set it to 1. */
static int refuse_const_assign(const struct poptOption *op, int *valp)
{
if (!op->arg)
return 0;
if (op->argInfo == POPT_ARG_VAL) {
*valp = op->val;
return 1;
}
/* A nonzero val here means the row ALSO runs a parser action, so it is
* not merely an assignment and must not be folded in with one. */
if (op->argInfo == POPT_ARG_NONE && op->val == 0) {
*valp = 1;
return 1;
}
return 0;
}
/* Do two table rows name the same capability? An exact refuse rule names a
* capability, not one spelling of it. */
static int same_refuse_action(const struct poptOption *a, const struct poptOption *b)
{
int a_val, b_val;
/* Constant assignments: same destination, same resulting value. The
* value check keeps opposite switches such as --foo and --no-foo apart,
* since they differ only in what they store. */
if (refuse_const_assign(a, &a_val) && refuse_const_assign(b, &b_val))
return a->arg == b->arg && a_val == b_val;
/* Anything else has to match as a table entry: a row storing a runtime
* value (POPT_ARG_INT, POPT_ARG_STRING) needs the same destination and
* action code, and an action-only row the same nonzero code. */
if (a->argInfo != b->argInfo || a->val != b->val)
return 0;
return a->arg ? a->arg == b->arg : !b->arg && a->val != 0;
}
static void parse_one_refuse_match(int negated, const char *ref, const struct poptOption *list_end)
{
struct poptOption *op;
struct poptOption *op, *matched_op = NULL;
char shortName[2];
int is_wild = strpbrk(ref, "*?[") != NULL;
int found_match = 0;
@@ -915,8 +985,21 @@ static void parse_one_refuse_match(int negated, const char *ref, const struct po
else if (!is_wild)
op->descrip = negated ? "a=" : "r=";
found_match = 1;
if (!is_wild)
if (!is_wild) {
matched_op = op;
break;
}
}
}
if (matched_op) {
for (op = long_options; op != list_end; op++) {
if (op == matched_op || !same_refuse_action(op, matched_op))
continue;
if (op->descrip[1] == '*')
op->descrip = negated ? "a*" : "r*";
else
op->descrip = negated ? "a=" : "r=";
}
}
@@ -996,6 +1079,11 @@ static void set_refuse_options(void)
parse_one_refuse_match(0, "iconv", list_end);
#endif
parse_one_refuse_match(0, "log-file*", list_end);
/* A client must never disable the daemon's symlink confinement:
* --insecure-links is a local-only flag, so the daemon hard-refuses it
* (dropping the connection). The daemon's own opt-out is the
* "insecure links" module parameter, not this flag. */
parse_one_refuse_match(0, "insecure-links", list_end);
}
#ifndef SUPPORT_ATIMES
@@ -1077,6 +1165,8 @@ static ssize_t parse_size_arg(const char *size_arg, char def_suf, const char *op
int reps, mult, len;
const char *arg, *err = "invalid", *min_max = NULL;
ssize_t limit = -1, size = 1;
ssize_t size_max = max_value >= 0 ? max_value : (ssize_t)(SIZE_MAX / 2);
double dsize;
for (arg = size_arg; isDigit(arg); arg++) {}
if (*arg == '.' || *arg == get_decimal_point()) /* backward compatibility: always allow '.' */
@@ -1111,11 +1201,38 @@ static ssize_t parse_size_arg(const char *size_arg, char def_suf, const char *op
mult = 1024, arg += 2;
else
goto failure;
while (reps--)
while (reps--) {
if (size > size_max / mult) {
err = "too large";
min_max = "max";
limit = max_value;
goto failure;
}
size *= mult;
size *= atof(size_arg);
if ((*arg == '+' || *arg == '-') && arg[1] == '1' && arg != size_arg)
size += atoi(arg), arg += 2;
}
errno = 0;
dsize = strtod(size_arg, NULL);
if (errno == ERANGE || dsize < 0 || dsize > (double)size_max / size
|| (max_value < 0 && dsize >= (double)size_max / size)) {
err = "too large";
min_max = "max";
limit = max_value;
goto failure;
}
size = (ssize_t)(dsize * size);
if ((*arg == '+' || *arg == '-') && arg[1] == '1' && arg != size_arg) {
if (*arg == '+') {
if (size == size_max) {
err = "too large";
min_max = "max";
limit = max_value;
goto failure;
}
size++;
} else
size--;
arg += 2;
}
if (*arg)
goto failure;
if (size < 0 || (max_value >= 0 && size > max_value)) {
@@ -1139,6 +1256,8 @@ failure:
min_max, do_big_num(limit, 3, NULL),
unlimited_0 && min_max[1] == 'i' ? " or 0 for unlimited" : "");
}
if (len < 0 || len > (int)sizeof err_buf - 2)
len = sizeof err_buf - 2;
err_buf[len] = '\n';
err_buf[len+1] = '\0';
return -1;
@@ -1156,7 +1275,7 @@ static time_t parse_time(const char *arg)
{
const char *cp;
time_t val, now = time(NULL);
struct tm t, *today = localtime(&now);
struct tm t, tmp, *today = localtime_r(&now, &tmp);
int in_date, old_mday, n;
memset(&t, 0, sizeof t);
@@ -1369,6 +1488,10 @@ int parse_arguments(int *argc_p, const char ***argv_p)
/* TODO: Call poptReadDefaultConfig; handle errors. */
pc = poptGetContext(RSYNC_NAME, argc, argv, long_options, 0);
if (pc == NULL) {
strlcpy(err_buf, "poptGetContext returned NULL\n", sizeof err_buf);
return 0;
}
if (!am_server) {
poptReadDefaultConfig(pc, 0);
popt_unalias(pc, "--daemon");
@@ -1473,7 +1596,6 @@ int parse_arguments(int *argc_p, const char ***argv_p)
*argc_p = 0;
} else if (poptDupArgv(argc, argv, argc_p, argv_p) != 0)
out_of_memory("parse_arguments");
argv = *argv_p;
poptFreeContext(pc);
am_starting_up = 0;
@@ -1944,6 +2066,10 @@ int parse_arguments(int *argc_p, const char ***argv_p)
ssize_t size = parse_size_arg(max_alloc_arg, 'B', "max-alloc", 1024*1024, -1, True);
if (size < 0)
goto cleanup;
if (size == 0) {
snprintf(err_buf, sizeof err_buf, "max-alloc must be greater than zero\n");
goto cleanup;
}
max_alloc = size;
}
if (!max_alloc)
@@ -2006,6 +2132,14 @@ int parse_arguments(int *argc_p, const char ***argv_p)
create_refuse_error(refused_compress);
goto cleanup;
}
if (do_compression_threads < 0)
do_compression_threads = 0;
/* A daemon client controls the server-side sender arguments. Keep one
* unauthenticated connection from asking Zstandard to materialize its
* implementation maximum (currently hundreds) of worker threads. Local
* and remote-shell invocations retain the operator-requested value. */
if (am_daemon && do_compression_threads > MAX_DAEMON_COMPRESSION_THREADS)
do_compression_threads = MAX_DAEMON_COMPRESSION_THREADS;
}
#ifdef HAVE_SETVBUF
@@ -2043,7 +2177,7 @@ int parse_arguments(int *argc_p, const char ***argv_p)
set_output_verbosity(verbose, DEFAULT_PRIORITY);
if (do_stats) {
if (vfs_stats) {
parse_output_words(info_words, info_levels,
verbose > 1 ? "stats3" : "stats2", DEFAULT_PRIORITY);
}
@@ -2237,13 +2371,33 @@ int parse_arguments(int *argc_p, const char ***argv_p)
STRUCT_STAT st;
char prefix[SYMLINK_PREFIX_LEN]; /* NOT +1 ! */
strlcpy(prefix, SYMLINK_PREFIX, sizeof prefix); /* trim the trailing slash */
if (do_stat(prefix, &st) == 0 && S_ISDIR(st.st_mode)) {
if (vfs_stat(VFS_AT_FDCWD, prefix, &st, VFS_ALLOW_SYMLINK) == 0 && S_ISDIR(st.st_mode)) {
rprintf(FERROR, "Symlink munging is unsafe when a %s directory exists.\n",
prefix);
exit_cleanup(RERR_UNSUPPORTED);
}
}
if (confine_root) {
/* A daemon already has module_dir for this job, and honouring a
* peer-supplied root there could only loosen the module boundary. */
if (am_daemon)
confine_root = NULL;
else if (*confine_root != '/') {
snprintf(err_buf, sizeof err_buf,
"--confine-root must be an absolute path\n");
return 0;
} else if (insecure_links) {
/* The opt-out restores the legacy open, which short-circuits the
* walk that enforces the root -- so the pair would silently mean
* no confinement at all. Say so instead. */
snprintf(err_buf, sizeof err_buf,
"--insecure-links cannot be combined with --confine-root\n");
return 0;
} else
confine_root = normalize_path(confine_root, True, &confine_rootlen);
}
if (sanitize_paths) {
int i;
for (i = argc; i-- > 0; )
@@ -2255,21 +2409,26 @@ int parse_arguments(int *argc_p, const char ***argv_p)
}
if (daemon_filter_list.head && !am_sender) {
filter_rule_list *elp = &daemon_filter_list;
/* Strip the module-dir prefix to get the module-relative name, but keep a
* leading "/" for a "path = /" module (module_dirlen <= 1) so an absolute
* (module-rooted) filter rule still matches. */
if (tmpdir) {
char *dir;
char clean[MAXPATHLEN], *dir;
if (!*tmpdir)
goto options_rejected;
dir = tmpdir + (*tmpdir == '/' ? module_dirlen : 0);
clean_fname(dir, CFN_COLLAPSE_DOT_DOT_DIRS);
if (!sanitize_path(clean, tmpdir, "/", 0, SP_DEFAULT))
strlcpy(clean, tmpdir, sizeof clean);
dir = clean + (*clean == '/' && module_dirlen > 1 ? module_dirlen : 0);
if (check_filter(elp, FLOG, dir, 1) < 0)
goto options_rejected;
}
if (backup_dir) {
char *dir;
char clean[MAXPATHLEN], *dir;
if (!*backup_dir)
goto options_rejected;
dir = backup_dir + (*backup_dir == '/' ? module_dirlen : 0);
clean_fname(dir, CFN_COLLAPSE_DOT_DOT_DIRS);
if (!sanitize_path(clean, backup_dir, "/", 0, SP_DEFAULT))
strlcpy(clean, backup_dir, sizeof clean);
dir = clean + (*clean == '/' && module_dirlen > 1 ? module_dirlen : 0);
if (check_filter(elp, FLOG, dir, 1) < 0)
goto options_rejected;
}
@@ -2446,7 +2605,7 @@ int parse_arguments(int *argc_p, const char ***argv_p)
if (files_from) {
char *h, *p;
int q;
int q = 0;
if (argc > 2 || (!am_daemon && !am_server && argc == 1)) {
usage(FERROR);
exit_cleanup(RERR_SYNTAX);
@@ -2480,7 +2639,16 @@ int parse_arguments(int *argc_p, const char ***argv_p)
if (check_filter(&daemon_filter_list, FLOG, dir, 0) < 0)
goto options_rejected;
}
filesfrom_fd = open(files_from, O_RDONLY|O_BINARY);
/* Operator-supplied path that may transit attacker-writable
* parents; refuse symlinks not owned by uid 0 or our euid,
* as for --exclude-from/--include-from/--filter in exclude.c.
* A daemon reads this list from a CLIENT-requested path
* (--files-from=:LIST) and it must stay inside the module:
* the is_operator walk also refuses a (trusted-owned) symlink
* that redirects the list outside the module root -- e.g. a
* root-owned backup symlink. No-op off a daemon (the module-root
* check only fires when am_daemon). */
filesfrom_fd = vfs_open_owner_walk(files_from, O_RDONLY|O_BINARY, 0, 1);
if (filesfrom_fd < 0) {
snprintf(err_buf, sizeof err_buf,
"failed to open files-from file %s: %s\n",
@@ -2520,7 +2688,7 @@ static char SPLIT_ARG_WHEN_OLD[1];
**/
char *safe_arg(const char *opt, const char *arg)
{
#define SHELL_CHARS "!#$&;|<>(){}\"'` \t\\"
#define SHELL_CHARS "!#$&;|<>(){}\"\'` \t\n\r\\"
#define WILD_CHARS "*?[]" /* We don't allow remote brace expansion */
BOOL is_filename_arg = !opt;
char *escapes = is_filename_arg ? SHELL_CHARS : WILD_CHARS SHELL_CHARS;
@@ -2539,7 +2707,16 @@ char *safe_arg(const char *opt, const char *arg)
escape_leading_tilde = 1;
}
for (f = arg; *f; f++) {
if (strchr(escapes, *f))
if (*f == '\\') {
/* Mirror the writer below: in filename mode a backslash
* before a wildcard is not doubled, so don't reserve a slot
* for it. The "f[1] &&" also avoids the strchr(WILD_CHARS,
* '\0') footgun (which matches the terminator) on a trailing
* backslash -- otherwise the counter and writer disagree and
* an uninitialized heap byte leaks into the result. */
if (!is_filename_arg || !(f[1] && strchr(WILD_CHARS, f[1])))
extras++;
} else if (strchr(escapes, *f))
extras++;
}
}
@@ -2564,7 +2741,7 @@ char *safe_arg(const char *opt, const char *arg)
*t++ = '\\';
while (*f) {
if (*f == '\\') {
if (!is_filename_arg || !strchr(WILD_CHARS, f[1]))
if (!is_filename_arg || !(f[1] && strchr(WILD_CHARS, f[1])))
*t++ = '\\';
} else if (strchr(escapes, *f))
*t++ = '\\';
@@ -2604,7 +2781,10 @@ void server_options(char **args, int *argc_p)
if (protect_args)
argstr[x++] = 's';
for (i = 0; i < verbose; i++)
/* `verbose` is unbounded (one increment per -v on our own command
* line), so an uncapped loop walks past argstr[64]. Anything beyond
* level ~5 is meaningless to the server anyway. */
for (i = 0; i < verbose && i < 9; i++)
argstr[x++] = 'v';
if (quiet && msgs2stderr)
@@ -2835,7 +3015,7 @@ void server_options(char **args, int *argc_p)
args[ac++] = "--super";
if (size_only)
args[ac++] = "--size-only";
if (do_stats)
if (vfs_stats)
args[ac++] = "--stats";
} else {
if (skip_compress)
@@ -2881,6 +3061,11 @@ void server_options(char **args, int *argc_p)
if (copy_unsafe_links)
args[ac++] = "--copy-unsafe-links";
/* --insecure-links is NOT forwarded: it is a local-only opt-out. A daemon
* governs its own confinement via the "insecure links" module parameter and
* drops a connection that sends --insecure-links; a remote-shell peer that
* wants it must be given it on its own side (e.g. via --rsync-path). */
if (safe_symlinks)
args[ac++] = "--safe-links";
+1 -1
View File
@@ -1,4 +1,4 @@
TARGETS := all install install-ssl-daemon install-all install-strip conf gen reconfigure restatus \
TARGETS := all install install-ssl-daemon install-all install-strip uninstall uninstall-ssl-daemon uninstall-all conf gen reconfigure restatus \
proto man clean cleantests distclean test check check29 check30 installcheck splint \
doxygen doxygen-upload finddead rrsync
-174
View File
@@ -1,174 +0,0 @@
#!/usr/bin/env -S python3 -B
# This script turns one or more diff files in the patches dir (which is
# expected to be a checkout of the rsync-patches git repo) into a branch
# in the main rsync git checkout. This allows the applied patch to be
# merged with the latest rsync changes and tested. To update the diff
# with the resulting changes, see the patch-update script.
import os, sys, re, argparse, glob
sys.path = ['packaging'] + sys.path
from pkglib import *
def main():
global created, info, local_branch
cur_branch, args.base_branch = check_git_state(args.base_branch, not args.skip_check, args.patches_dir)
local_branch = get_patch_branches(args.base_branch)
if args.delete_local_branches:
for name in sorted(local_branch):
branch = f"patch/{args.base_branch}/{name}"
cmd_chk(['git', 'branch', '-D', branch])
local_branch = set()
if args.add_missing:
for fn in sorted(glob.glob(f"{args.patches_dir}/*.diff")):
name = re.sub(r'\.diff$', '', re.sub(r'.+/', '', fn))
if name not in local_branch and fn not in args.patch_files:
args.patch_files.append(fn)
if not args.patch_files:
return
for fn in args.patch_files:
if not fn.endswith('.diff'):
die(f"Filename is not a .diff file: {fn}")
if not os.path.isfile(fn):
die(f"File not found: {fn}")
scanned = set()
info = { }
patch_list = [ ]
for fn in args.patch_files:
m = re.match(r'^(?P<dir>.*?)(?P<name>[^/]+)\.diff$', fn)
patch = argparse.Namespace(**m.groupdict())
if patch.name in scanned:
continue
patch.fn = fn
lines = [ ]
commit_hash = None
with open(patch.fn, 'r', encoding='utf-8') as fh:
for line in fh:
m = re.match(r'^based-on: (\S+)', line)
if m:
commit_hash = m[1]
break
if (re.match(r'^index .*\.\..* \d', line)
or re.match(r'^diff --git ', line)
or re.match(r'^--- (old|a)/', line)):
break
lines.append(re.sub(r'\s*\Z', "\n", line, 1))
info_txt = ''.join(lines).strip() + "\n"
lines = None
parent = args.base_branch
patches = re.findall(r'patch -p1 <%s/(\S+)\.diff' % args.patches_dir, info_txt)
if patches:
last = patches.pop()
if last != patch.name:
warn(f"No identity patch line in {patch.fn}")
patches.append(last)
if patches:
parent = patches.pop()
if parent not in scanned:
diff_fn = patch.dir + parent + '.diff'
if not os.path.isfile(diff_fn):
die(f"Failed to find parent of {patch.fn}: {parent}")
# Add parent to args.patch_files so that we will look for the
# parent's parent. Any duplicates will be ignored.
args.patch_files.append(diff_fn)
else:
warn(f"No patch lines found in {patch.fn}")
info[patch.name] = [ parent, info_txt, commit_hash ]
patch_list.append(patch)
created = set()
for patch in patch_list:
create_branch(patch)
cmd_chk(['git', 'checkout', args.base_branch])
def create_branch(patch):
if patch.name in created:
return
created.add(patch.name)
parent, info_txt, commit_hash = info[patch.name]
parent = argparse.Namespace(dir=patch.dir, name=parent, fn=patch.dir + parent + '.diff')
if parent.name == args.base_branch:
parent_branch = commit_hash if commit_hash else args.base_branch
else:
create_branch(parent)
parent_branch = '/'.join(['patch', args.base_branch, parent.name])
branch = '/'.join(['patch', args.base_branch, patch.name])
print("\n" + '=' * 64)
print(f"Processing {branch} ({parent_branch})")
if patch.name in local_branch:
cmd_chk(['git', 'branch', '-D', branch])
cmd_chk(['git', 'checkout', '-b', branch, parent_branch])
info_fn = 'PATCH.' + patch.name
with open(info_fn, 'w', encoding='utf-8') as fh:
fh.write(info_txt)
cmd_chk(['git', 'add', info_fn])
with open(patch.fn, 'r', encoding='utf-8') as fh:
patch_txt = fh.read()
cmd_run('patch -p1'.split(), input=patch_txt)
for fn in glob.glob('*.orig') + glob.glob('*/*.orig'):
os.unlink(fn)
pos = 0
new_file_re = re.compile(r'\nnew file mode (?P<mode>\d+)\s+--- /dev/null\s+\+\+\+ b/(?P<fn>.+)')
while True:
m = new_file_re.search(patch_txt, pos)
if not m:
break
os.chmod(m['fn'], int(m['mode'], 8))
cmd_chk(['git', 'add', m['fn']])
pos = m.end()
while True:
cmd_chk('git status'.split())
ans = input('Press Enter to commit, Ctrl-C to abort, or type a wild-name to add a new file: ')
if ans == '':
break
cmd_chk("git add " + ans, shell=True)
while True:
s = cmd_run(['git', 'commit', '-a', '-m', f"Creating branch from {patch.name}.diff."])
if not s.returncode:
break
s = cmd_run([os.environ.get('SHELL', '/bin/sh')])
if s.returncode:
die('Aborting due to shell error code')
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Create a git patch branch from an rsync patch file.", add_help=False)
parser.add_argument('--branch', '-b', dest='base_branch', metavar='BASE_BRANCH', default='master', help="The branch the patch is based on. Default: master.")
parser.add_argument('--add-missing', '-a', action='store_true', help="Add a branch for every patches/*.diff that doesn't have a branch.")
parser.add_argument('--skip-check', action='store_true', help="Skip the check that ensures starting with a clean branch.")
parser.add_argument('--delete', dest='delete_local_branches', action='store_true', help="Delete all the local patch/BASE/* branches, not just the ones that are being recreated.")
parser.add_argument('--patches-dir', '-p', metavar='DIR', default='patches', help="Override the location of the rsync-patches dir. Default: patches.")
parser.add_argument('patch_files', metavar='patches/DIFF_FILE', nargs='*', help="Specify what patch diff files to process. Default: all of them.")
parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
args = parser.parse_args()
main()
# vim: sw=4 et ft=python
+2
View File
@@ -0,0 +1,2 @@
- /generated-files/
- /binaries/
+3 -7
View File
@@ -1,6 +1,6 @@
Summary: A fast, versatile, remote (and local) file-copying tool
Name: rsync
Version: 3.4.0
Version: 3.5.0
%define fullversion %{version}
Release: 1
%define srcdir src
@@ -79,9 +79,5 @@ rm -rf $RPM_BUILD_ROOT
%dir /etc/rsync-ssl/certs
%changelog
* Wed Jan 15 2025 Wayne Davison <wayne@opencoder.net>
Released 3.4.0.
* Fri Mar 21 2008 Wayne Davison <wayne@opencoder.net>
Added installation of /etc/xinetd.d/rsync file and some commented-out
lines that demonstrate how to use the rsync-patches tar file.
* Thu Aug 13 2026 Rsync Project <rsync.project@gmail.com>
Released 3.5.0.
-244
View File
@@ -1,244 +0,0 @@
#!/usr/bin/env -S python3 -B
# This script is used to turn one or more of the "patch/BASE/*" branches
# into one or more diffs in the "patches" directory. Pass the option
# --gen if you want generated files in the diffs. Pass the name of
# one or more diffs if you want to just update a subset of all the
# diffs.
import os, sys, re, argparse, time, shutil
sys.path = ['packaging'] + sys.path
from pkglib import *
MAKE_GEN_CMDS = [
'./prepare-source'.split(),
'cd build && if test -f config.status ; then ./config.status ; else ../configure ; fi',
'make -C build gen'.split(),
]
TMP_DIR = "patches.gen"
os.environ['GIT_MERGE_AUTOEDIT'] = 'no'
def main():
global master_commit, parent_patch, description, completed, last_touch
if not os.path.isdir(args.patches_dir):
die(f'No "{args.patches_dir}" directory was found.')
if not os.path.isdir('.git'):
die('No ".git" directory present in the current dir.')
starting_branch, args.base_branch = check_git_state(args.base_branch, not args.skip_check, args.patches_dir)
master_commit = latest_git_hash(args.base_branch)
if cmd_txt_chk(['packaging/prep-auto-dir']).out == '':
die('You must setup an auto-build-save dir to use this script.')
if args.gen:
if os.path.lexists(TMP_DIR):
die(f'"{TMP_DIR}" must not exist in the current directory.')
gen_files = get_gen_files()
os.mkdir(TMP_DIR, 0o700)
for cmd in MAKE_GEN_CMDS:
cmd_chk(cmd)
cmd_chk(['rsync', '-a', *gen_files, f'{TMP_DIR}/master/'])
last_touch = int(time.time())
# Start by finding all patches so that we can load all possible parents.
patches = sorted(list(get_patch_branches(args.base_branch)))
parent_patch = { }
description = { }
for patch in patches:
branch = f"patch/{args.base_branch}/{patch}"
desc = ''
proc = cmd_pipe(['git', 'diff', '-U1000', f"{args.base_branch}...{branch}", '--', f"PATCH.{patch}"])
in_diff = False
for line in proc.stdout:
if in_diff:
if not re.match(r'^[ +]', line):
continue
line = line[1:]
m = re.search(r'patch -p1 <patches/(\S+)\.diff', line)
if m and m[1] != patch:
parpat = parent_patch[patch] = m[1]
if not parpat in patches:
die(f"Parent of {patch} is not a local branch: {parpat}")
desc += line
elif re.match(r'^@@ ', line):
in_diff = True
description[patch] = desc
proc.communicate()
if args.patch_files: # Limit the list of patches to actually process
valid_patches = patches
patches = [ ]
for fn in args.patch_files:
name = re.sub(r'\.diff$', '', re.sub(r'.+/', '', fn))
if name not in valid_patches:
die(f"Local branch not available for patch: {name}")
patches.append(name)
completed = set()
for patch in patches:
if patch in completed:
continue
if not update_patch(patch):
break
if args.gen:
shutil.rmtree(TMP_DIR)
while last_touch >= int(time.time()):
time.sleep(1)
cmd_chk(['git', 'checkout', starting_branch])
cmd_chk(['packaging/prep-auto-dir'], discard='output')
def update_patch(patch):
global last_touch
completed.add(patch) # Mark it as completed early to short-circuit any (bogus) dependency loops.
parent = parent_patch.get(patch, None)
if parent:
if parent not in completed:
if not update_patch(parent):
return 0
based_on = parent = f"patch/{args.base_branch}/{parent}"
else:
parent = args.base_branch
based_on = master_commit
print(f"======== {patch} ========")
while args.gen and last_touch >= int(time.time()):
time.sleep(1)
branch = f"patch/{args.base_branch}/{patch}"
s = cmd_run(['git', 'checkout', branch])
if s.returncode != 0:
return 0
s = cmd_run(['git', 'merge', based_on])
ok = s.returncode == 0
skip_shell = False
if not ok or args.cmd or args.make or args.shell:
cmd_chk(['packaging/prep-auto-dir'], discard='output')
if not ok:
print(f'"git merge {based_on}" incomplete -- please fix.')
if not run_a_shell(parent, patch):
return 0
if not args.make and not args.cmd:
skip_shell = True
if args.make:
if cmd_run(['packaging/smart-make']).returncode != 0:
if not run_a_shell(parent, patch):
return 0
if not args.cmd:
skip_shell = True
if args.cmd:
if cmd_run(args.cmd).returncode != 0:
if not run_a_shell(parent, patch):
return 0
skip_shell = True
if args.shell and not skip_shell:
if not run_a_shell(parent, patch):
return 0
with open(f"{args.patches_dir}/{patch}.diff", 'w', encoding='utf-8') as fh:
fh.write(description[patch])
fh.write(f"\nbased-on: {based_on}\n")
if args.gen:
gen_files = get_gen_files()
for cmd in MAKE_GEN_CMDS:
cmd_chk(cmd)
cmd_chk(['rsync', '-a', *gen_files, f"{TMP_DIR}/{patch}/"])
else:
gen_files = [ ]
last_touch = int(time.time())
proc = cmd_pipe(['git', 'diff', based_on])
skipping = False
for line in proc.stdout:
if skipping:
if not re.match(r'^diff --git a/', line):
continue
skipping = False
elif re.match(r'^diff --git a/PATCH', line):
skipping = True
continue
if not re.match(r'^index ', line):
fh.write(line)
proc.communicate()
if args.gen:
e_tmp_dir = re.escape(TMP_DIR)
diff_re = re.compile(r'^(diff -Nurp) %s/[^/]+/(.*?) %s/[^/]+/(.*)' % (e_tmp_dir, e_tmp_dir))
minus_re = re.compile(r'^\-\-\- %s/[^/]+/([^\t]+)\t.*' % e_tmp_dir)
plus_re = re.compile(r'^\+\+\+ %s/[^/]+/([^\t]+)\t.*' % e_tmp_dir)
if parent == args.base_branch:
parent_dir = 'master'
else:
m = re.search(r'([^/]+)$', parent)
parent_dir = m[1]
proc = cmd_pipe(['diff', '-Nurp', f"{TMP_DIR}/{parent_dir}", f"{TMP_DIR}/{patch}"])
for line in proc.stdout:
line = diff_re.sub(r'\1 a/\2 b/\3', line)
line = minus_re.sub(r'--- a/\1', line)
line = plus_re.sub(r'+++ b/\1', line)
fh.write(line)
proc.communicate()
return 1
def run_a_shell(parent, patch):
m = re.search(r'([^/]+)$', parent)
parent_dir = m[1]
os.environ['PS1'] = f"[{parent_dir}] {patch}: "
while True:
s = cmd_run([os.environ.get('SHELL', '/bin/sh')])
if s.returncode != 0:
ans = input("Abort? [n/y] ")
if re.match(r'^y', ans, flags=re.I):
return False
continue
cur_branch, is_clean, status_txt = check_git_status(0)
if is_clean:
break
print(status_txt, end='')
cmd_run('rm -f build/*.o build/*/*.o')
return True
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Turn a git branch back into a diff files in the patches dir.", add_help=False)
parser.add_argument('--branch', '-b', dest='base_branch', metavar='BASE_BRANCH', default='master', help="The branch the patch is based on. Default: master.")
parser.add_argument('--skip-check', action='store_true', help="Skip the check that ensures starting with a clean branch.")
parser.add_argument('--make', '-m', action='store_true', help="Run the smart-make script in every patch branch.")
parser.add_argument('--cmd', '-c', help="Run a command in every patch branch.")
parser.add_argument('--shell', '-s', action='store_true', help="Launch a shell for every patch/BASE/* branch updated, not just when a conflict occurs.")
parser.add_argument('--gen', metavar='DIR', nargs='?', const='', help='Include generated files. Optional DIR value overrides the default of using the "patches" dir.')
parser.add_argument('--patches-dir', '-p', metavar='DIR', default='patches', help="Override the location of the rsync-patches dir. Default: patches.")
parser.add_argument('patch_files', metavar='patches/DIFF_FILE', nargs='*', help="Specify what patch diff files to process. Default: all of them.")
parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
args = parser.parse_args()
if args.gen == '':
args.gen = args.patches_dir
elif args.gen is not None:
args.patches_dir = args.gen
main()
# vim: sw=4 et ft=python
+9 -2
View File
@@ -206,7 +206,14 @@ def get_rsync_version():
die("Unable to find RSYNC_VERSION define in version.h")
def get_NEWS_version_info():
def get_NEWS_version_info(skip_version=None):
"""Return (last_version, its protocol version, {version: protocol-change date}).
skip_version lets the caller exclude the version it is about to release.
Its NEWS entry may already carry a release date -- dated by hand, or by an
earlier run of --step-3-tweak -- and would otherwise be reported as the
PREVIOUS release, which is both wrong and fatal when it has no table row yet.
"""
rel_re = re.compile(r'^\| \S{2} \w{3} \d{4}\s+\|\s+(?P<ver>\d+\.\d+\.\d+)\s+\|\s+(?P<pdate>\d{2} \w{3} \d{4})?\s+\|\s+(?P<pver>\d+)\s+\|')
last_version = last_protocol_version = None
pdate = { }
@@ -215,7 +222,7 @@ def get_NEWS_version_info():
for line in fh:
if not last_version: # Find the first non-dev|pre version with a release date.
m = re.search(r'rsync (\d+\.\d+\.\d+) .*\d\d\d\d', line)
if m:
if m and m[1] != skip_version:
last_version = m[1]
m = rel_re.match(line)
if m:
-414
View File
@@ -1,414 +0,0 @@
#!/usr/bin/env -S python3 -B
# This script expects the directory ~/samba-rsync-ftp to exist and to be a
# copy of the /home/ftp/pub/rsync dir on samba.org. When the script is done,
# the git repository in the current directory will be updated, and the local
# ~/samba-rsync-ftp dir will be ready to be rsynced to samba.org. See the
# script samba-rsync for an easy way to initialize the local ftp copy and to
# thereafter update the remote files from your local copy.
# This script also expects to be able to gpg sign the resulting tar files
# using your default gpg key. Make sure that the html download.html file
# has a link to the relevant keys that are authorized to sign the tar files
# and also make sure that the following commands work as expected:
#
# touch TeMp
# gpg --sign TeMp
# gpg --verify TeMp.gpg
# gpg --sign TeMp
# rm TeMp*
#
# The second time you sign the file it should NOT prompt you for your password
# (unless the timeout period has passed). It will prompt about overriding the
# existing TeMp.gpg file, though.
import os, sys, re, argparse, glob, shutil, signal
from datetime import datetime
from getpass import getpass
sys.path = ['packaging'] + sys.path
from pkglib import *
os.environ['LESS'] = 'mqeiXR'; # Make sure that -F is turned off and -R is turned on.
dest = os.environ['HOME'] + '/samba-rsync-ftp'
ORIGINAL_PATH = os.environ['PATH']
def main():
if not os.path.isfile('packaging/release-rsync'):
die('You must run this script from the top of your rsync checkout.')
now = datetime.now()
cl_today = now.strftime('* %a %b %d %Y')
year = now.strftime('%Y')
ztoday = now.strftime('%d %b %Y')
today = ztoday.lstrip('0')
curdir = os.getcwd()
signal.signal(signal.SIGINT, signal_handler)
if cmd_txt_chk(['packaging/prep-auto-dir']).out == '':
die('You must setup an auto-build-save dir to use this script.');
auto_dir, gen_files = get_gen_files(True)
gen_pathnames = [ os.path.join(auto_dir, fn) for fn in gen_files ]
dash_line = '=' * 74
print(f"""\
{dash_line}
== This will release a new version of rsync onto an unsuspecting world. ==
{dash_line}
""")
with open('build/rsync.1') as fh:
for line in fh:
if line.startswith(r'.\" prefix='):
doc_prefix = line.split('=')[1].strip()
if doc_prefix != '/usr':
warn(f"*** The documentation was built with prefix {doc_prefix} instead of /usr ***")
die("*** Read the md2man script for a way to override this. ***")
break
if line.startswith('.P'):
die("Failed to find the prefix comment at the start of the rsync.1 manpage.")
if not os.path.isdir(dest):
die(dest, "dest does not exist")
if not os.path.isdir('.git'):
die("There is no .git dir in the current directory.")
if os.path.lexists('a'):
die('"a" must not exist in the current directory.')
if os.path.lexists('b'):
die('"b" must not exist in the current directory.')
if os.path.lexists('patches.gen'):
die('"patches.gen" must not exist in the current directory.')
check_git_state(args.master_branch, True, 'patches')
curversion = get_rsync_version()
# All version values are strings!
lastversion, last_protocol_version, pdate = get_NEWS_version_info()
protocol_version, subprotocol_version = get_protocol_versions()
version = curversion
m = re.search(r'pre(\d+)', version)
if m:
version = re.sub(r'pre\d+', 'pre' + str(int(m[1]) + 1), version)
else:
version = version.replace('dev', 'pre1')
ans = input(f"Please enter the version number of this release: [{version}] ")
if ans == '.':
version = re.sub(r'pre\d+', '', version)
elif ans != '':
version = ans
if not re.match(r'^[\d.]+(pre\d+)?$', version):
die(f'Invalid version: "{version}"')
v_ver = 'v' + version
rsync_ver = 'rsync-' + version
if os.path.lexists(rsync_ver):
die(f'"{rsync_ver}" must not exist in the current directory.')
out = cmd_txt_chk(['git', 'tag', '-l', v_ver]).out
if out != '':
print(f"Tag {v_ver} already exists.")
ans = input("\nDelete tag or quit? [Q/del] ")
if not re.match(r'^del', ans, flags=re.I):
die("Aborted")
cmd_chk(['git', 'tag', '-d', v_ver])
if os.path.isdir('patches/.git'):
cmd_chk(f"cd patches && git tag -d '{v_ver}'")
version = re.sub(r'[-.]*pre[-.]*', 'pre', version)
if 'pre' in version and not curversion.endswith('dev'):
lastversion = curversion
ans = input(f"Enter the previous version to produce a patch against: [{lastversion}] ")
if ans != '':
lastversion = ans
lastversion = re.sub(r'[-.]*pre[-.]*', 'pre', lastversion)
rsync_lastver = 'rsync-' + lastversion
if os.path.lexists(rsync_lastver):
die(f'"{rsync_lastver}" must not exist in the current directory.')
m = re.search(r'(pre\d+)', version)
pre = m[1] if m else ''
release = '0.1' if pre else '1'
ans = input(f"Please enter the RPM release number of this release: [{release}] ")
if ans != '':
release = ans
if pre:
release += '.' + pre
finalversion = re.sub(r'pre\d+', '', version)
proto_changed = protocol_version != last_protocol_version
if proto_changed:
if finalversion in pdate:
proto_change_date = pdate[finalversion]
else:
while True:
ans = input("On what date did the protocol change to {protocol_version} get checked in? (dd Mmm yyyy) ")
if re.match(r'^\d\d \w\w\w \d\d\d\d$', ans):
break
proto_change_date = ans
else:
proto_change_date = ' ' * 11
if 'pre' in lastversion:
if not pre:
die("You should not diff a release version against a pre-release version.")
srcdir = srcdiffdir = lastsrcdir = 'src-previews'
skipping = ' ** SKIPPING **'
elif pre:
srcdir = srcdiffdir = 'src-previews'
lastsrcdir = 'src'
skipping = ' ** SKIPPING **'
else:
srcdir = lastsrcdir = 'src'
srcdiffdir = 'src-diffs'
skipping = ''
print(f"""
{dash_line}
version is "{version}"
lastversion is "{lastversion}"
dest is "{dest}"
curdir is "{curdir}"
srcdir is "{srcdir}"
srcdiffdir is "{srcdiffdir}"
lastsrcdir is "{lastsrcdir}"
release is "{release}"
About to:
- tweak SUBPROTOCOL_VERSION in rsync.h, if needed
- tweak the version in version.h and the spec files
- tweak NEWS.md to ensure header values are correct
- generate configure.sh, config.h.in, and proto.h
- page through the differences
""")
ans = input("<Press Enter to continue> ")
specvars = {
'Version:': finalversion,
'Release:': release,
'%define fullversion': f'%{{version}}{pre}',
'Released': version + '.',
'%define srcdir': srcdir,
}
tweak_files = 'version.h rsync.h'.split()
tweak_files += glob.glob('packaging/*.spec')
tweak_files += glob.glob('packaging/*/*.spec')
for fn in tweak_files:
with open(fn, 'r', encoding='utf-8') as fh:
old_txt = txt = fh.read()
if fn == 'version.h':
x_re = re.compile(r'^(#define RSYNC_VERSION).*', re.M)
msg = f"Unable to update RSYNC_VERSION in {fn}"
txt = replace_or_die(x_re, r'\1 "%s"' % version, txt, msg)
elif '.spec' in fn:
for var, val in specvars.items():
x_re = re.compile(r'^%s .*' % re.escape(var), re.M)
txt = replace_or_die(x_re, var + ' ' + val, txt, f"Unable to update {var} in {fn}")
x_re = re.compile(r'^\* \w\w\w \w\w\w \d\d \d\d\d\d (.*)', re.M)
txt = replace_or_die(x_re, r'%s \1' % cl_today, txt, f"Unable to update ChangeLog header in {fn}")
elif fn == 'rsync.h':
x_re = re.compile('(#define\s+SUBPROTOCOL_VERSION)\s+(\d+)')
repl = lambda m: m[1] + ' ' + ('0' if not pre or not proto_changed else '1' if m[2] == '0' else m[2])
txt = replace_or_die(x_re, repl, txt, f"Unable to find SUBPROTOCOL_VERSION define in {fn}")
elif fn == 'NEWS.md':
efv = re.escape(finalversion)
x_re = re.compile(r'^# NEWS for rsync %s \(UNRELEASED\)\s+## Changes in this version:\n' % efv
+ r'(\n### PROTOCOL NUMBER:\s+- The protocol number was changed to \d+\.\n)?')
rel_day = 'UNRELEASED' if pre else today
repl = (f'# NEWS for rsync {finalversion} ({rel_day})\n\n'
+ '## Changes in this version:\n')
if proto_changed:
repl += f'\n### PROTOCOL NUMBER:\n\n - The protocol number was changed to {protocol_version}.\n'
good_top = re.sub(r'\(.*?\)', '(UNRELEASED)', repl, 1)
msg = f"The top lines of {fn} are not in the right format. It should be:\n" + good_top
txt = replace_or_die(x_re, repl, txt, msg)
x_re = re.compile(r'^(\| )(\S{2} \S{3} \d{4})(\s+\|\s+%s\s+\| ).{11}(\s+\| )\S{2}(\s+\|+)$' % efv, re.M)
repl = lambda m: m[1] + (m[2] if pre else ztoday) + m[3] + proto_change_date + m[4] + protocol_version + m[5]
txt = replace_or_die(x_re, repl, txt, f'Unable to find "| ?? ??? {year} | {finalversion} | ... |" line in {fn}')
else:
die(f"Unrecognized file in tweak_files: {fn}")
if txt != old_txt:
print(f"Updating {fn}")
with open(fn, 'w', encoding='utf-8') as fh:
fh.write(txt)
cmd_chk(['packaging/year-tweak'])
print(dash_line)
cmd_run("git diff".split())
srctar_name = f"{rsync_ver}.tar.gz"
pattar_name = f"rsync-patches-{version}.tar.gz"
diff_name = f"{rsync_lastver}-{version}.diffs.gz"
srctar_file = os.path.join(dest, srcdir, srctar_name)
pattar_file = os.path.join(dest, srcdir, pattar_name)
diff_file = os.path.join(dest, srcdiffdir, diff_name)
lasttar_file = os.path.join(dest, lastsrcdir, rsync_lastver + '.tar.gz')
print(f"""\
{dash_line}
About to:
- git commit all changes
- run a full build, ensuring that the manpages & configure.sh are up-to-date
- merge the {args.master_branch} branch into the patch/{args.master_branch}/* branches
- update the files in the "patches" dir and OPTIONALLY (if you type 'y') to
run patch-update with the --make option (which opens a shell on error)
""")
ans = input("<Press Enter OR 'y' to continue> ")
s = cmd_run(['git', 'commit', '-a', '-m', f'Preparing for release of {version} [buildall]'])
if s.returncode:
die('Aborting')
cmd_chk('touch configure.ac && packaging/smart-make && make gen')
print('Creating any missing patch branches.')
s = cmd_run(f'packaging/branch-from-patch --branch={args.master_branch} --add-missing')
if s.returncode:
die('Aborting')
print('Updating files in "patches" dir ...')
s = cmd_run(f'packaging/patch-update --branch={args.master_branch}')
if s.returncode:
die('Aborting')
if re.match(r'^y', ans, re.I):
print(f'\nRunning smart-make on all "patch/{args.master_branch}/*" branches ...')
cmd_run(f"packaging/patch-update --branch={args.master_branch} --skip-check --make")
if os.path.isdir('patches/.git'):
s = cmd_run(f"cd patches && git commit -a -m 'The patches for {version}.'")
if s.returncode:
die('Aborting')
print(f"""\
{dash_line}
About to:
- create signed tag for this release: {v_ver}
- create release diffs, "{diff_name}"
- create release tar, "{srctar_name}"
- generate {rsync_ver}/patches/* files
- create patches tar, "{pattar_name}"
- update top-level README.md, NEWS.md, TODO, and ChangeLog
- update top-level rsync*.html manpages
- gpg-sign the release files
- update hard-linked top-level release files{skipping}
""")
ans = input("<Press Enter to continue> ")
# TODO: is there a better way to ensure that our passphrase is in the agent?
cmd_run("touch TeMp; gpg --sign TeMp; rm TeMp*")
out = cmd_txt(f"git tag -s -m 'Version {version}.' {v_ver}", capture='combined').out
print(out, end='')
if 'bad passphrase' in out or 'failed' in out:
die('Aborting')
if os.path.isdir('patches/.git'):
out = cmd_txt(f"cd patches && git tag -s -m 'Version {version}.' {v_ver}", capture='combined').out
print(out, end='')
if 'bad passphrase' in out or 'failed' in out:
die('Aborting')
os.environ['PATH'] = ORIGINAL_PATH
# Extract the generated files from the old tar.
tweaked_gen_files = [ os.path.join(rsync_lastver, fn) for fn in gen_files ]
cmd_run(['tar', 'xzf', lasttar_file, *tweaked_gen_files])
os.rename(rsync_lastver, 'a')
print(f"Creating {diff_file} ...")
cmd_chk(['rsync', '-a', *gen_pathnames, 'b/'])
sed_script = r's:^((---|\+\+\+) [ab]/[^\t]+)\t.*:\1:' # CAUTION: must not contain any single quotes!
cmd_chk(f"(git diff v{lastversion} {v_ver} -- ':!.github'; diff -upN a b | sed -r '{sed_script}') | gzip -9 >{diff_file}")
shutil.rmtree('a')
os.rename('b', rsync_ver)
print(f"Creating {srctar_file} ...")
cmd_chk(f"git archive --format=tar --prefix={rsync_ver}/ {v_ver} | tar xf -")
cmd_chk(f"support/git-set-file-times --quiet --prefix={rsync_ver}/")
cmd_chk(['fakeroot', 'tar', 'czf', srctar_file, '--exclude=.github', rsync_ver])
shutil.rmtree(rsync_ver)
print(f'Updating files in "{rsync_ver}/patches" dir ...')
os.mkdir(rsync_ver, 0o755)
os.mkdir(f"{rsync_ver}/patches", 0o755)
cmd_chk(f"packaging/patch-update --skip-check --branch={args.master_branch} --gen={rsync_ver}/patches".split())
print(f"Creating {pattar_file} ...")
cmd_chk(['fakeroot', 'tar', 'chzf', pattar_file, rsync_ver + '/patches'])
shutil.rmtree(rsync_ver)
print(f"Updating the other files in {dest} ...")
md_files = 'README.md NEWS.md INSTALL.md'.split()
html_files = [ fn for fn in gen_pathnames if fn.endswith('.html') ]
cmd_chk(['rsync', '-a', *md_files, *html_files, dest])
cmd_chk(["./md-convert", "--dest", dest, *md_files])
cmd_chk(f"git log --name-status | gzip -9 >{dest}/ChangeLog.gz")
for fn in (srctar_file, pattar_file, diff_file):
asc_fn = fn + '.asc'
if os.path.lexists(asc_fn):
os.unlink(asc_fn)
res = cmd_run(['gpg', '--batch', '-ba', fn])
if res.returncode != 0 and res.returncode != 2:
die("gpg signing failed")
if not pre:
for find in f'{dest}/rsync-*.gz {dest}/rsync-*.asc {dest}/src-previews/rsync-*diffs.gz*'.split():
for fn in glob.glob(find):
os.unlink(fn)
top_link = [
srctar_file, f"{srctar_file}.asc",
pattar_file, f"{pattar_file}.asc",
diff_file, f"{diff_file}.asc",
]
for fn in top_link:
os.link(fn, re.sub(r'/src(-\w+)?/', '/', fn))
print(f"""\
{dash_line}
Local changes are done. When you're satisfied, push the git repository
and rsync the release files. Remember to announce the release on *BOTH*
rsync-announce@lists.samba.org and rsync@lists.samba.org (and the web)!
""")
def replace_or_die(regex, repl, txt, die_msg):
m = regex.search(txt)
if not m:
die(die_msg)
return regex.sub(repl, txt, 1)
def signal_handler(sig, frame):
die("\nAborting due to SIGINT.")
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Prepare a new release of rsync in the git repo & ftp dir.", add_help=False)
parser.add_argument('--branch', '-b', dest='master_branch', default='master', help="The branch to release. Default: master.")
parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
args = parser.parse_args()
main()
# vim: sw=4 et ft=python
+714
View File
@@ -0,0 +1,714 @@
#!/usr/bin/env python3
# Step-based release script for rsync. Each step is a separate invocation
# selected by a --step-N-XX option, so the maintainer drives the release
# manually one piece at a time.
#
# All persistent state and working files live in ../release/ (a sibling of
# the rsync git checkout):
#
# ../release/rsync-ftp/ mirror of samba.org:/home/ftp/pub/rsync
# ../release/rsync-html/ release-time snapshot of the html site
# ../release/work/ scratch space for tarball / diff staging
# ../release/release-state.json info shared between steps
#
# The rsync-patches archive is no longer maintained and has been dropped.
#
# Run "packaging/release.py --list" to see the step list.
import os, sys, re, argparse, glob, shutil, json, signal, subprocess
from datetime import datetime
sys.path = ['packaging'] + sys.path
from pkglib import (
warn, die, cmd_run, cmd_chk, cmd_txt, cmd_txt_chk, cmd_pipe,
check_git_state, get_rsync_version,
get_NEWS_version_info, get_protocol_versions,
)
# ---------- Paths ----------
RELEASE_DIR = os.path.realpath('../release')
FTP_DIR = os.path.join(RELEASE_DIR, 'rsync-ftp')
HTML_DIR = os.path.join(RELEASE_DIR, 'rsync-html')
WORK_DIR = os.path.join(RELEASE_DIR, 'work')
STATE_FILE = os.path.join(RELEASE_DIR, 'release-state.json')
# The rsync-web/ subdirectory in the rsync source tree is the source-of-truth
# for the git-tracked html content. step-1-fetch snapshots it into HTML_DIR
# for the release flow, where it can be edited or augmented with server-side
# content before step-11-push-html sends it to samba.org.
HTML_SRC = os.path.realpath('rsync-web')
FTP_REMOTE_PATH = '/home/ftp/pub/rsync'
HTML_REMOTE_PATH = '/home/httpd/html/rsync'
# Files that ./configure + make produce and that the release tarball / diff
# need to bundle alongside the git-tracked source. Mirrors the GENFILES
# definition in Makefile.in (with rrsync.1{,.html} since we always configure
# --with-rrsync in --step-4-build).
GEN_FILES = [
'configure.sh',
'aclocal.m4',
'config.h.in',
'rsync.1', 'rsync.1.html',
'rsync-ssl.1', 'rsync-ssl.1.html',
'rsyncd.conf.5', 'rsyncd.conf.5.html',
'rrsync.1', 'rrsync.1.html',
]
# ---------- Step registry ----------
STEPS = [
('step-1-fetch', 'mirror ../release/rsync-ftp from samba.org and snapshot ../release/rsync-html from rsync-web/'),
('step-2-prepare', 'gather release info interactively and write release-state.json'),
('step-3-tweak', 'update version.h, rsync.h, NEWS.md, and packaging/*.spec'),
('step-4-build', 'run smart-make + make gen'),
('step-5-commit', 'git commit -a (commit the prepared release changes)'),
('step-6-tag', 'create the gpg-signed git tag'),
('step-7-tarball', 'build the source tarball and diffs.gz against the previous release'),
('step-8-update-ftp', 'refresh README/NEWS/INSTALL/html in the ftp dir, regen ChangeLog.gz, gpg-sign tarballs'),
('step-9-toplinks', 'hard-link top-level release files (final releases only)'),
('step-10-push-ftp', 'rsync ../release/rsync-ftp/ to samba.org'),
('step-11-push-html', 'rsync ../release/rsync-html/ to samba.org (after any manual edits)'),
('step-12-push-git', 'print the git push commands for you to run'),
]
STEP_FLAGS = [s[0] for s in STEPS]
DASH_LINE = '=' * 74
# ---------- State helpers ----------
def load_state():
if not os.path.isfile(STATE_FILE):
die(f"{STATE_FILE} not found. Run --step-2-prepare first.")
with open(STATE_FILE, 'r', encoding='utf-8') as fh:
return json.load(fh)
def save_state(state):
os.makedirs(RELEASE_DIR, exist_ok=True)
with open(STATE_FILE, 'w', encoding='utf-8') as fh:
json.dump(state, fh, indent=2, sort_keys=True)
fh.write('\n')
def require_samba_host():
host = os.environ.get('RSYNC_SAMBA_HOST', '')
if not host.endswith('.samba.org'):
die("Set RSYNC_SAMBA_HOST in your environment to the samba hostname (e.g. hr3.samba.org).")
return host
def require_top_of_checkout():
if not os.path.isfile('packaging/release.py'):
die("Run this script from the top of your rsync checkout.")
if not os.path.exists('.git'):
die("There is no .git in the current directory (run from the top of a git checkout or worktree).")
def replace_or_die(regex, repl, txt, die_msg):
m = regex.search(txt)
if not m:
die(die_msg)
return regex.sub(repl, txt, 1)
def section(title):
print(f"\n{DASH_LINE}\n== {title}\n{DASH_LINE}")
def confirm(prompt, default_no=True):
suffix = '[n] ' if default_no else '[y] '
ans = input(f"{prompt} {suffix}").strip().lower()
if default_no:
return ans.startswith('y')
return ans == '' or ans.startswith('y')
# ---------- Step 1: fetch ftp + html ----------
def step_1_fetch(args):
host = require_samba_host()
os.makedirs(RELEASE_DIR, exist_ok=True)
os.makedirs(WORK_DIR, exist_ok=True)
section(f"Fetching ftp dir into {FTP_DIR}")
if not os.path.isdir(FTP_DIR):
os.makedirs(FTP_DIR)
# packaging/ftp.filt is the authoritative copy of the .filt filter file
# that controls which subtrees rsync excludes from the FTP mirror.
# Seed FTP_DIR/.filt from it so the bundled version is what step-1's
# rsync uses here, and so step-10-push-ftp propagates it back to the
# server. --exclude=/.filt below stops the server's copy from
# overwriting our bundled one on the way down.
filt = os.path.join(FTP_DIR, '.filt')
bundled_filt = os.path.realpath('packaging/ftp.filt')
if not os.path.isfile(bundled_filt):
die(f"{bundled_filt} not found; cannot seed .filt for the FTP pull.")
shutil.copyfile(bundled_filt, filt)
cmd_chk(['rsync', '-aivOHP', f'-f:_{filt}', '--exclude=/.filt',
f'{host}:{FTP_REMOTE_PATH}/', f'{FTP_DIR}/'])
section(f"Snapshotting html dir from {HTML_SRC} into {HTML_DIR}")
if not os.path.isdir(HTML_SRC):
die(f"{HTML_SRC} not found. This should be the in-tree rsync-web/ "
f"subdirectory; something is wrong with your checkout.")
os.makedirs(HTML_DIR, exist_ok=True)
cmd_chk(['rsync', '-aiv', f'{HTML_SRC}/', f'{HTML_DIR}/'])
# Then mirror non-git html content from the server, skipping files that
# the html git already provides (driven by the 'filt' file in HTML_DIR).
filt = os.path.join(HTML_DIR, 'filt')
if os.path.exists(filt):
tmp_filt = os.path.join(HTML_DIR, 'tmp-filt')
cmd_chk(f"sed -n -e 's/[-P]/H/p' '{filt}' >'{tmp_filt}'")
cmd_chk(['rsync', '-aivOHP', f'-f._{tmp_filt}',
f'{host}:{HTML_REMOTE_PATH}/', f'{HTML_DIR}/'])
os.unlink(tmp_filt)
print(f"\nFetch complete. Local dirs are now in {RELEASE_DIR}.")
# ---------- Step 2: prepare ----------
def step_2_prepare(args):
require_top_of_checkout()
os.makedirs(RELEASE_DIR, exist_ok=True)
if not os.path.isdir(FTP_DIR):
die(f"{FTP_DIR} does not exist. Run --step-1-fetch first.")
now = datetime.now().astimezone()
cl_today = now.strftime('* %a %b %d %Y')
year = now.strftime('%Y')
ztoday = now.strftime('%d %b %Y')
today = ztoday.lstrip('0')
tz_now = now.strftime('%z')
tz_num = tz_now[0:1].replace('+', '') + str(float(tz_now[1:3]) + float(tz_now[3:]) / 60)
curversion = get_rsync_version()
# Skip the version we are releasing: its NEWS entry may already be dated,
# in which case it would otherwise be taken for the previous release.
lastversion, last_protocol_version, pdate = get_NEWS_version_info(
skip_version=re.sub(r'(pre\d+|dev)$', '', curversion))
protocol_version, subprotocol_version = get_protocol_versions()
# Default next version: bump preN, or move dev -> pre1.
version = curversion
m = re.search(r'pre(\d+)', version)
if m:
version = re.sub(r'pre\d+', 'pre' + str(int(m[1]) + 1), version)
else:
version = version.replace('dev', 'pre1')
print(f"\nCurrent version (version.h): {curversion}")
print(f"Last released version (NEWS.md): {lastversion}")
print(f"Current protocol version: {protocol_version} (last released: {last_protocol_version})")
ans = input(f"\nVersion to release [{version}, '.' to drop the preN suffix]: ").strip()
if ans == '.':
version = re.sub(r'pre\d+', '', version)
elif ans:
version = ans
if not re.match(r'^[\d.]+(pre\d+)?$', version):
die(f'Invalid version: "{version}"')
version = re.sub(r'[-.]*pre[-.]*', 'pre', version)
if 'pre' in version and not curversion.endswith('dev'):
lastversion = curversion
ans = input(f"Previous version to diff against [{lastversion}]: ").strip()
if ans:
lastversion = ans
lastversion = re.sub(r'[-.]*pre[-.]*', 'pre', lastversion)
m = re.search(r'(pre\d+)', version)
pre = m[1] if m else ''
finalversion = re.sub(r'pre\d+', '', version)
release = '0.1' if pre else '1'
ans = input(f"RPM release number [{release}]: ").strip()
if ans:
release = ans
if pre:
release += '.' + pre
proto_changed = protocol_version != last_protocol_version
if proto_changed:
if finalversion in pdate:
proto_change_date = pdate[finalversion]
else:
while True:
ans = input(f"Date the protocol changed to {protocol_version} (dd Mmm yyyy): ").strip()
if re.match(r'^\d\d \w\w\w \d\d\d\d$', ans):
break
proto_change_date = ans
else:
proto_change_date = ' ' * 11
if 'pre' in lastversion:
if not pre:
die("Refusing to diff a release version against a pre-release version.")
srcdir = srcdiffdir = lastsrcdir = 'src-previews'
elif pre:
srcdir = srcdiffdir = 'src-previews'
lastsrcdir = 'src'
else:
srcdir = lastsrcdir = 'src'
srcdiffdir = 'src-diffs'
state = {
'version': version,
'lastversion': lastversion,
'finalversion': finalversion,
'pre': pre,
'release': release,
'protocol_version': protocol_version,
'subprotocol_version': subprotocol_version,
'proto_changed': proto_changed,
'proto_change_date': proto_change_date,
'srcdir': srcdir,
'srcdiffdir': srcdiffdir,
'lastsrcdir': lastsrcdir,
'today': today,
'ztoday': ztoday,
'cl_today': cl_today,
'year': year,
'tz_num': tz_num,
'master_branch': args.master_branch,
}
save_state(state)
section("Release info")
for k in ('version', 'lastversion', 'release', 'srcdir', 'srcdiffdir', 'lastsrcdir',
'protocol_version', 'proto_changed', 'proto_change_date'):
print(f" {k}: {state[k]}")
print(f"\nWrote {STATE_FILE}. Re-run --step-2-prepare to change anything.")
# ---------- Step 3: tweak version files ----------
def step_3_tweak(args):
require_top_of_checkout()
state = load_state()
version = state['version']
finalversion = state['finalversion']
pre = state['pre']
release = state['release']
today = state['today']
ztoday = state['ztoday']
cl_today = state['cl_today']
year = state['year']
tz_num = state['tz_num']
proto_changed = state['proto_changed']
proto_change_date = state['proto_change_date']
protocol_version = state['protocol_version']
srcdir = state['srcdir']
specvars = {
'Version:': finalversion,
'Release:': release,
'%define fullversion': f'%{{version}}{pre}',
'Released': version + '.',
'%define srcdir': srcdir,
}
tweak_files = ['version.h', 'rsync.h', 'NEWS.md']
tweak_files += glob.glob('packaging/*.spec')
tweak_files += glob.glob('packaging/*/*.spec')
for fn in tweak_files:
with open(fn, 'r', encoding='utf-8') as fh:
old_txt = txt = fh.read()
if fn == 'version.h':
x_re = re.compile(r'^(#define RSYNC_VERSION).*', re.M)
txt = replace_or_die(x_re, r'\1 "%s"' % version, txt,
f"Unable to update RSYNC_VERSION in {fn}")
x_re = re.compile(r'^(#define MAINTAINER_TZ_OFFSET).*', re.M)
txt = replace_or_die(x_re, r'\1 ' + tz_num, txt,
f"Unable to update MAINTAINER_TZ_OFFSET in {fn}")
elif fn == 'rsync.h':
x_re = re.compile(r'(#define\s+SUBPROTOCOL_VERSION)\s+(\d+)')
repl = lambda m: m[1] + ' ' + (
'0' if not pre or not proto_changed
else '1' if m[2] == '0'
else m[2])
txt = replace_or_die(x_re, repl, txt,
f"Unable to find SUBPROTOCOL_VERSION in {fn}")
elif fn == 'NEWS.md':
efv = re.escape(finalversion)
# Accept either "(UNRELEASED)" or an already-filled date, so a
# release entry that was dated by hand (or by an earlier run of
# this step) does not have to be reverted before releasing.
x_re = re.compile(
r'^# NEWS for rsync %s \((?:UNRELEASED|\d+ \w{3} \d{4})\)\s+## Changes in this version:\n' % efv
+ r'(\n### PROTOCOL NUMBER:\s+- The protocol number was changed to \d+\.\n)?')
rel_day = 'UNRELEASED' if pre else today
repl = (f'# NEWS for rsync {finalversion} ({rel_day})\n\n'
+ '## Changes in this version:\n')
if proto_changed:
repl += f'\n### PROTOCOL NUMBER:\n\n - The protocol number was changed to {protocol_version}.\n'
good_top = re.sub(r'\(.*?\)', '(UNRELEASED)', repl, 1)
msg = (f"The top of {fn} is not in the right format. It should be:\n" + good_top
+ "(an already-filled release date in place of UNRELEASED is also accepted)")
txt = replace_or_die(x_re, repl, txt, msg)
x_re = re.compile(
r'^(\| )(\S{2} \S{3} \d{4})(\s+\|\s+%s\s+\| ).{11}(\s+\| )\S{2}(\s+\|+)$' % efv,
re.M)
repl = lambda m: (m[1] + (m[2] if pre else ztoday) + m[3]
+ proto_change_date + m[4] + protocol_version + m[5])
txt = replace_or_die(x_re, repl, txt,
f'Unable to find "| ?? ??? {year} | {finalversion} | ... |" line in {fn}')
elif '.spec' in fn:
for var, val in specvars.items():
x_re = re.compile(r'^%s .*' % re.escape(var), re.M)
txt = replace_or_die(x_re, var + ' ' + val, txt,
f"Unable to update {var} in {fn}")
x_re = re.compile(r'^\* \w\w\w \w\w\w \d\d \d\d\d\d (.*)', re.M)
txt = replace_or_die(x_re, r'%s \1' % cl_today, txt,
f"Unable to update ChangeLog header in {fn}")
else:
die(f"Unrecognized file in tweak_files: {fn}")
if txt != old_txt:
print(f"Updating {fn}")
with open(fn, 'w', encoding='utf-8') as fh:
fh.write(txt)
cmd_chk(['packaging/year-tweak'])
section("git diff after tweaks")
cmd_run(['git', '--no-pager', 'diff'])
# ---------- Step 4: build ----------
def step_4_build(args):
require_top_of_checkout()
load_state() # just to ensure we've prepared
section("Running prepare-source + configure --prefix=/usr --with-rrsync + make + make gen")
# Always re-prepare so configure.sh is current; we run configure ourselves
# with the release-required flags rather than relying on the cached
# config.status (which may have been produced with different options).
if os.path.isfile('.fetch'):
cmd_chk(['./prepare-source', 'fetch'])
else:
cmd_chk(['./prepare-source'])
cmd_chk(['./configure', '--prefix=/usr', '--with-rrsync'])
cmd_chk(['make'])
cmd_chk(['make', 'gen'])
# ---------- Step 5: commit ----------
def step_5_commit(args):
require_top_of_checkout()
state = load_state()
version = state['version']
section("git status")
cmd_run(['git', 'status'])
if not confirm("Commit all current changes with the release message?"):
die("Aborted.")
cmd_chk(['git', 'commit', '-a', '-m', f'Preparing for release of {version} [buildall]'])
# ---------- Step 6: tag ----------
def step_6_tag(args):
require_top_of_checkout()
state = load_state()
version = state['version']
v_ver = 'v' + version
out = cmd_txt_chk(['git', 'tag', '-l', v_ver]).out
if out.strip():
if not confirm(f"Tag {v_ver} already exists. Delete and recreate?"):
die("Aborted.")
cmd_chk(['git', 'tag', '-d', v_ver])
# Prime the gpg agent so the actual tag signing won't prompt.
section("Priming gpg agent")
cmd_run("touch TeMp; gpg --sign TeMp; rm -f TeMp TeMp.gpg")
section(f"Creating signed tag {v_ver}")
out = cmd_txt(['git', 'tag', '-s', '-m', f'Version {version}.', v_ver],
capture='combined').out
print(out, end='')
if 'bad passphrase' in out.lower() or 'failed' in out.lower():
die("Tag creation failed.")
# ---------- Step 7: tarball + diff ----------
def step_7_tarball(args):
require_top_of_checkout()
state = load_state()
version = state['version']
lastversion = state['lastversion']
pre = state['pre']
srcdir = state['srcdir']
srcdiffdir = state['srcdiffdir']
lastsrcdir = state['lastsrcdir']
rsync_ver = 'rsync-' + version
rsync_lastver = 'rsync-' + lastversion
v_ver = 'v' + version
srctar_name = f"{rsync_ver}.tar.gz"
diff_name = f"{rsync_lastver}-{version}.diffs.gz"
srctar_file = os.path.join(FTP_DIR, srcdir, srctar_name)
diff_file = os.path.join(FTP_DIR, srcdiffdir, diff_name)
lasttar_file = os.path.join(FTP_DIR, lastsrcdir, rsync_lastver + '.tar.gz')
for d in (os.path.dirname(srctar_file), os.path.dirname(diff_file)):
os.makedirs(d, exist_ok=True)
if not os.path.isfile(lasttar_file):
die(f"Previous tarball not found: {lasttar_file}")
# Stage in ../release/work to keep the source checkout clean.
if os.path.isdir(WORK_DIR):
shutil.rmtree(WORK_DIR)
os.makedirs(WORK_DIR)
a_dir = os.path.join(WORK_DIR, 'a')
b_dir = os.path.join(WORK_DIR, 'b')
# Extract gen files from the previous tarball into work/a/.
tweaked_gen_files = [os.path.join(rsync_lastver, fn) for fn in GEN_FILES]
cmd_chk(['tar', '-C', WORK_DIR, '-xzf', lasttar_file, *tweaked_gen_files])
os.rename(os.path.join(WORK_DIR, rsync_lastver), a_dir)
# Copy current gen files (built in the top-level checkout) into work/b/.
os.makedirs(b_dir)
cmd_chk(['rsync', '-a', *GEN_FILES, b_dir + '/'])
section(f"Creating {diff_file}")
sed_script = r's:^((---|\+\+\+) [ab]/[^\t]+)\t.*:\1:' # no single quotes!
cmd_chk(
f"(git diff v{lastversion} {v_ver} -- ':!.github'; "
f"diff -upN {a_dir} {b_dir} | sed -r '{sed_script}') | gzip -9 >{diff_file}")
section(f"Creating {srctar_file}")
# Reuse work/b/ (which already holds the fresh gen files) as the release
# staging dir, then let "git archive" overlay the git-tracked source files
# on top. That way the tarball ends up with both gen files and source.
rsync_ver_dir = os.path.join(WORK_DIR, rsync_ver)
shutil.rmtree(a_dir)
os.rename(b_dir, rsync_ver_dir)
cmd_chk(f"git archive --format=tar --prefix={rsync_ver}/ {v_ver} | "
f"tar -C {WORK_DIR} -xf -")
cmd_chk(f"support/git-set-file-times --quiet --prefix={rsync_ver_dir}/")
cmd_chk(['fakeroot', 'tar', '-C', WORK_DIR, '-czf', srctar_file,
'--exclude=.github', rsync_ver])
# Leave staging in place; --step-8-update-ftp does its own thing.
print(f"\nCreated:\n {srctar_file}\n {diff_file}")
# ---------- Step 8: update ftp ----------
def step_8_update_ftp(args):
require_top_of_checkout()
state = load_state()
version = state['version']
lastversion = state['lastversion']
srcdir = state['srcdir']
srcdiffdir = state['srcdiffdir']
rsync_ver = 'rsync-' + version
rsync_lastver = 'rsync-' + lastversion
srctar_file = os.path.join(FTP_DIR, srcdir, f"{rsync_ver}.tar.gz")
diff_file = os.path.join(FTP_DIR, srcdiffdir,
f"{rsync_lastver}-{version}.diffs.gz")
section(f"Refreshing top-of-tree files in {FTP_DIR}")
md_files = ['README.md', 'NEWS.md', 'INSTALL.md']
html_files = [fn for fn in GEN_FILES if fn.endswith('.html')]
cmd_chk(['rsync', '-a', *md_files, *html_files, FTP_DIR + '/'])
cmd_chk(['./md-convert', '--dest', FTP_DIR, *md_files])
section(f"Regenerating {FTP_DIR}/ChangeLog.gz")
cmd_chk(f"git log --name-status | gzip -9 >{FTP_DIR}/ChangeLog.gz")
# Prime gpg agent and then sign the tar + diff.
section("Priming gpg agent")
cmd_run("touch TeMp; gpg --sign TeMp; rm -f TeMp TeMp.gpg")
for fn in (srctar_file, diff_file):
if not os.path.isfile(fn):
die(f"Missing file to sign: {fn}. Did --step-7-tarball run successfully?")
asc_fn = fn + '.asc'
if os.path.lexists(asc_fn):
os.unlink(asc_fn)
section(f"GPG-signing {fn}")
res = cmd_run(['gpg', '--batch', '-ba', fn])
if res.returncode not in (0, 2):
die("gpg signing failed.")
# ---------- Step 9: top-level hard links ----------
def step_9_toplinks(args):
require_top_of_checkout()
state = load_state()
pre = state['pre']
if pre:
print("Skipping: pre-releases do not get top-level hard links.")
return
version = state['version']
lastversion = state['lastversion']
srcdir = state['srcdir']
srcdiffdir = state['srcdiffdir']
rsync_ver = 'rsync-' + version
rsync_lastver = 'rsync-' + lastversion
srctar_file = os.path.join(FTP_DIR, srcdir, f"{rsync_ver}.tar.gz")
diff_file = os.path.join(FTP_DIR, srcdiffdir,
f"{rsync_lastver}-{version}.diffs.gz")
section("Removing stale top-level rsync-* files")
for find in [f'{FTP_DIR}/rsync-*.gz',
f'{FTP_DIR}/rsync-*.asc',
f'{FTP_DIR}/src-previews/rsync-*diffs.gz*']:
for fn in glob.glob(find):
os.unlink(fn)
top_link = [
srctar_file, srctar_file + '.asc',
diff_file, diff_file + '.asc',
]
for fn in top_link:
target = re.sub(r'/src(-\w+)?/', '/', fn)
if os.path.lexists(target):
os.unlink(target)
os.link(fn, target)
print(f" linked {target}")
# ---------- Step 10: push ftp ----------
def step_10_push_ftp(args):
host = require_samba_host()
if not os.path.isdir(FTP_DIR):
die(f"{FTP_DIR} does not exist. Run --step-1-fetch first.")
section(f"rsync ftp dir to {host}")
rsync_with_confirm(['-aivOHP', '--chown=:rsync', '--del',
f'-f._{os.path.join(FTP_DIR, ".filt")}',
f'{FTP_DIR}/', f'{host}:{FTP_REMOTE_PATH}/'])
# ---------- Step 11: push html ----------
def step_11_push_html(args):
host = require_samba_host()
if not os.path.isdir(HTML_DIR):
die(f"{HTML_DIR} does not exist. Run --step-1-fetch first.")
section(f"rsync html dir to {host}")
filt = os.path.join(HTML_DIR, 'filt')
rsync_with_confirm(['-aivOHP', '--chown=:rsync', '--del',
f'-f._{filt}',
f'{HTML_DIR}/', f'{host}:{HTML_REMOTE_PATH}/'])
# ---------- Step 12: print push-git instructions ----------
def step_12_push_git(args):
state = load_state()
version = state['version']
master_branch = state['master_branch']
v_ver = 'v' + version
print(f"""\
{DASH_LINE}
Run these from the rsync-git checkout (this script does not push for you):
git push origin {master_branch}
git push origin {v_ver}
If you have a 'samba' remote configured (git.samba.org:/data/git/rsync.git):
git push samba {master_branch}
git push samba {v_ver}
Then upload the tarball + .asc to the GitHub release for {v_ver},
and announce on rsync-announce@, rsync@, and Discord.
NOTE! Also update the PPAs if needed
""")
# ---------- shared rsync-with-confirm ----------
def rsync_with_confirm(rsync_args):
"""Run an rsync command in dry-run mode, then ask before running for real."""
cmd_run(['rsync', '--dry-run', *rsync_args])
if confirm("Run without --dry-run?"):
cmd_run(['rsync', *rsync_args])
# ---------- dispatch ----------
STEP_FUNCS = {
'step-1-fetch': step_1_fetch,
'step-2-prepare': step_2_prepare,
'step-3-tweak': step_3_tweak,
'step-4-build': step_4_build,
'step-5-commit': step_5_commit,
'step-6-tag': step_6_tag,
'step-7-tarball': step_7_tarball,
'step-8-update-ftp': step_8_update_ftp,
'step-9-toplinks': step_9_toplinks,
'step-10-push-ftp': step_10_push_ftp,
'step-11-push-html': step_11_push_html,
'step-12-push-git': step_12_push_git,
}
def signal_handler(sig, frame):
die("\nAborting due to SIGINT.")
def main():
parser = argparse.ArgumentParser(
description="Step-based release script for rsync.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Run --list to see the steps. Each invocation runs exactly one --step-* option.")
parser.add_argument('--branch', '-b', dest='master_branch', default='master',
help="The branch to release (default: master).")
parser.add_argument('--list', action='store_true',
help="List all release steps and exit.")
grp = parser.add_mutually_exclusive_group()
for flag, descr in STEPS:
grp.add_argument('--' + flag, dest='step', action='store_const',
const=flag, help=descr)
args = parser.parse_args()
if args.list:
print("Release steps:")
for flag, descr in STEPS:
print(f" --{flag:18s} {descr}")
return
if not args.step:
parser.error("pick one --step-N-XX option (or --list to see them).")
signal.signal(signal.SIGINT, signal_handler)
os.environ['LESS'] = 'mqeiXR'
STEP_FUNCS[args.step](args)
if __name__ == '__main__':
main()
# vim: sw=4 et ft=python
-124
View File
@@ -1,124 +0,0 @@
#!/bin/bash
# This script makes it easy to update the ftp & html directories on the samba.org server.
# It expects the 2 *_DEST directories to contain updated files that need to be sent to
# the remote server. If these directories don't exist yet, they will be copied from the
# remote server (while also making the html dir a git checkout).
FTP_SRC="$HOME/samba-rsync-ftp"
HTML_SRC="$HOME/samba-rsync-html"
FTP_DEST="/home/ftp/pub/rsync"
HTML_DEST="/home/httpd/html/rsync"
HTML_GIT='git.samba.org:/data/git/rsync-web.git'
export RSYNC_PARTIAL_DIR=''
case "$RSYNC_SAMBA_HOST" in
*.samba.org) ;;
*)
echo "You must set RSYNC_SAMBA_HOST in your environment to the samba hostname to use." >&2
exit 1
;;
esac
MODE=''
REVERSE=''
while (( $# )); do
case "$1" in
-R|--reverse) REVERSE=yes ;;
f|ftp) MODE=ftp ;;
h|html) MODE=html ;;
-h|--help)
echo "Usage: [-R] [f|ftp|h|html]"
echo "-R --reverse Copy the files from the server to the local host."
echo " The default is to update the remote files."
echo "-h --help Output this help message."
echo " "
echo "The script will prompt if ftp or html is not specified on the command line."
echo "Only one category can be copied at a time. When pulling html files, a git"
echo "checkout will be either created or updated prior to the rsync copy."
exit
;;
*)
echo "Invalid option: $1" >&2
exit 1
;;
esac
shift
done
while [ ! "$MODE" ]; do
if [ "$REVERSE" = yes ]; then
DIRECTION=FROM
else
DIRECTION=TO
fi
echo -n "Copy which files $DIRECTION the server? ftp or html? "
read ans
case "$ans" in
f*) MODE=ftp ;;
h*) MODE=html ;;
'') exit 1 ;;
*) echo "You must answer f or h to copy the ftp or html data." ;;
esac
done
if [ "$MODE" = ftp ]; then
SRC_DIR="$FTP_SRC"
DEST_DIR="$FTP_DEST"
FILT=".filt"
else
SRC_DIR="$HTML_SRC"
DEST_DIR="$HTML_DEST"
FILT="filt"
fi
function do_rsync {
rsync --dry-run "${@}" | grep -v 'is uptodate$'
echo ''
echo -n "Run without --dry-run? [n] "
read ans
case "$ans" in
y*) rsync "${@}" | grep -v 'is uptodate$' ;;
esac
}
if [ -d "$SRC_DIR" ]; then
REVERSE_RSYNC=do_rsync
else
echo "The directory $SRC_DIR does not exist yet."
echo -n "Do you want to create it? [n] "
read ans
case "$ans" in
y*) ;;
*) exit 1 ;;
esac
REVERSE=yes
REVERSE_RSYNC=rsync
fi
if [ "$REVERSE" = yes ]; then
OPTS='-aivOHP'
TMP_FILT="$SRC_DIR/tmp-filt"
echo "Copying files from $RSYNC_SAMBA_HOST to $SRC_DIR ..."
if [ "$MODE" = html ]; then
if [ $REVERSE_RSYNC = rsync ]; then
git clone "$HTML_GIT" "$SRC_DIR" || exit 1
else
cd "$SRC_DIR" || exit 1
git pull || exit 1
fi
sed -n -e 's/[-P]/H/p' "$SRC_DIR/$FILT" >"$TMP_FILT"
OPTS="${OPTS}f._$TMP_FILT"
else
OPTS="${OPTS}f:_$FILT"
fi
$REVERSE_RSYNC "$OPTS" "$RSYNC_SAMBA_HOST:$DEST_DIR/" "$SRC_DIR/"
rm -f "$TMP_FILT"
exit
fi
cd "$SRC_DIR" || exit 1
echo "Copying files from $SRC_DIR to $RSYNC_SAMBA_HOST ..."
do_rsync -aivOHP --del -f._$FILT . "$RSYNC_SAMBA_HOST:$DEST_DIR/"
-33
View File
@@ -1,33 +0,0 @@
#!/bin/bash -e
# This script expects the ~/src/rsync directory to contain the rsync
# source that has been updated. It also expects the auto-build-save
# directory to have been created prior to the running of configure so
# that each branch has its own build directory underneath. This supports
# the maintainer workflow for the rsync-patches files maintenace.
FTP_SRC="$HOME/samba-rsync-ftp"
FTP_DEST="/home/ftp/pub/rsync"
MD_FILES="README.md INSTALL.md NEWS.md"
case "$RSYNC_SAMBA_HOST" in
*.samba.org) ;;
*)
echo "You must set RSYNC_SAMBA_HOST in your environment to the samba hostname to use." >&2
exit 1
;;
esac
if [ ! -d "$FTP_SRC" ]; then
packaging/samba-rsync ftp # Ask to initialize the local ftp dir
fi
cd ~/src/rsync
make man
./md-convert --dest="$FTP_SRC" $MD_FILES
rsync -aiic $MD_FILES auto-build-save/master/*.?.html "$FTP_SRC"
cd "$FTP_SRC"
rsync -aiic README.* INSTALL.* NEWS.* *.?.html "$RSYNC_SAMBA_HOST:$FTP_DEST/"
+1 -55
View File
@@ -7,9 +7,6 @@
import sys, os, re, argparse, subprocess
from datetime import datetime
MAINTAINER_NAME = 'Wayne Davison'
MAINTAINER_SUF = ' ' + MAINTAINER_NAME + "\n"
def main():
latest_year = '2000'
@@ -22,10 +19,6 @@ def main():
m = argparse.Namespace(**m.groupdict())
if m.year > latest_year:
latest_year = m.year
if m.fn.startswith('zlib/') or m.fn.startswith('popt/'):
continue
if re.search(r'\.(c|h|sh|test)$', m.fn):
maybe_edit_copyright_year(m.fn, m.year)
proc.communicate()
fn = 'latest-year.h'
@@ -39,55 +32,8 @@ def main():
fh.write(txt)
def maybe_edit_copyright_year(fn, year):
opening_lines = [ ]
copyright_line = None
with open(fn, 'r', encoding='utf-8') as fh:
for lineno, line in enumerate(fh):
opening_lines.append(line)
if lineno > 3 and not re.search(r'\S', line):
break
m = re.match(r'^(?P<pre>.*Copyright\s+\S+\s+)(?P<year>\d\d\d\d(?:-\d\d\d\d)?(,\s+\d\d\d\d)*)(?P<suf>.+)', line)
if not m:
continue
copyright_line = argparse.Namespace(**m.groupdict())
copyright_line.lineno = len(opening_lines)
copyright_line.is_maintainer_line = MAINTAINER_NAME in copyright_line.suf
copyright_line.txt = line
if copyright_line.is_maintainer_line:
break
if not copyright_line:
return
if copyright_line.is_maintainer_line:
cyears = copyright_line.year.split('-')
if year == cyears[0]:
cyears = [ year ]
else:
cyears = [ cyears[0], year ]
txt = copyright_line.pre + '-'.join(cyears) + MAINTAINER_SUF
if txt == copyright_line.txt:
return
opening_lines[copyright_line.lineno - 1] = txt
else:
if fn.startswith('lib/') or fn.startswith('testsuite/'):
return
txt = copyright_line.pre + year + MAINTAINER_SUF
opening_lines[copyright_line.lineno - 1] += txt
remaining_txt = fh.read()
print(f"Updating {fn} with year {year}")
with open(fn, 'w', encoding='utf-8') as fh:
fh.write(''.join(opening_lines))
fh.write(remaining_txt)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Grab the year of last mod for our c & h files and make sure the Copyright comment is up-to-date.")
parser = argparse.ArgumentParser(description="Grab the year of the last mod for our c & h files and make sure the LATEST_YEAR value is accurate.")
args = parser.parse_args()
main()
+9 -2
View File
@@ -416,7 +416,7 @@ static int include_config(char *include, int manage_globals)
char *match = manage_globals ? "*.conf" : "*.inc";
int ret;
if (do_stat(include, &sb) < 0) {
if (vfs_stat(VFS_AT_FDCWD, include, &sb, VFS_ALLOW_SYMLINK) < 0) {
rsyserr(FLOG, errno, "unable to stat config file \"%s\"", include);
return 0;
}
@@ -580,7 +580,14 @@ static FILE *OpenConfFile( char *FileName )
return( NULL );
}
OpenedFile = fopen( FileName, "r" );
/* rsyncd.conf path (--config or default): a planted symlink could redirect
* the daemon's config read. Refuse symlinks not owned by uid 0 or euid. */
{
int cfg_fd = vfs_open_owner_walk( FileName, O_RDONLY, 0 , 0);
OpenedFile = cfg_fd >= 0 ? fdopen( cfg_fd, "r" ) : NULL;
if( !OpenedFile && cfg_fd >= 0 )
close( cfg_fd );
}
if( NULL == OpenedFile )
{
rsyserr(FLOG, errno, "unable to open config file \"%s\"",
-55
View File
@@ -1,55 +0,0 @@
/** \ingroup popt
* \file popt/findme.c
*/
/* (C) 1998-2002 Red Hat, Inc. -- Licensing details are in the COPYING
file accompanying popt source distributions, available from
ftp://ftp.rpm.org/pub/rpm/dist. */
#include "system.h"
#include "findme.h"
const char * findProgramPath(const char * argv0)
{
char * path = getenv("PATH");
char * pathbuf;
char * start, * chptr;
char * buf;
size_t bufsize;
if (argv0 == NULL) return NULL; /* XXX can't happen */
/* If there is a / in the argv[0], it has to be an absolute path */
if (strchr(argv0, '/'))
return xstrdup(argv0);
if (path == NULL) return NULL;
bufsize = strlen(path) + 1;
start = pathbuf = alloca(bufsize);
if (pathbuf == NULL) return NULL; /* XXX can't happen */
strlcpy(pathbuf, path, bufsize);
bufsize += sizeof "/" - 1 + strlen(argv0);
buf = malloc(bufsize);
if (buf == NULL) return NULL; /* XXX can't happen */
chptr = NULL;
/*@-branchstate@*/
do {
if ((chptr = strchr(start, ':')))
*chptr = '\0';
snprintf(buf, bufsize, "%s/%s", start, argv0);
if (!access(buf, X_OK))
return buf;
if (chptr)
start = chptr + 1;
else
start = NULL;
} while (start && *start);
/*@=branchstate@*/
free(buf);
return NULL;
}
-20
View File
@@ -1,20 +0,0 @@
/** \ingroup popt
* \file popt/findme.h
*/
/* (C) 1998-2000 Red Hat, Inc. -- Licensing details are in the COPYING
file accompanying popt source distributions, available from
ftp://ftp.rpm.org/pub/rpm/dist. */
#ifndef H_FINDME
#define H_FINDME
/**
* Return absolute path to executable by searching PATH.
* @param argv0 name of executable
* @return (malloc'd) absolute path to executable (or NULL)
*/
/*@null@*/ const char * findProgramPath(/*@null@*/ const char * argv0)
/*@*/;
#endif
Loaded 100 of 664 files, more files were not shown because too many files have changed in this diff. Show more