Version in base suite: 3.4.1+ds1-5+deb13u4 Version in overlay suite: 3.5.0+ds1-0+deb13u1 Base version: rsync_3.5.0+ds1-0+deb13u1 Target version: rsync_3.5.0+ds1-0+deb13u2 Base file: /srv/ftp-master.debian.org/ftp/pool/main/r/rsync/rsync_3.5.0+ds1-0+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/r/rsync/rsync_3.5.0+ds1-0+deb13u2.dsc changelog | 8 patches/CVE-2026-119741.patch | 656 ++++++++++++++++++++++++++++++++++++++++++ patches/series | 2 3 files changed, 666 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpqejr2cqo/rsync_3.5.0+ds1-0+deb13u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpqejr2cqo/rsync_3.5.0+ds1-0+deb13u2.dsc: no acceptable signature found 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-16 01:46:30.000000000 +0000 +++ rsync-3.5.0+ds1/debian/changelog 2026-11-07 22:03:56.000000000 +0000 @@ -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 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 1970-01-01 00:00:00.000000000 +0000 +++ rsync-3.5.0+ds1/debian/patches/CVE-2026-119741.patch 2026-11-07 22:03:56.000000000 +0000 @@ -0,0 +1,656 @@ +From a7404a9a633cff28ee1602f328b9c3fa69cdbd04 Mon Sep 17 00:00:00 2001 +From: Omar Elsayed +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 + * 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-16 01:46:30.000000000 +0000 +++ rsync-3.5.0+ds1/debian/patches/series 2026-11-07 22:03:56.000000000 +0000 @@ -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