Repository navigation
match: bound hash_search collision work - #1132
Conversation
…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 (RsyncProject#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.
| 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; |
There was a problem hiding this comment.
Four misses do not prove that the next offsets cannot match. This skips valid basis matches and can substantially increase literal data. Split out the #1115 chain correction; any collision-flood policy needs explicit CPU and bandwidth bounds plus regressions with valid matches at each skip boundary. Also cover --inplace same-offset preservation when mutating the chain.
There was a problem hiding this comment.
This doesn't trigger on 4 normal misses. It only triggers on 4 consecutive 32-bit weak-checksum collisions (done_csum2 == 1) where the strong checksum failed to match every time. On normal/random data hitting 32 bytes weak sum collision 4 bytes at a row is practically impossible, so the data is either not matching already or this is a crafted checksum. Also, as noted in the PR description, on files with distinct repeated-byte runs, large even block sizes (like 64K/128K) wrap modulo 65536 to the same weak checksum (0), which can trigger this skip and send the colliding window as literal data, so using an odd --block-size is better there to avoid weak-checksum collisions on repeated bytes altogether. In both cases, there is no other way to stop the sender from pegging the CPU: if we don't drop the colliding block and jump offset to the next block, hash_search() is forced to walk 1 byte at a time and compute a full 64K–128K strong checksum (get_checksum2()) on every single byte O(NxB) , which still hangs the sender even when the chain length is 1.
I’ll broader the test coverage.
There was a problem hiding this comment.
Hmmmmm the skip can jump over valid matches and turn those regions into literal data. That might be an acceptable trade-off for separate collision-flood hardening but it probably should not be bundled with the regression fix. Please split out the duplicate-chain correction first.
9d8bee6 to
ac5a566
Compare
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.
ac5a566 to
da70230
Compare
|
Another inplace (updating_basis_file) edge-case/max-chain regression remained. When aligned duplicate blocks (0x00 sparse runs) match in-place, they all receive SUMFLG_SAME_OFFSET and bypass chain deduplication. When more than MAX_CHAIN_LEN pinned duplicates accumulate, they exhaust the chain limit, spike false_alarms, and block valid matches further down the chain (causing extra literal data to be sent instead of pegging the cpu due to the collision limit). Fix this by tracking last_false_flags so that once a SUMFLG_SAME_OFFSET node is retained as last_false_sum2, redundant duplicate nodes carrying SUMFLG_SAME_OFFSET are unlinked as well. |
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.
a679add to
d8b6bf1
Compare
|
See previous review comments. But overall neither issue really warrants a CVE on the evidence here. A performance regression and the collision case only shows one per-connection sender process doing excessive work; it does not demonstrate memory corruption, cross-connection impact or daemon loss. Thank you for your work thus far nonetheless. |
|
@steadytao To put the literal-data concern in perspective: the max chain limit currently in master already skips vastly more valid basis matches on repeated-sequence files than Because build_hash_table() inserts blocks in reverse order (LIFO), any sparse file with Current patch would result in 0 literal data as the old versions without all that cpu cost. It skips a match if a block is synthetically crafted with a bunched Adler-32 perturbation ( Also, Here is how the 3 states compare:
I actually thought you meant split the tests, but i combined them in one PR so we actually finish fixing the algorithmic complexity issues in |
|
Hmm. Fair point, good to merge. |
|
Thanks for the fix.
On the CVE question: I agree the #1115 slowdown on legitimate data doesn't need one by itself. The per-offset strong-checksum cost is a different matter, though. SECURITY.md describes CVE-2026-70453 (rated HIGH) as preventing a crafted checksum set from making the sender "pin one connection's CPU" in It would also help downstream. The 70453 cap was backported to the v3.4.1-sec-patches3/4 and v3.2.7-sec-patches3/4 branches, so distributions that shipped it need an identifier to track this follow-up fix. |
|
Hmmm. Will speak to tridge on this one. |
|
@tridge - for tracking |
|
I agree, this still pin one connections CPU so it needs an independent CVE for tracking the complete fix of hash_search(). |
Summary
This PR resolves the sparse-file performance regression reported in #1115 (introduced by the
MAX_CHAIN_LENcap for #217) and bounds the CPU work spent inhash_search()when rolling across regions with repeated weak-checksum collisions.Root Cause
Duplicate entries in a hash chain exhausting
MAX_CHAIN_LEN(rsync 3.5.0 is very slow and uses a lot of CPU compared to 3.4.x with large sparse files #1115):When transferring large sparse or disk image files (where the block size reaches 64 KiB or 128 KiB), long runs of identical blocks (such as
0x00sparse holes) land on the same hash chain as other constant-byte blocks (such as0xFF) due toget_checksum1()arithmetic modulo 65536. Becausebuild_hash_table()inserts blocks in sequential order, thousands of identical0x00blocks sit at the front of the chain. Whenhash_search()looks for a block sitting behind them, it walks andmemcmps every identical0x00entry until hittingMAX_CHAIN_LEN(1024), aborting before it can reach valid matching blocks further down the chain.Per-byte strong-checksum evaluation on unmatched collision runs:
When a chain fails to produce a strong-checksum match (either after exhausting
MAX_CHAIN_LENor after computingget_checksum2()),hash_search()falls back to rolling forward 1 byte at a time. Across a constant-byte or colliding region that does not exist in the basis file, this forces a fullget_checksum2()calculation at every single byte offset.Changes
last_false_sum2):When a chain entry fails the strong-checksum comparison, record its
sum2pointer inlast_false_sum2. Subsequent entries in the same chain that share the exact samesum1,len, andsum2(and do not carrySUMFLG_SAME_OFFSETin--inplacemode) are unlinked from the chain (*prev = s->sums[i].chain) without incrementingchain_len. One representative of that block remains in the chain for future matches (withwant_i/aligned_icontinuing to resolve sequential indices directly froms->sums[]), while eliminating redundantmemcmpcalls and allowinghash_search()to reach valid blocks behind long duplicate runs.consecutive_false_offsets):If 4 consecutive 1-byte rolling offsets hit a weak-checksum collision (
done_csum2orchain_len > MAX_CHAIN_LEN) without matching any block in the chain, advanceoffsetby a full block window (k - 1prior to the loop's++offset) rather than computing a strong checksum on every byte.testsuite/collision_test.py):Verifies both duplicate-chain unlinking (rsync 3.5.0 is very slow and uses a lot of CPU compared to 3.4.x with large sparse files #1115) and bounded
hash_hitson consecutive unmatched weak-checksum collisions.Note on Even Block Sizes and Repeated Sequences
Because$65536$ , large power-of-2 / even block sizes (such as $65536$ and gives all 256 constant-byte fills distinct weak checksums.
get_checksum1()accumulatess1ands2modulo65536and131072) can cause distinct constant-byte sequences to share the same 32-bit weak checksum. When rolling across unmatched repeated-byte sequences under an even block size, repeated weak collisions will trigger the consecutive-collision skip and transfer the colliding window as literal data. Users transferring files dominated by distinct constant-byte runs who want to avoid weak-checksum collisions altogether can specify an odd--block-size(e.g.,--block-size=131071), which is coprime toMany thanks to @samueloph and @dfskoll for reporting the
MAX_CHAIN_LENregression.