bzip3-rs 1.2.0

Pure-Rust port of the bzip3 compressor (no C FFI)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
//! Lempel-Ziv-plus-prediction stage, forward and inverse.
//!
//! This stage collapses long redundant runs before entropy coding. The
//! encoder replaces repeats of at least [`MIN_MATCH`] bytes with a marker
//! run. The decoder restores the repeats. A literal marker byte escapes as
//! marker plus [`ESCAPE`]. Both directions clear the hash table on entry,
//! exactly like the reference. The decoder clamps match copies to the free
//! output space, so untrusted data cannot force an overflow.

use crate::buffer::filled;
use crate::error::Error;
use crate::error::Result;

/// Bits of the hash table index. The table holds one slot per index value.
pub const DICTIONARY_BITS: u32 = 18;

/// Slots in the hash table. Matches the reference table byte size.
pub const TABLE_LEN: usize = 1_usize << DICTIONARY_BITS;

/// Shortest repeat the encoder replaces with a match run.
pub const MIN_MATCH: usize = 40;

/// Smallest input the encoder accepts. Shorter inputs return `None`.
pub const MIN_INPUT: usize = MIN_MATCH + 32;

/// Marker byte that starts a match run.
pub const MATCH_MARKER: u8 = 0xf2;

/// Escape byte. A literal marker encodes as marker plus escape.
pub const ESCAPE: u8 = 255;

/// Match lengths encode minus [`MIN_MATCH`], in base 254 digits.
const LENGTH_BASE: usize = 254;

/// Stored value of one full length digit.
const LENGTH_BYTE: u8 = 254;

/// Bytes of slack the encoder keeps at the end of the output.
const ENCODE_RESERVE: usize = 8;

/// Hash table of the two stages.
pub type HashTable = [i32; TABLE_LEN];

/// Build a fresh zeroed hash table for [`encode_into`] and [`decode_into`].
///
/// # Errors
///
/// Returns [`Error::OutOfMemory`] when the table allocation fails.
#[inline]
pub fn new_table() -> Result<Box<HashTable>> {
    filled(TABLE_LEN, 0)
        .and_then(|table| table.into_boxed_slice().try_into().ok())
        .ok_or(Error::OutOfMemory)
}

/// Hash the 4-byte context into a table index. Mirrors the reference.
#[inline]
const fn hash(ctx: u32) -> usize {
    let raw = (ctx >> 15) ^ ctx ^ (ctx >> 3);
    (raw & ((1_u32 << DICTIONARY_BITS) - 1)) as usize
}

/// Load 4 bytes as one word. Uses native byte order like the reference.
#[inline]
fn load(block: &[u8], at: usize) -> u32 {
    let word = &block[at..at + 4];
    u32::from_ne_bytes([word[0], word[1], word[2], word[3]])
}

/// Fold the 4 bytes before `end` into a context value. The newest byte
/// sits in the low bits.
#[inline]
fn context_at(block: &[u8], end: usize) -> u32 {
    let word = &block[end - 4..end];
    u32::from_be_bytes([word[0], word[1], word[2], word[3]])
}

/// Encode `input` into `output` with scratch table `table`.
///
/// Needs `input.len() >= MIN_INPUT` and `output.len() >= input.len()`.
/// The table is the full table. The reserve counts from the input size, so any
/// larger output stays byte-identical to the reference. Returns `None`
/// exactly where the reference returns `-1`. Clears the table on entry.
#[must_use]
#[inline]
pub fn encode_into(input: &[u8], output: &mut [u8], table: &mut HashTable) -> Option<usize> {
    encode_kernel(input, output, table)
}

/// Cursor of either LZP pass: input and output positions plus context.
///
/// Bundles the positions that the match and tail helpers move together,
/// so each helper takes few arguments.
#[derive(Debug)]
struct PassCursor {
    /// Next input offset to code, or next coded offset to read.
    pos: usize,
    /// Next output offset to write.
    next_out: usize,
    /// Context of the bytes before the written output.
    ctx: u32,
}

// Reason: positions stay below 2 GiB through the block API, so the
// casts below wrap exactly where the reference relies on 32-bit slots.
// Reason: `ref_pos` derives from a positive slot, so it never goes negative.
// Reason: the final length byte truncates exactly like the reference store.
#[expect(
    clippy::cast_possible_wrap,
    clippy::cast_sign_loss,
    clippy::cast_possible_truncation
)]
/// Body of [`encode_into`].
fn encode_kernel(input: &[u8], output: &mut [u8], table: &mut HashTable) -> Option<usize> {
    if input.len() < MIN_INPUT || output.len() < input.len() {
        return None;
    }
    table.fill(0);
    output[..4].copy_from_slice(&input[..4]);
    let mut cursor = PassCursor {
        pos: 4,
        next_out: 4,
        ctx: context_at(input, 4),
    };
    let end = input.len();
    let bound = end - MIN_INPUT;
    let out_eob = end - ENCODE_RESERVE;
    // Latest input offset covered by a rejected short match. Later match
    // tries must still agree with the rejected bytes at this offset.
    let mut heur = 0_usize;
    while cursor.pos < bound && cursor.next_out < out_eob {
        let slot = hash(cursor.ctx);
        let val = table[slot];
        table[slot] = cursor.pos as i32;
        if val > 0 {
            let ref_pos = val as usize;
            // Both windows sit inside the input: `pos + 40` stays below
            // `end - 32`, and `ref_pos` stays below `pos`.
            if usable_match(input, cursor.pos, ref_pos, heur) {
                let len = extend_match(input, cursor.pos, ref_pos, bound);
                if len < MIN_MATCH {
                    fence_short(&mut heur, cursor.pos, len);
                    emit_literal(
                        output,
                        input,
                        &mut cursor.pos,
                        &mut cursor.next_out,
                        &mut cursor.ctx,
                        true,
                    );
                } else {
                    let grown = match_tail_len(input, cursor.pos, ref_pos, len);
                    push_match(input, output, &mut cursor, grown, out_eob);
                }
            } else {
                // Warm slot without usable match: same miss rule as above.
                emit_literal(
                    output,
                    input,
                    &mut cursor.pos,
                    &mut cursor.next_out,
                    &mut cursor.ctx,
                    true,
                );
            }
        } else {
            // Cold slot: the decoder reads the marker as one literal byte,
            // so no escape byte follows.
            emit_literal(
                output,
                input,
                &mut cursor.pos,
                &mut cursor.next_out,
                &mut cursor.ctx,
                false,
            );
        }
    }
    // The tail pass copies literals only. It still escapes a marker when
    // the decoder would take the match path for the same table state.
    encode_tail(input, output, table, &mut cursor, out_eob, end);
    if cursor.next_out >= out_eob {
        return None;
    }
    Some(cursor.next_out)
}

/// Test whether the table hit at `ref_pos` can code `pos`.
///
/// Compares the first and match words and checks the rejected-match
/// fence, exactly as the reference does.
#[inline]
fn usable_match(input: &[u8], pos: usize, ref_pos: usize, heur: usize) -> bool {
    load(input, pos + MIN_MATCH - 4) == load(input, ref_pos + MIN_MATCH - 4)
        && load(input, pos) == load(input, ref_pos)
        && !(heur > pos && load(input, heur) != load(input, ref_pos + (heur - pos)))
}

/// Grow `len` past the word loop with the three tail bytes.
///
/// Tail reads stay inside the input: the word loop leaves `pos + len`
/// below `end - 66`, and `ref_pos` stays below `pos`.
#[inline]
fn match_tail_len(input: &[u8], pos: usize, ref_pos: usize, mut len: usize) -> usize {
    for _ in 0..3 {
        if input[pos + len] == input[ref_pos + len] {
            len += 1;
        }
    }
    len
}

/// Fence later match tries after a rejected short match.
///
/// Short matches stay literal, but they fence later tries.
/// `heur` stays below `end - 32`, so later reads stay safe.
#[inline]
const fn fence_short(heur: &mut usize, pos: usize, len: usize) {
    if *heur < pos + len {
        *heur = pos + len;
    }
}

/// Record one match run of `len` bytes at the cursor.
///
/// Moves past the run, refreshes the context, and stores the marker
/// plus the length minus [`MIN_MATCH`] as base 254 digits.
fn push_match(
    input: &[u8],
    output: &mut [u8],
    cursor: &mut PassCursor,
    len: usize,
    out_eob: usize,
) {
    cursor.pos += len;
    cursor.ctx = context_at(input, cursor.pos);
    output[cursor.next_out] = MATCH_MARKER;
    cursor.next_out += 1;
    write_match_len(output, &mut cursor.next_out, out_eob, len - MIN_MATCH);
}

/// Copy the trailing literals that the main loop leaves.
///
/// Still escapes a marker when the decoder would take the match path
/// for the same table state.
// Reason: positions stay below 2 GiB through the block API, so the
// store below wraps exactly where the reference relies on 32-bit slots.
#[expect(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
fn encode_tail(
    input: &[u8],
    output: &mut [u8],
    table: &mut HashTable,
    cursor: &mut PassCursor,
    out_eob: usize,
    end: usize,
) {
    cursor.ctx = context_at(input, cursor.pos);
    while cursor.pos < end && cursor.next_out < out_eob {
        let slot = hash(cursor.ctx);
        let val = table[slot];
        table[slot] = cursor.pos as i32;
        emit_literal(
            output,
            input,
            &mut cursor.pos,
            &mut cursor.next_out,
            &mut cursor.ctx,
            val > 0,
        );
    }
}

/// Count the bytes that `pos` and `ref_pos` share, in steps of 4 bytes.
///
/// Start at 4, as the match test already compared the first word. Stop at
/// the first unequal word or when `pos + len` reaches `bound`. The result
/// matches a word-by-word loop. Each window reads [`WINDOW`] bytes: `pos + len`
/// stays below `bound`, which is 72 bytes before the end, and `ref_pos`
/// stays below `pos`.
fn extend_match(input: &[u8], pos: usize, ref_pos: usize, bound: usize) -> usize {
    let mut len = 4_usize;
    while pos + len < bound {
        let diff = first_diff(&input[pos + len..], &input[ref_pos + len..]);
        // Words left before `bound`, counted from `pos + len`. Never zero here.
        let room = (bound - pos - len).div_ceil(4);
        let words = (diff / 4).min(room);
        len += 4 * words;
        if words < WINDOW / 4 {
            break;
        }
    }
    len
}

/// Bytes in one window of the match extension.
const WINDOW: usize = 32;

/// Return the index of the first unequal byte of two windows.
///
/// Return [`WINDOW`] when all bytes are equal. Both slices need [`WINDOW`] bytes.
#[inline]
fn first_diff(left: &[u8], right: &[u8]) -> usize {
    let left: &[u8; WINDOW] = left[..WINDOW].try_into().unwrap();
    let right: &[u8; WINDOW] = right[..WINDOW].try_into().unwrap();
    if left == right {
        return WINDOW;
    }
    first_unequal(left, right)
}

/// Return the index of the first unequal byte of two windows that differ.
///
/// The call stays out of line. Inline, the compiler merges this search with
/// the equal test and builds a slow byte-by-byte vector body.
#[inline(never)]
fn first_unequal(left: &[u8; WINDOW], right: &[u8; WINDOW]) -> usize {
    left.iter()
        .zip(right)
        .position(|(a, b)| a != b)
        .unwrap_or(0)
}

/// Emit one literal, plus the escape after a marker when `warm` is set.
///
/// The decoder takes the match path for a warm slot, so a marker
/// literal needs its escape byte there. A cold slot needs none. Both
/// stores land inside `output`: the caller holds `next_out` below the
/// reserve, and the escape below the input size, which never exceeds
/// the output size.
fn emit_literal(
    output: &mut [u8],
    input: &[u8],
    pos: &mut usize,
    next_out: &mut usize,
    ctx: &mut u32,
    warm: bool,
) {
    let next = input[*pos];
    output[*next_out] = next;
    *next_out += 1;
    *pos += 1;
    *ctx = (*ctx << 8) | u32::from(next);
    if warm && next == MATCH_MARKER {
        output[*next_out] = ESCAPE;
        *next_out += 1;
    }
}

/// Store a match length minus [`MIN_MATCH`] as base 254 digits.
///
/// Stops early at the reserve, like the reference. The caller then reports
/// no saving, so the cut digits never reach a decoder.
// Reason: the caller keeps `next_out` at or below the reserve, so each
// store below lands inside the output. The final byte truncates exactly
// like the reference store when the digit run cuts short.
#[expect(clippy::cast_possible_truncation)]
const fn write_match_len(
    output: &mut [u8],
    next_out: &mut usize,
    out_eob: usize,
    mut extra: usize,
) {
    while extra >= LENGTH_BASE {
        extra -= LENGTH_BASE;
        output[*next_out] = LENGTH_BYTE;
        *next_out += 1;
        if *next_out >= out_eob {
            break;
        }
    }
    output[*next_out] = extra as u8;
    *next_out += 1;
}

// Reason: positions stay below 2 GiB through the block API, so the
// slot store below wraps exactly where the reference relies on 32-bit slots.
#[expect(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
/// Decode `input` into `output` with scratch table `table`.
///
/// Needs `input.len() >= 4`, `output.len() >= 4`, and `table.len() >=
/// TABLE_LEN`. Match copies stop at the end of `output`, like the clamped
/// reference. Returns the decoded size. Clears the table on entry.
///
/// # Errors
///
/// Returns [`Error::CorruptStageData`] when the payload ends inside a match run or the
/// buffers cannot hold a valid decode. The block layer maps every decode
/// failure to this variant, exactly like the reference.
pub fn decode_into(input: &[u8], output: &mut [u8], table: &mut HashTable) -> Result<usize> {
    if input.len() < 4 || output.len() < 4 {
        return Err(Error::CorruptStageData);
    }
    table.fill(0);
    output[..4].copy_from_slice(&input[..4]);
    let mut cursor = PassCursor {
        pos: 4,
        next_out: 4,
        ctx: context_at(output, 4),
    };
    let end = input.len();
    let capacity = output.len();
    while cursor.pos < end && cursor.next_out < capacity {
        let slot = hash(cursor.ctx);
        let val = table[slot];
        table[slot] = cursor.next_out as i32;
        if input[cursor.pos] == MATCH_MARKER && val > 0 {
            decode_marker(input, output, &mut cursor, val, end, capacity)?;
        } else {
            let next = input[cursor.pos];
            output[cursor.next_out] = next;
            cursor.next_out += 1;
            cursor.pos += 1;
            cursor.ctx = (cursor.ctx << 8) | u32::from(next);
        }
    }
    Ok(cursor.next_out)
}

// Reason: `ref_pos` derives from a positive slot, so it never goes negative.
#[expect(clippy::cast_sign_loss)]
/// Decode one marker run: an escaped literal or a match copy.
///
/// Reads the length digits, copies the match clamped to the free
/// output space, and refreshes the context.
///
/// # Errors
///
/// Returns [`Error::CorruptStageData`] when the payload ends inside
/// the run or the buffers cannot hold a valid decode.
#[inline]
fn decode_marker(
    input: &[u8],
    output: &mut [u8],
    cursor: &mut PassCursor,
    val: i32,
    end: usize,
    capacity: usize,
) -> Result<()> {
    cursor.pos += 1;
    if cursor.pos == end {
        return Err(Error::CorruptStageData);
    }
    if input[cursor.pos] == ESCAPE {
        cursor.pos += 1;
        output[cursor.next_out] = MATCH_MARKER;
        cursor.next_out += 1;
        cursor.ctx = (cursor.ctx << 8) | u32::from(MATCH_MARKER);
    } else {
        let max_len = capacity - cursor.next_out;
        let len = read_match_len(input, &mut cursor.pos, end, max_len)?;
        // Stored slots only hold past output offsets, so `ref_pos`
        // stays below `next_out` and every read lands inside `output`.
        let ref_pos = val as usize;
        let stop = if len > max_len {
            capacity
        } else {
            cursor.next_out + len
        };
        copy_match(output, ref_pos, stop, &mut cursor.next_out);
        cursor.ctx = context_at(output, cursor.next_out);
    }
    Ok(())
}

/// Read the base 254 length digits of one match run.
///
/// Adds each digit while `len` stays within `max_len` plus one digit,
/// so the total cannot wrap. Advances `pos` past the terminator.
///
/// # Errors
///
/// Returns [`Error::CorruptStageData`] when the payload ends inside
/// the run.
#[inline]
fn read_match_len(input: &[u8], pos: &mut usize, end: usize, max_len: usize) -> Result<usize> {
    let mut len = MIN_MATCH;
    loop {
        if *pos == end {
            return Err(Error::CorruptStageData);
        }
        // `len` stays within `max_len` plus one digit, so it cannot wrap.
        if len <= max_len {
            len += usize::from(input[*pos]);
        }
        let byte = input[*pos];
        *pos += 1;
        if byte != LENGTH_BYTE {
            break;
        }
    }
    Ok(len)
}

/// Copy a match run, so overlaps repeat fresh bytes.
///
/// The source ends where the copied bytes begin, so each step copies
/// a block that does not overlap its source. A block doubles the
/// repeated span of the step before it.
fn copy_match(output: &mut [u8], ref_pos: usize, stop: usize, next_out: &mut usize) {
    // `ref_pos` is below `next_out`. The floor keeps the loop finite anyway.
    let dist = (*next_out - ref_pos).max(1);
    let count = stop - *next_out;
    let mut copied = 0;
    while copied < count {
        let chunk = (count - copied).min(dist + copied);
        output.copy_within(ref_pos..ref_pos + chunk, *next_out + copied);
        copied += chunk;
    }
    *next_out = stop;
}

#[cfg(test)]
mod tests {
    use super::HashTable;
    use super::decode_into;
    use super::encode_into;
    use super::new_table;
    use crate::error::Error;

    fn fresh_table() -> Box<HashTable> {
        new_table().expect("table allocates")
    }

    fn encode(input: &[u8]) -> Option<Vec<u8>> {
        let mut table = fresh_table();
        let mut out = vec![0_u8; input.len()];
        encode_into(input, &mut out, &mut table).map(|n| {
            out.truncate(n);
            out
        })
    }

    fn decode(enc: &[u8], capacity: usize) -> Result<Vec<u8>, Error> {
        let mut table = fresh_table();
        let mut out = vec![0_u8; capacity];
        decode_into(enc, &mut out, &mut table).map(|n| {
            out.truncate(n);
            out
        })
    }

    #[test]
    fn forward_matches_oracle_minmatch40() {
        let raw = include_bytes!("../tests/vectors/lzp_minmatch40.in");
        let want = include_bytes!("../tests/vectors/lzp_minmatch40.enc");
        assert_eq!(encode(raw), Some(want.to_vec()));
    }

    #[test]
    fn forward_matches_oracle_minmatch120() {
        let raw = include_bytes!("../tests/vectors/lzp_minmatch120.in");
        let want = include_bytes!("../tests/vectors/lzp_minmatch120.enc");
        assert_eq!(encode(raw), Some(want.to_vec()));
    }

    #[test]
    fn forward_matches_oracle_escape() {
        let raw = include_bytes!("../tests/vectors/lzp_escape.in");
        let want = include_bytes!("../tests/vectors/lzp_escape.enc");
        assert_eq!(encode(raw), Some(want.to_vec()));
    }

    #[test]
    fn forward_matches_oracle_longmatch() {
        let raw = include_bytes!("../tests/vectors/lzp_longmatch.in");
        let want = include_bytes!("../tests/vectors/lzp_longmatch.enc");
        assert_eq!(encode(raw), Some(want.to_vec()));
    }

    #[test]
    fn forward_matches_oracle_dictedge() {
        let raw = include_bytes!("../tests/vectors/lzp_dictedge.in");
        let want = include_bytes!("../tests/vectors/lzp_dictedge.enc");
        assert_eq!(encode(raw), Some(want.to_vec()));
    }

    #[test]
    fn forward_matches_oracle_text() {
        let raw = include_bytes!("../tests/vectors/lzp_text.in");
        let want = include_bytes!("../tests/vectors/lzp_text.enc");
        assert_eq!(encode(raw), Some(want.to_vec()));
    }

    #[test]
    fn forward_rejects_oracle_negatives() {
        let cases: [&[u8]; 5] = [
            include_bytes!("../tests/vectors/lzp_random.in"),
            include_bytes!("../tests/vectors/lzp_nomatch39.in"),
            include_bytes!("../tests/vectors/lzp_short71.in"),
            include_bytes!("../tests/vectors/lzp_short72.in"),
            include_bytes!("../tests/vectors/lzp_short73.in"),
        ];
        for raw in cases {
            assert!(encode(raw).is_none(), "input of {} bytes", raw.len());
        }
    }

    #[test]
    fn inverse_restores_oracle_vectors() {
        let pairs: [(&[u8], &[u8]); 6] = [
            (
                include_bytes!("../tests/vectors/lzp_minmatch40.enc"),
                include_bytes!("../tests/vectors/lzp_minmatch40.in"),
            ),
            (
                include_bytes!("../tests/vectors/lzp_minmatch120.enc"),
                include_bytes!("../tests/vectors/lzp_minmatch120.in"),
            ),
            (
                include_bytes!("../tests/vectors/lzp_escape.enc"),
                include_bytes!("../tests/vectors/lzp_escape.in"),
            ),
            (
                include_bytes!("../tests/vectors/lzp_longmatch.enc"),
                include_bytes!("../tests/vectors/lzp_longmatch.in"),
            ),
            (
                include_bytes!("../tests/vectors/lzp_dictedge.enc"),
                include_bytes!("../tests/vectors/lzp_dictedge.in"),
            ),
            (
                include_bytes!("../tests/vectors/lzp_text.enc"),
                include_bytes!("../tests/vectors/lzp_text.in"),
            ),
        ];
        for (enc, raw) in pairs {
            let got = decode(enc, raw.len()).expect("oracle payload decodes");
            assert_eq!(got, raw);
        }
    }

    #[test]
    fn roundtrip_without_fixture() {
        let mut raw = [0_u8; 300];
        let mut next = 0_u8;
        for cell in &mut raw {
            // Cycles every 64 bytes, so repeats span the match window.
            *cell = next;
            next = if next == 63 { 0 } else { next + 1 };
        }
        let enc = encode(&raw).expect("periodic input compresses");
        let back = decode(&enc, raw.len()).expect("own output decodes");
        assert_eq!(back, raw);
    }

    #[test]
    fn corrupt_payloads_yield_crc() {
        let trunc = include_bytes!("../tests/vectors/lzp_trunc.bad");
        let flood = include_bytes!("../tests/vectors/lzp_flood.bad");
        let empty: &[u8] = &[];
        let tiny: &[u8] = b"ABC";
        for bad in [trunc.as_slice(), flood.as_slice(), empty, tiny] {
            assert!(matches!(decode(bad, 4096), Err(Error::CorruptStageData)));
        }
    }

    #[test]
    fn long_match_clamps_to_output() {
        let flood = include_bytes!("../tests/vectors/lzp_floodterm.bad");
        let full = decode(flood, 4096).expect("roomy decode succeeds");
        assert!(full.len() > 100);
        let clamped = decode(flood, 100).expect("small decode clamps");
        assert_eq!(clamped, full[..100]);
    }

    #[test]
    fn undersized_buffers_fail_without_panic() {
        let raw = include_bytes!("../tests/vectors/lzp_escape.in");
        let mut table = fresh_table();
        let mut tiny = vec![0_u8; 8];
        assert!(encode_into(raw, &mut tiny, &mut table).is_none());
        let mut out = vec![0_u8; raw.len()];
        let enc = include_bytes!("../tests/vectors/lzp_escape.enc");
        let mut no_room = [0_u8; 2];
        assert!(matches!(
            decode_into(enc, &mut no_room, &mut fresh_table()),
            Err(Error::CorruptStageData)
        ));
        let empty: &[u8] = &[];
        assert!(matches!(
            decode_into(empty, &mut out, &mut fresh_table()),
            Err(Error::CorruptStageData)
        ));
    }

    #[test]
    fn table_state_does_not_leak_between_calls() {
        // The reference clears its table on every call. Reused tables
        // must give the same bytes as fresh ones in both directions.
        let text = include_bytes!("../tests/vectors/lzp_text.in");
        let esc = include_bytes!("../tests/vectors/lzp_escape.in");
        let room = text.len().max(esc.len());
        let mut table = fresh_table();
        let mut out = vec![0_u8; room];
        let first = encode_into(text, &mut out, &mut table).map(|n| out[..n].to_vec());
        let _ = encode_into(esc, &mut out, &mut table);
        let again = encode_into(text, &mut out, &mut table).map(|n| out[..n].to_vec());
        assert_eq!(first, again);
        let text_enc = include_bytes!("../tests/vectors/lzp_text.enc");
        let esc_enc = include_bytes!("../tests/vectors/lzp_escape.enc");
        let mut dec = vec![0_u8; room];
        let n1 = decode_into(text_enc, &mut dec, &mut table).expect("decodes");
        let first_dec = dec[..n1].to_vec();
        decode_into(esc_enc, &mut dec, &mut table).ok();
        let n2 = decode_into(text_enc, &mut dec, &mut table).expect("decodes");
        let again_dec = dec[..n2].to_vec();
        assert_eq!(first_dec, again_dec);
    }

    /// Word-by-word match extension, kept as the reference for the window one.
    fn extend_match_words(input: &[u8], pos: usize, ref_pos: usize, bound: usize) -> usize {
        let word = |at: usize| u32::from_ne_bytes(input[at..at + 4].try_into().unwrap());
        let mut len = 4;
        while pos + len < bound {
            if word(pos + len) != word(ref_pos + len) {
                break;
            }
            len += 4;
        }
        len
    }

    #[test]
    fn match_extension_matches_word_loop() {
        let mut seed = 0x2545_F491_u32;
        let mut next = move || {
            seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
            seed >> 16
        };
        for _ in 0..3000 {
            let total = 80 + next() as usize % 400;
            let mut input: Vec<u8> = (0..total).map(|_| (next() % 3) as u8).collect();
            // Copy a span forward, then flip one byte, so a match ends at any offset.
            let ref_pos = next() as usize % 20;
            let dist = 1 + next() as usize % 30;
            let pos = ref_pos + dist;
            for at in pos..total {
                input[at] = input[at - dist];
            }
            let flip = pos + next() as usize % (total - pos);
            input[flip] ^= 1;
            let bound = total - super::MIN_INPUT;
            if pos >= bound {
                continue;
            }
            assert_eq!(
                super::extend_match(&input, pos, ref_pos, bound),
                extend_match_words(&input, pos, ref_pos, bound),
                "total {total} pos {pos} ref {ref_pos} flip {flip}"
            );
        }
    }
}