Package: release.debian.org
Control: affects -1 + src:rsync
X-Debbugs-Cc: [email protected]
User: [email protected]
Usertags: pu
Tags: trixie
Severity: normal

[ Reason ]
CVE-2026-119741: a receiver can send block checksums whose weak
checksum collides with the sender's rolling checksum at consecutive
offsets, while the strong checksum never matches. The sender then
calculates a full strong checksum at every byte offset.
Unauthenticated clients pulling from a public daemon module can use
this to exhaust the daemon's CPU across concurrent connections.

The same upstream commit also fixes #1149604 (upstream GH #1115), a
regression that 3.5.0+ds1-0+deb13u1 (DSA-6527-1) brought to trixie.
3.5.0 caps the hash_search() chain walk at 1024 candidates with the
same weak checksum (MAX_CHAIN_LEN). When more than 1024 identical
non-matching blocks come before the matching ones in a chain, the
walk stops before it reaches the matching blocks, and that data is
sent as literal data instead. Disk images, with their long runs of
identical blocks, are prone to this. The reporter's backups of 32GB
SD card images became much slower and used much more CPU after the
upgrade to 3.5.0.

[ Impact ]
Without the update, an rsync daemon on trixie with a module that
allows anonymous access stays open to remote CPU exhaustion by
unauthenticated clients.

Users of 3.5.0+ds1-0+deb13u1 also keep the #1149604 slowdown. Since
that version is in proposed-updates, the next point release would
also bring the slowdown to trixie users who don't use
trixie-security.

[ Tests ]
The patch adds two upstream regression tests,
testsuite/hashsearch-collision-skip_test.py and
testsuite/hashsearch-dup-chain_test.py. The test runner picks them
up automatically, so they run in the build-time test suite and in
the upstream-tests and upstream-tests-as-root autopkgtests. Both
tests fail against 3.5.0+ds1-0+deb13u1 and pass with this update.

The package builds, and the build-time test suite and all
autopkgtests pass.

[ Risks ]
Low. The change is upstream commit a7404a9a, released in upstream
3.5.2, and it only touches hash_search() in match.c (+45/-9 lines),
the sender's block-matching loop. It doesn't change the protocol,
so patched and unpatched peers interoperate.

At worst, the new logic makes the sender miss a block it could have
matched. That data is then sent as literal data, so the result is
still correct and only the transfer size grows. Upstream's tests
cover --inplace, the exact skip boundaries, and the case where more
literal data is sent.

[ Checklist ]
  [x] *all* changes are documented in the d/changelog
  [x] I reviewed all changes and I approve them
  [x] attach debdiff against the package in (old)stable
  [x] the issue is verified as fixed in unstable

[ Changes ]
* d/patches/CVE-2026-119741.patch: upstream commit a7404a9a ("match:
  bound hash_search collision work (GH #1132)"). Only the context was
  refreshed: the patched source is identical to applying the
  upstream commit itself to 3.5.0+ds1-0+deb13u1. In hash_search():
  - When a chain entry fails the strong checksum comparison, later
    entries in that chain with the same strong checksum are unlinked
    instead of being checked again. Duplicate blocks then no longer
    use up the MAX_CHAIN_LEN budget and hide matching blocks
    (#1149604). With --inplace, an entry pinned with
    SUMFLG_SAME_OFFSET is only unlinked in favour of another pinned
    entry.
  - The block length is now compared together with the weak
    checksum, before the entry counts towards MAX_CHAIN_LEN.
  - After 4 consecutive offsets that needed a strong checksum but
    found no match, the search advances by a full block instead of a
    single byte. This bounds the strong checksum work
    (CVE-2026-119741).
  - It adds the two regression tests mentioned above.
* d/patches/series: apply the new patch.
* d/changelog: new entry, closing #1149604.

[ Other info ]
The debdiff is against 3.5.0+ds1-0+deb13u1, which came through
trixie-security (DSA-6527-1) and is now in proposed-updates.

The fix is in unstable (3.5.2+ds1-1) but hasn't reached testing,
because #1153316 blocks its migration. That bug is a riscv64 FTBFS
where aligned_overflow_test.py hits its 240s timeout. The test comes
from a separate upstream change (GH #1133), which isn't part of this
update. In that same riscv64 build, the two tests added here passed.

Given the simplicity of the backporting changes and the confirmation of fix,
I'm uploading this to trixie at the same time I'm opening this pu request.

Thank you,

-- 
Samuel Henrique <samueloph>
diff -Nru rsync-3.5.0+ds1/debian/changelog rsync-3.5.0+ds1/debian/changelog
--- rsync-3.5.0+ds1/debian/changelog    2026-09-15 18:46:30.000000000 -0700
+++ rsync-3.5.0+ds1/debian/changelog    2026-11-07 14:03:56.000000000 -0800
@@ -1,3 +1,11 @@
+rsync (3.5.0+ds1-0+deb13u2) trixie; urgency=medium
+
+  * Fix CVE-2026-119741: Unauthenticated attackers pulling from a public
+    daemon module can exhaust sender CPU across concurrent connections
+    (closes: #1149604).
+
+ -- Samuel Henrique <[email protected]>  Sat, 07 Nov 2026 14:03:56 -0800
+
 rsync (3.5.0+ds1-0+deb13u1) trixie-security; urgency=medium
 
   * New upstream release, pull the same upstream patches applied in Debian
diff -Nru rsync-3.5.0+ds1/debian/patches/CVE-2026-119741.patch 
rsync-3.5.0+ds1/debian/patches/CVE-2026-119741.patch
--- rsync-3.5.0+ds1/debian/patches/CVE-2026-119741.patch        1969-12-31 
16:00:00.000000000 -0800
+++ rsync-3.5.0+ds1/debian/patches/CVE-2026-119741.patch        2026-11-07 
14:03:56.000000000 -0800
@@ -0,0 +1,656 @@
+From a7404a9a633cff28ee1602f328b9c3fa69cdbd04 Mon Sep 17 00:00:00 2001
+From: Omar Elsayed <[email protected]>
+Date: Tue, 20 Oct 2026 10:28:03 +0300
+Subject: [PATCH] match: bound hash_search collision work (#1132)
+
+* Fix hash_search() duplicate-chain regression (#1115) and collision flood
+
+When a hash chain contains > MAX_CHAIN_LEN identical colliding blocks
+(such as 0x00 sparse regions colliding with 0xFF blocks at 64K/128K
+block sizes), walking every duplicate entry exhausts MAX_CHAIN_LEN before
+reaching valid matching blocks further down the chain (#1115).
+
+Track the strong checksum of the last failed chain entry (last_false_sum2)
+and unlink subsequent duplicate entries sharing the same strong checksum
+(unless protected by SUMFLG_SAME_OFFSET in --inplace mode) so duplicates
+are neither re-walked nor counted repeatedly against MAX_CHAIN_LEN.
+
+Additionally, if 4 consecutive 1-byte rolling offsets experience a
+weak-checksum collision where the entire chain fails to match, advance
+offset by a full block rather than computing a full strong checksum on
+every single byte.
+
+* Split hash_search() collision tests and add boundary/--inplace coverage
+
+Split testsuite/collision_test.py into two dedicated regression suites:
+
+1. testsuite/hashsearch-dup-chain_test.py:
+  - Verifies forward chains (> MAX_CHAIN_LEN duplicates),
+     multi-group interleaved duplicate chains, and --inplace
+     SUMFLG_SAME_OFFSET preservation when mutating the chain after a
+     subsequent duplicate is overwritten on disk.
+
+2. testsuite/hashsearch-collision-skip_test.py:
+- Asserts explicit O(N/B) hash_hits CPU bounds and exact literal/matched
+     bandwidth bounds across skip boundaries (3, BLOCK, BLOCK+3, BLOCK+4,
+     2*BLOCK+3, 2*BLOCK+6, and 10*BLOCK+137) in both standard and
+     --inplace modes, and documents the bunched-perturbation hazard case.
+
+* Unlink redundant SUMFLG_SAME_OFFSET duplicates in hash_search()
+
+In --inplace mode, when > MAX_CHAIN_LEN identical basis blocks match
+at their aligned offsets (such as 0x00 sparse runs), every matching
+entry acquires SUMFLG_SAME_OFFSET. Skipping deduplication for all
+SUMFLG_SAME_OFFSET entries left all pinned duplicates in the chain,
+exhausting MAX_CHAIN_LEN, inflating false_alarms, and hiding valid
+basis blocks behind them.
+
+Track the flags of the entry that set last_false_sum2 (last_false_flags).
+Once a SUMFLG_SAME_OFFSET entry has been retained as last_false_sum2,
+unlink subsequent duplicate entries carrying SUMFLG_SAME_OFFSET as well,
+while still preventing unpinned entries from unlinking a pinned entry.
+
+Add Case 4 to testsuite/hashsearch-dup-chain_test.py to verify that
+> MAX_CHAIN_LEN pinned duplicate blocks are unlinked and trailing valid
+blocks behind them are matched with 0 extra literal bytes.
+
+Backported-by: Samuel Henrique <[email protected]>
+ * Refresh patch context, no functional changes.
+
+---
+ match.c                                     |  54 +++++--
+ testsuite/hashsearch-collision-skip_test.py | 234 ++++++++++++++++++++++++++++
+ testsuite/hashsearch-dup-chain_test.py      | 225 ++++++++++++++++++++++++++
+ 3 files changed, 504 insertions(+), 9 deletions(-)
+ create mode 100644 testsuite/hashsearch-collision-skip_test.py
+ create mode 100644 testsuite/hashsearch-dup-chain_test.py
+
+diff --git a/match.c b/match.c
+index 4fa3b85..d427c62 100644
+--- a/match.c
++++ b/match.c
+@@ -166,7 +166,9 @@ static void hash_search(int f,struct sum_struct *s,
+       OFF_T offset, aligned_offset, end;
+       int32 k, want_i, aligned_i, backup;
+       char sum2[MAX_DIGEST_LEN];
++      int32 consecutive_false_offsets = 0;
+       uint32 s1, s2, sum;
++      uchar last_false_flags = 0;
+       int more;
+       schar *map;
+ 
+@@ -203,6 +205,8 @@ static void hash_search(int f,struct sum_struct *s,
+ 
+       do {
+               int done_csum2 = 0;
++              int matched_block = 0;
++              const char *last_false_sum2 = NULL;
+               uint32 hash_entry;
+               int32 i, *prev;
+               int32 chain_len = 0;
+@@ -214,14 +218,18 @@ static void hash_search(int f,struct sum_struct *s,
+ 
+               if (tablesize == TRADITIONAL_TABLESIZE) {
+                       hash_entry = SUM2HASH2(s1,s2);
+-                      if ((i = hash_table[hash_entry]) < 0)
++                      if ((i = hash_table[hash_entry]) < 0) {
++                              consecutive_false_offsets = 0;
+                               goto null_hash;
++                      }
+                       sum = (s1 & 0xffff) | (s2 << 16);
+               } else {
+                       sum = (s1 & 0xffff) | (s2 << 16);
+                       hash_entry = BIG_SUM2HASH(sum);
+-                      if ((i = hash_table[hash_entry]) < 0)
++                      if ((i = hash_table[hash_entry]) < 0) {
++                              consecutive_false_offsets = 0;
+                               goto null_hash;
++                      }
+               }
+               prev = &hash_table[hash_entry];
+ 
+@@ -237,10 +245,20 @@ static void hash_search(int f,struct sum_struct *s,
+                               *prev = s->sums[i].chain;
+                               continue;
+                       }
+-                      prev = &s->sums[i].chain;
++            l = (int32)MIN((OFF_T)s->blength, len-offset);
++                      if (sum != s->sums[i].sum1 || l != s->sums[i].len) {
++                              prev = &s->sums[i].chain;
++                              continue;
++                      }
+ 
+-                      if (sum != s->sums[i].sum1)
++                      if (last_false_sum2
++                       && (!(s->sums[i].flags & SUMFLG_SAME_OFFSET)
++                        || (last_false_flags & SUMFLG_SAME_OFFSET))
++                       && memcmp(sum2_at(s, i), last_false_sum2, s->s2length) 
== 0) {
++                              *prev = s->sums[i].chain;
+                               continue;
++                      }
++                      prev = &s->sums[i].chain;
+ 
+                       /* Bound the work spent on a single pathological hash
+                        * bucket.  If this weak checksum matches more than
+@@ -250,11 +268,6 @@ static void hash_search(int f,struct sum_struct *s,
+                       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)
+-                              continue;
+-
+                       if (DEBUG_GTE(DELTASUM, 3)) {
+                               rprintf(FINFO,
+                                       "potential match at %s i=%ld 
sum=%08x\n",
+@@ -269,6 +282,8 @@ static void hash_search(int f,struct sum_struct *s,
+ 
+                       if (memcmp(sum2, sum2_at(s, i), s->s2length) != 0) {
+                               false_alarms++;
++                              last_false_sum2 = sum2_at(s, i);
++                              last_false_flags = s->sums[i].flags;
+                               continue;
+                       }
+ 
+@@ -341,8 +356,29 @@ static void hash_search(int f,struct sum_struct *s,
+                       s1 = sum & 0xFFFF;
+                       s2 = sum >> 16;
+                       matches++;
++                      matched_block = 1;
++                      consecutive_false_offsets = 0;
+                       break;
+               } while ((i = s->sums[i].chain) >= 0);
++              /* If we computed a strong checksum (weak collision) and the 
entire
++               * chain failed to match, don't let repeated collisions force a
++               * 1-byte walk with a full strong-checksum calculation on every 
byte. */
++              if (!matched_block && (done_csum2 || chain_len > 
MAX_CHAIN_LEN)) {
++                      if (++consecutive_false_offsets >= 4 && k > 1) {
++                              OFF_T skip = MIN((OFF_T)(k - 1), end - 1 - 
offset);
++                              if (skip > 0) {
++                                      offset += skip;
++                                      k = (int32)MIN((OFF_T)s->blength, 
len-offset);
++                                      map = (schar *)map_ptr(buf, offset, k);
++                                      sum = get_checksum1((char *)map, k);
++                                      s1 = sum & 0xFFFF;
++                                      s2 = sum >> 16;
++                              }
++                              consecutive_false_offsets = 3;
++                      }
++              }
++              else if (!matched_block)
++                      consecutive_false_offsets = 0;
+ 
+         null_hash:
+               backup = (int32)(offset - last_match);
+diff --git a/testsuite/hashsearch-collision-skip_test.py 
b/testsuite/hashsearch-collision-skip_test.py
+new file mode 100644
+index 0000000..34aa1f4
+--- /dev/null
++++ b/testsuite/hashsearch-collision-skip_test.py
+@@ -0,0 +1,234 @@
++#!/usr/bin/env python3
++"""Regression test for hash_search() collision-flood bounds and skip 
boundaries.
++
++Verifies that when rolling across unmatched constant-checksum collision runs:
++1. CPU work (hash_hits and false_alarms) is strictly bounded at O(N / blength)
++   rather than computing a strong checksum on every byte O(N).
++2. Bandwidth (Literal data vs Matched data) is exact: valid basis blocks 
placed
++   immediately after colliding runs at every critical skip boundary are 100%
++   matched without skipping a single valid block:
++   - Sub-threshold collision run (3 bytes < 4-offset threshold)
++   - Exact 1-block boundary (BLOCK bytes)
++   - Exact 1st-skip landing boundary (BLOCK + 3 bytes)
++   - 1-byte past 1st-skip landing boundary (BLOCK + 4 bytes)
++   - Exact 2nd-skip landing boundary (2 * BLOCK + 3 bytes)
++   - Multi-skip + sub-threshold roll (2 * BLOCK + 6 bytes)
++   - Long multi-block flood + arbitrary unaligned offset (10 * BLOCK + 137 
bytes)
++3. `--inplace` mode preserves exact boundary matches across collision skips.
++4. Crafted weak-checksum collisions with bunched perturbations (e.g., (+1, 
-2, +1)
++   at bytes 0..2) trigger premature boundary skips due to partial window 
overlap,
++   resulting in *increased literal data* (safely falling back to literal 
transmission
++   rather than corrupting the file or hanging the CPU).
++"""
++
++import re
++
++from rsyncfns import (
++    FROMDIR, TODIR, assert_same, rmtree, run_rsync, test_fail,
++)
++
++BLOCK = 704
++C_BYTE = 100
++
++
++def make_spread_colliding_block(delta: int, fill: int = C_BYTE) -> bytes:
++    b = bytearray([fill]) * BLOCK
++    b[0] = fill + delta
++    b[351] = fill - delta
++    b[352] = fill - delta
++    b[703] = fill + delta
++    return bytes(b)
++
++
++def make_block(perturb_offset: int = -1, fill: int = C_BYTE) -> bytes:
++    """Crafted weak-checksum collision block ((+1, -2, +1) perturbation at 
0..2).
++    Because perturbations are bunched at the start, partial boundary windows
++    falsely collide, triggering premature skips and resulting in increased 
literal data.
++    """
++    b = bytearray([fill]) * BLOCK
++    if perturb_offset >= 0:
++        b[perturb_offset] = fill + 1
++        b[perturb_offset + 1] = fill - 2
++        b[perturb_offset + 2] = fill + 1
++    return bytes(b)
++
++
++def parse_stats(out: str):
++    m_delta = re.search(r'hash_hits=(\d+)\s+false_alarms=(\d+)', out)
++    m_lit = re.search(r'Literal data:\s*([\d,]+)\s*bytes', out)
++    m_match = re.search(r'Matched data:\s*([\d,]+)\s*bytes', out)
++    if not m_delta or not m_lit or not m_match:
++        test_fail(f"could not find deltasum/stats in rsync output:\n{out}")
++    return (
++        int(m_delta.group(1)),
++        int(m_delta.group(2)),
++        int(m_lit.group(1).replace(',', '')),
++        int(m_match.group(1).replace(',', '')),
++    )
++
++
++block_a = make_spread_colliding_block(5)
++block_d = make_spread_colliding_block(9)
++
++rmtree(FROMDIR)
++rmtree(TODIR)
++FROMDIR.mkdir(parents=True, exist_ok=True)
++TODIR.mkdir(parents=True, exist_ok=True)
++
++# ---------------------------------------------------------------------------
++# Case 1: Skip-boundary match preservation + CPU/bandwidth bounds
++# ---------------------------------------------------------------------------
++src1 = FROMDIR / 'skip_boundaries.bin'
++dst1 = TODIR / 'skip_boundaries.bin'
++
++dst1_payload = (block_a * 2) + (block_d * 2) + (block_a * 14) + (block_d * 14)
++dst1.write_bytes(dst1_payload)
++
++boundary_runs = [
++    bytes([C_BYTE]) * 3,                  # < 4 misses (no skip, rolls into 
match)
++    bytes([C_BYTE]) * BLOCK,              # Exact 1-block boundary
++    bytes([C_BYTE]) * (BLOCK + 3),        # Exact landing offset of 1st skip 
(3 + BLOCK)
++    bytes([C_BYTE]) * (BLOCK + 4),        # 1 byte after 1st skip landing
++    bytes([C_BYTE]) * (2 * BLOCK + 3),    # Exact landing offset of 2nd 
consecutive skip
++    bytes([C_BYTE]) * (2 * BLOCK + 6),    # 2 skips + 3-byte sub-threshold 
roll
++    bytes([C_BYTE]) * (10 * BLOCK + 137), # Long flood + unaligned remainder
++]
++
++src1_parts = [(block_a * 2) + (block_d * 2)]
++for idx, run in enumerate(boundary_runs):
++    valid_group = (block_d * 4) if (idx % 2 == 0) else (block_a * 4)
++    src1_parts.append(run)
++    src1_parts.append(valid_group)
++
++src1_payload = b''.join(src1_parts)
++src1.write_bytes(src1_payload)
++
++proc1 = run_rsync('-a', '-I', '--no-whole-file', f'--block-size={BLOCK}',
++                  '--no-compress', '--stats', '--debug=deltasum1',
++                  f'{src1}', f'{dst1}', capture_output=True)
++
++hash_hits1, false_alarms1, literal1, matched1 = parse_stats(
++    proc1.stdout + proc1.stderr
++)
++
++total_valid_blocks = 4 * (1 + len(boundary_runs))  # 32 blocks = 22,528 bytes
++expected_matched1 = total_valid_blocks * BLOCK
++expected_literal1 = sum(len(r) for r in boundary_runs)
++
++if matched1 != expected_matched1 or literal1 != expected_literal1:
++    test_fail(
++        f"Case 1 skip boundary missed valid basis blocks: "
++        f"matched={matched1} (expected {expected_matched1}), "
++        f"literal={literal1} (expected {expected_literal1})"
++    )
++
++MAX_EXPECTED_HITS1 = 160
++if hash_hits1 > MAX_EXPECTED_HITS1:
++    test_fail(
++        f"Case 1 collision flood rolled 1 byte at a time: 
hash_hits={hash_hits1} "
++        f"(expected <= {MAX_EXPECTED_HITS1})"
++    )
++
++assert_same(dst1, src1, label='Case 1: collision skip-boundary transfer')
++
++# ---------------------------------------------------------------------------
++# Case 2: Pure unmatched collision flood CPU bound (O(N/B) vs O(N))
++# ---------------------------------------------------------------------------
++src2 = FROMDIR / 'pure_flood.bin'
++dst2 = TODIR / 'pure_flood.bin'
++
++N_FLOOD = 12
++dst2.write_bytes(block_a * N_FLOOD)
++src2.write_bytes(bytes([C_BYTE]) * (N_FLOOD * BLOCK))
++
++proc2 = run_rsync('-a', '-I', '--no-whole-file', f'--block-size={BLOCK}',
++                  '--no-compress', '--stats', '--debug=deltasum1',
++                  f'{src2}', f'{dst2}', capture_output=True)
++
++hash_hits2, false_alarms2, literal2, matched2 = parse_stats(
++    proc2.stdout + proc2.stderr
++)
++
++MAX_EXPECTED_HITS2 = N_FLOOD * 3
++if hash_hits2 > MAX_EXPECTED_HITS2:
++    test_fail(
++        f"Case 2 pure flood rolled 1 byte at a time: hash_hits={hash_hits2} "
++        f"(expected <= {MAX_EXPECTED_HITS2})"
++    )
++
++assert_same(dst2, src2, label='Case 2: pure collision flood transfer')
++
++# ---------------------------------------------------------------------------
++# Case 3: Skip-boundary match preservation under --inplace
++# ---------------------------------------------------------------------------
++src3 = FROMDIR / 'inplace_skip.bin'
++dst3 = TODIR / 'inplace_skip.bin'
++
++dst3.write_bytes(dst1_payload)
++src3.write_bytes(src1_payload)
++
++proc3 = run_rsync('-a', '-I', '--inplace', '--no-whole-file',
++                  f'--block-size={BLOCK}', '--no-compress',
++                  '--stats', '--debug=deltasum1',
++                  f'{src3}', f'{dst3}', capture_output=True)
++
++hash_hits3, _, literal3, matched3 = parse_stats(proc3.stdout + proc3.stderr)
++
++if matched3 != expected_matched1 or literal3 != expected_literal1:
++    test_fail(
++        f"Case 3 (--inplace skip boundaries) missed valid matches: "
++        f"matched={matched3} (expected {expected_matched1}), "
++        f"literal={literal3} (expected {expected_literal1})"
++    )
++
++if hash_hits3 > MAX_EXPECTED_HITS1:
++    test_fail(
++        f"Case 3 (--inplace) rolled 1 byte at a time: hash_hits={hash_hits3} "
++        f"(expected <= {MAX_EXPECTED_HITS1})"
++    )
++
++assert_same(dst3, src3, label='Case 3: --inplace collision skip-boundary 
transfer')
++
++# ---------------------------------------------------------------------------
++# Case 4: Bunched-perturbation hazard demonstration (make_block vs spread)
++# ---------------------------------------------------------------------------
++# Documents that if a colliding block uses bunched perturbations (like (+1, 
-2, +1)
++# at bytes 0..2), partial overlapping boundary windows falsely trigger 
premature
++# skips, jumping over valid blocks and resulting in *increased literal data*
++# (safe fallback to network transfer) while preserving 100% file integrity.
++# Odd block sizes here should not result in more literal data.
++src4 = FROMDIR / 'bunched_hazard.bin'
++dst4 = TODIR / 'bunched_hazard.bin'
++
++# Valid block built with bunched perturbation (make_block)
++bunched_block = make_block(0)
++
++dst4.write_bytes(bunched_block * 16)
++
++# Interleave pure C_BYTE gaps with bunched blocks
++src4_payload = (
++    (bunched_block * 2)
++    + (bytes([C_BYTE]) * BLOCK)  # 1-block boundary gap
++    + (bunched_block * 4)
++)
++src4.write_bytes(src4_payload)
++
++proc4 = run_rsync('-a', '-I', '--no-whole-file', f'--block-size={BLOCK}',
++                  '--no-compress', '--stats', '--debug=deltasum1',
++                  f'{src4}', f'{dst4}', capture_output=True)
++
++hash_hits4, _, literal4, matched4 = parse_stats(proc4.stdout + proc4.stderr)
++
++# Because bunched perturbations trigger premature skips, literal data is
++# higher than the exact gap length (> 704 bytes), but file integrity holds.
++EXPECTED_BASE_LITERAL = BLOCK + 10
++if literal4 <= EXPECTED_BASE_LITERAL:
++    test_fail(f"Case 4 expected bunched perturbation to cause literal data 
inflation, got {literal4} bytes")
++
++assert_same(dst4, src4, label='Case 4: bunched-perturbation hazard transfer')
++
++print(
++    f"collision-skip test passed: boundary_hits={hash_hits1}, "
++    f"flood_hits={hash_hits2}, inplace_hits={hash_hits3}, "
++    f"bunched_literal={literal4} (exceeded {EXPECTED_BASE_LITERAL} due to 
premature skip)"
++)
+diff --git a/testsuite/hashsearch-dup-chain_test.py 
b/testsuite/hashsearch-dup-chain_test.py
+new file mode 100644
+index 0000000..a98426f
+--- /dev/null
++++ b/testsuite/hashsearch-dup-chain_test.py
+@@ -0,0 +1,225 @@
++"""Regression test for duplicate-chain unlinking in hash_search() (issue 
#1115).
++
++Covers:
++1. Forward layout (#1115): valid blocks at the beginning of the basis file
++   sitting behind > MAX_CHAIN_LEN (1024) duplicate colliding blocks.
++2. Multi-group interleaved layout: multiple distinct valid block types (A, D)
++   buried behind > MAX_CHAIN_LEN duplicate colliding blocks (B, E) in the same
++   hash bucket.
++3. `--inplace` SUMFLG_SAME_OFFSET preservation: mutating the chain on 
duplicate
++   false alarms must never let an unpinned node unlink a basis block carrying
++   SUMFLG_SAME_OFFSET when the unpinned duplicate later gets overwritten.
++4. `--inplace` duplicate SUMFLG_SAME_OFFSET unlinking: when > MAX_CHAIN_LEN
++   duplicate blocks all match in-place (earning SUMFLG_SAME_OFFSET), keeping
++   the first pinned representative must unlink the remaining pinned duplicates
++   so valid blocks behind them are still reached and matched with bounded
++   false_alarms.
++"""
++
++import re
++
++from rsyncfns import (
++    FROMDIR, TODIR, assert_same, rmtree, run_rsync, test_fail,
++)
++
++BLOCK = 704
++C_BYTE = 100
++
++
++def make_block(perturb_offset: int = -1, fill: int = C_BYTE) -> bytes:
++    b = bytearray([fill]) * BLOCK
++    if perturb_offset >= 0:
++        # (+1, -2, +1) preserves get_checksum1 (both s1 and s2) while
++        # producing a distinct strong checksum.
++        b[perturb_offset] = fill + 1
++        b[perturb_offset + 1] = fill - 2
++        b[perturb_offset + 2] = fill + 1
++    return bytes(b)
++
++
++def parse_stats(out: str):
++    m_delta = re.search(r'hash_hits=(\d+)\s+false_alarms=(\d+)', out)
++    m_lit = re.search(r'Literal data:\s*([\d,]+)\s*bytes', out)
++    m_match = re.search(r'Matched data:\s*([\d,]+)\s*bytes', out)
++    if not m_delta or not m_lit or not m_match:
++        test_fail(f"could not find deltasum/stats in rsync output:\n{out}")
++    return (
++        int(m_delta.group(1)),
++        int(m_delta.group(2)),
++        int(m_lit.group(1).replace(',', '')),
++        int(m_match.group(1).replace(',', '')),
++    )
++
++
++# Blocks A, B, D, E all share the exact same 32-bit weak checksum (sum1)
++# but have distinct strong checksums (sum2).
++block_a = make_block(0)
++block_b = make_block(4)
++block_d = make_block(8)
++block_e = make_block(12)
++
++# Distinct non-colliding blocks for --inplace overwrite testing
++block_x = make_block(-1, fill=88)
++block_y = make_block(-1, fill=69)
++
++rmtree(FROMDIR)
++rmtree(TODIR)
++FROMDIR.mkdir(parents=True, exist_ok=True)
++TODIR.mkdir(parents=True, exist_ok=True)
++
++# ---------------------------------------------------------------------------
++# Case 1: Forward layout (#1115) -- Valid blocks behind 1,500 duplicate B 
blocks
++# ---------------------------------------------------------------------------
++src1 = FROMDIR / 'forward1115.bin'
++dst1 = TODIR / 'forward1115.bin'
++
++N_HEAD = 8
++N_DUP = 1500  # > MAX_CHAIN_LEN (1024)
++
++payload1 = (block_a * N_HEAD) + (block_b * N_DUP)
++src1.write_bytes(payload1)
++dst1.write_bytes(payload1)
++
++proc1 = run_rsync('-a', '-I', '--no-whole-file', f'--block-size={BLOCK}',
++                  '--no-compress', '--stats', '--debug=deltasum1',
++                  f'{src1}', f'{dst1}', capture_output=True)
++
++_, false_alarms1, literal1, matched1 = parse_stats(proc1.stdout + 
proc1.stderr)
++
++if literal1 != 0 or matched1 != len(payload1):
++    test_fail(
++        f"Case 1 (#1115 forward) failed: literal={literal1} (expected 0), "
++        f"matched={matched1} (expected {len(payload1)})")
++
++if false_alarms1 > N_HEAD * 2:
++    test_fail(
++        f"Case 1 duplicate chain entries were not unlinked: "
++        f"false_alarms={false_alarms1} (expected <= {N_HEAD * 2})")
++
++assert_same(dst1, src1, label='Case 1: #1115 forward layout')
++
++# ---------------------------------------------------------------------------
++# Case 2: Multi-group interleaved duplicates in a single hash chain
++# ---------------------------------------------------------------------------
++src2 = FROMDIR / 'interleaved1115.bin'
++dst2 = TODIR / 'interleaved1115.bin'
++
++# Basis chain has 4 colliding groups stacked > MAX_CHAIN_LEN deep:
++# [600 x E] -> [4 x D] -> [600 x B] -> [4 x A]  (total chain = 1,208 > 1024)
++dst2_payload = (block_a * 4) + (block_b * 600) + (block_d * 4) + (block_e * 
600)
++# Source requests A first, then D, then B, then E:
++src2_payload = (block_a * 4) + (block_d * 4) + (block_b * 4) + (block_e * 4)
++
++dst2.write_bytes(dst2_payload)
++src2.write_bytes(src2_payload)
++
++proc2 = run_rsync('-a', '-I', '--no-whole-file', f'--block-size={BLOCK}',
++                  '--no-compress', '--stats', '--debug=deltasum1',
++                  f'{src2}', f'{dst2}', capture_output=True)
++
++_, false_alarms2, literal2, matched2 = parse_stats(proc2.stdout + 
proc2.stderr)
++
++if literal2 != 0 or matched2 != len(src2_payload):
++    test_fail(
++        f"Case 2 (multi-group chain) failed: literal={literal2} (expected 0), 
"
++        f"matched={matched2} (expected {len(src2_payload)})")
++
++if false_alarms2 > 64:
++    test_fail(
++        f"Case 2 excessive false_alarms={false_alarms2} (expected <= 64)")
++
++assert_same(dst2, src2, label='Case 2: multi-group duplicate unlinking')
++
++# ---------------------------------------------------------------------------
++# Case 3: --inplace SUMFLG_SAME_OFFSET preservation when mutating chain
++# ---------------------------------------------------------------------------
++src3 = FROMDIR / 'inplace_preserve.bin'
++dst3 = TODIR / 'inplace_preserve.bin'
++
++N_TAIL = 20
++
++# Basis has A at Block 0 (offset 0) and Block 2 (offset 2*BLOCK), plus 1,200
++# duplicate B blocks at the end so the chain is > MAX_CHAIN_LEN:
++# Chain order: [1200 x B] -> [Block 2: A] -> [Block 0: A]
++dst3.write_bytes(block_a + block_y + block_a + (block_y * N_TAIL) + (block_b 
* 1200))
++
++# Source:
++# - Block 0: A (matches Block 0 in-place -> sets SUMFLG_SAME_OFFSET on Block 
0,
++#   and unlinks the 1,199 duplicate B blocks ahead of it)
++# - Block 1: D (collides with A & B -> walks [B -> Block 2 (A) -> Block 0 
(A)];
++#   must NOT let unpinned Block 2 unlink pinned Block 0!)
++# - Block 2: X (overwrites Block 2 on disk, expiring Block 2 once offset > 
2*BLOCK)
++# - Blocks 3..22: 20 blocks of A (must match surviving Block 0!)
++src3.write_bytes(block_a + block_d + block_x + (block_a * N_TAIL))
++
++proc3 = run_rsync('-a', '-I', '--inplace', '--no-whole-file',
++                  f'--block-size={BLOCK}', '--no-compress',
++                  '--stats', '--debug=deltasum1',
++                  f'{src3}', f'{dst3}', capture_output=True)
++
++_, _, literal3, matched3 = parse_stats(proc3.stdout + proc3.stderr)
++
++expected_literal3 = 2 * BLOCK          # Only Block 1 (D) and Block 2 (X)
++expected_matched3 = (1 + N_TAIL) * BLOCK  # Block 0 (A) + 20 trailing A blocks
++
++if literal3 != expected_literal3 or matched3 != expected_matched3:
++    test_fail(
++        f"Case 3 (--inplace SUMFLG_SAME_OFFSET preservation) failed: "
++        f"literal={literal3} (expected {expected_literal3}), "
++        f"matched={matched3} (expected {expected_matched3})")
++
++assert_same(dst3, src3, label='Case 3: --inplace SUMFLG_SAME_OFFSET 
preservation')
++
++# ---------------------------------------------------------------------------
++# Case 4: --inplace unlinking of > MAX_CHAIN_LEN pinned SUMFLG_SAME_OFFSET 
dups
++# ---------------------------------------------------------------------------
++src4 = FROMDIR / 'inplace_pinned_dups.bin'
++dst4 = TODIR / 'inplace_pinned_dups.bin'
++
++N_PINNED_DUPS = 1500  # > MAX_CHAIN_LEN (1024)
++N_TRAIL_A = 16
++
++# DST: [Block 0: A] + [Blocks 1..1500: B] + [Block 1501: A]
++# Chain starts as: [1501: A] -> [1500..1: B] -> [0: A]
++# - At Block 0 (A): matches [1501: A] at chain head, swaps to aligned_i = 0,
++#   setting SUMFLG_SAME_OFFSET on Block 0 (A) without walking [1500..1: B].
++# - At Blocks 1..1500 (B): matches [1500: B], swaps to aligned_i = 1..1500,
++#   setting SUMFLG_SAME_OFFSET on ALL 1,500 B blocks!
++# - At Block 1501 (X): overwrites Block 1501 on disk (expiring [1501: A]).
++# - At Blocks 1502..1517 (16 x A): [1501: A] is expired, and all 1,500 B 
blocks
++#   carry SUMFLG_SAME_OFFSET. Keeping [1500: B] (last_false_flags & 
SUMFLG_SAME_OFFSET)
++#   must unlink the other 1,499 pinned B blocks so [0: A] is reached and 
matched!
++dst4.write_bytes(block_a + (block_b * N_PINNED_DUPS) + block_a)
++src4.write_bytes(block_a + (block_b * N_PINNED_DUPS) + block_x + (block_a * 
N_TRAIL_A))
++
++proc4 = run_rsync('-a', '-I', '--inplace', '--no-whole-file',
++                  f'--block-size={BLOCK}', '--no-compress',
++                  '--stats', '--debug=deltasum1',
++                  f'{src4}', f'{dst4}', capture_output=True)
++
++_, false_alarms4, literal4, matched4 = parse_stats(proc4.stdout + 
proc4.stderr)
++
++expected_literal4 = 1 * BLOCK             # Only Block 1501 (X)
++expected_matched4 = (1 + N_PINNED_DUPS + N_TRAIL_A) * BLOCK
++
++if literal4 != expected_literal4 or matched4 != expected_matched4:
++    test_fail(
++        f"Case 4 (--inplace pinned duplicate unlinking) failed: "
++        f"literal={literal4} (expected {expected_literal4}), "
++        f"matched={matched4} (expected {expected_matched4})")
++
++# 1,500 B blocks each hit [1501: A] once (1,500), plus 16 trailing A blocks
++# each hit [1500: B] once (16) = 1,516 false_alarms (vs > 17,000 if not 
unlinked).
++MAX_EXPECTED_ALARMS4 = N_PINNED_DUPS + (N_TRAIL_A * 2)
++if false_alarms4 > MAX_EXPECTED_ALARMS4:
++    test_fail(
++        f"Case 4 pinned duplicates were not unlinked: "
++        f"false_alarms={false_alarms4} (expected <= {MAX_EXPECTED_ALARMS4})")
++
++assert_same(dst4, src4, label='Case 4: --inplace pinned duplicate unlinking')
++
++print(
++    f"dup-chain test passed: forward_alarms={false_alarms1}, "
++    f"interleaved_alarms={false_alarms2}, inplace_matched={matched3}, "
++    f"pinned_dups_matched={matched4} (alarms={false_alarms4})"
++)
diff -Nru rsync-3.5.0+ds1/debian/patches/series 
rsync-3.5.0+ds1/debian/patches/series
--- rsync-3.5.0+ds1/debian/patches/series       2026-09-15 18:46:30.000000000 
-0700
+++ rsync-3.5.0+ds1/debian/patches/series       2026-11-07 14:03:56.000000000 
-0800
@@ -25,3 +25,5 @@
 options_c_Fix_files-from_confinement_for_local_and_SSH_transfers.patch
 syscall_follow_trusted_sender_ancestor_links.patch
 
receiver_c_Tighten_alt-dest_path_resolution_and_partial-dir_state_validation.patch
+
+CVE-2026-119741.patch

Reply via email to