diff --git a/match.c b/match.c index 4fa3b8599..d427c62ea 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 000000000..34aa1f45a --- /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 000000000..a98426ff0 --- /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})" +)