Commit Graph
141 Commits
Author SHA1 Message Date
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 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 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 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 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 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
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
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 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 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
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 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
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
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 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 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 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 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
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
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
Zen Dodd 1d6770edbc ci: test uninstall targets 2026-06-06 16:07:20 +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 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 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.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