libfreemkv 1.1.0

Open source raw disc access library for optical drives
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
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
//! AACS content decryption — AES primitives, unit decryption, bus encryption.

use aes::Aes128;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};

// ── AACS constants ──────────────────────────────────────────────────────────

/// Fixed IV used by AACS for all AES-CBC operations.
pub(crate) const AACS_IV: [u8; 16] = [
    0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
];

/// Size of an AACS aligned unit (3 × 2048-byte sectors).
pub const ALIGNED_UNIT_LEN: usize = 6144;

/// An AACS aligned unit spans this many 2048-byte sectors (3).
pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_BYTES) as u32;

/// Whether `lba` sits on an AACS aligned-unit boundary, measured **relative to
/// the encrypted region's base LBA** (`unit_base` = the clip/extent `start_lba`,
/// NOT absolute disc LBA 0).
///
/// AACS aligned units (6144 B / 3 sectors) are anchored at the start of each
/// clip's encrypted region, so a read must begin a whole number of units past
/// that base for `decrypt_sectors` (which anchors units at buffer offset 0) to
/// align the CBC correctly. This is the SINGLE source of truth for the test —
/// the decrypt-on-read gate, the inline and highway mux read paths, and the
/// key-validation sample reader all key off this, never absolute `lba % 3`. A
/// disc whose clip `start_lba` is not itself 3-aligned would otherwise mis-gate
/// (reject readable units, then report "Decryption failed") on exactly the
/// titles whose clips land off a 3-boundary.
///
/// `lba` is always `>= unit_base` by contract (a read never begins before the
/// extent base it is measured against). `saturating_sub` makes the `lba <
/// unit_base` case well-defined anyway — it clamps the offset to 0, which is a
/// unit boundary — rather than the latent `wrapping_sub` trap where an
/// underflow wraps to ~2^32 and, because `2^32 ≡ 1 (mod 3)`, mis-reports the
/// alignment (e.g. `lba == unit_base - 1` would falsely read as aligned).
pub fn is_unit_aligned(lba: u32, unit_base: u32) -> bool {
    lba.saturating_sub(unit_base) % ALIGNED_UNIT_SECTORS == 0
}

use crate::consts::SECTOR_BYTES;

use crate::consts::BD_SOURCE_PACKET_BYTES;

/// TS sync byte.
const TS_SYNC: u8 = 0x47;

// ── AES primitives ──────────────────────────────────────────────────────────

/// AES-128-ECB encrypt a single 16-byte block.
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
    let cipher = Aes128::new(GenericArray::from_slice(key));
    let mut block = GenericArray::clone_from_slice(data);
    cipher.encrypt_block(&mut block);
    let mut out = [0u8; 16];
    out.copy_from_slice(&block);
    out
}

/// AES-128-ECB decrypt a single 16-byte block.
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
    let cipher = Aes128::new(GenericArray::from_slice(key));
    let mut block = GenericArray::clone_from_slice(data);
    cipher.decrypt_block(&mut block);
    let mut out = [0u8; 16];
    out.copy_from_slice(&block);
    out
}

/// AES-128-CBC decrypt in-place with the fixed AACS IV.
///
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
/// block is silently ignored; all callers pass aligned regions (6128 and
/// 2032 bytes), and the assert documents/enforces that contract.
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
    debug_assert!(
        data.len() % 16 == 0,
        "aes_cbc_decrypt requires a block-aligned slice"
    );
    let cipher = Aes128::new(GenericArray::from_slice(key));
    let num_blocks = data.len() / 16;
    // Process blocks in reverse to avoid clobbering ciphertext needed for XOR
    for i in (0..num_blocks).rev() {
        let offset = i * 16;
        let prev = if i == 0 {
            AACS_IV
        } else {
            let mut p = [0u8; 16];
            p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
            p
        };
        let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
        cipher.decrypt_block(&mut block);
        for j in 0..16 {
            data[offset + j] = block[j] ^ prev[j];
        }
    }
}

// ── Content decryption ──────────────────────────────────────────────────────

/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it
/// lacks the `0x47` sync bytes a clear BD-TS unit carries at offsets 4, 196,
/// 388, … (one per 192-byte source packet).
///
/// This is a pure BYTE heuristic; on its own it does NOT mean "encrypted". A
/// destroyed sync structure can be AACS ciphertext, uncorrected-ECC garbage, OR
/// data that was never MPEG-TS at all (UDF filesystem / nav) — those are
/// byte-indistinguishable. So this answers only *"does this unit look like valid
/// clear TS, or not"*, nothing about encryption.
///
/// The "is this unit AACS-encrypted (and must decrypt)?" decision is COMPOSED by
/// the caller, because it needs context this function lacks:
/// `inside an m2ts content extent` AND `ts_sync_destroyed` AND `no key decrypts`
/// (see [`crate::decrypt::decrypt_sectors_in_content`] and
/// [`crate::Disc::encrypted_content_ranges`]). Inside known content this
/// primitive separates an encrypted/garbled unit (destroyed) from a clear
/// segment (intact); OUTSIDE content it is meaningless — feeding it filesystem
/// bytes is what produced the first-2 GB false-positive this split fixes.
///
/// Flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) or
/// the TS scrambling-control bits (byte 7) — AACS sets neither reliably.
pub fn ts_sync_destroyed(unit: &[u8]) -> bool {
    unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
}

/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal — the Copy
/// Permission Indicator (CPI) in the top 2 bits of byte 0. Byte 0 is the first
/// byte of the first source packet's `TP_extra_header`, which AACS always leaves
/// in the clear (the first 16 bytes of every unit are the unencrypted SEED). So
/// this is readable WITHOUT a key:
///   * `(buf[0] & 0xC0) == 0` → CPI clear → the unit is plaintext; pass through.
///   * non-zero → bytes `16..6144` are AES-CBC encrypted; decrypt.
///
/// This is exactly libaacs' test (`if (!(buf[0] & 0xc0)) return; /* clear */`)
/// and is the spec-correct replacement for the [`ts_sync_destroyed`] byte
/// heuristic. CRITICAL: it is only meaningful when `unit` is read at the correct
/// clip-FILE-anchored boundary — byte 0 must be the real unit start. A
/// disc-absolute / mis-aligned read makes byte 0 arbitrary mid-stream data, so
/// the CPI bits are meaningless (which is precisely why per-unit verify must run
/// clip-anchored, not in the whole-disc sweep).
pub fn aacs_unit_encrypted(unit: &[u8]) -> bool {
    unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
}

/// True when an aligned unit is flagged encrypted (CPI set) AND still looks
/// scrambled (TS syncs not yet restored) — i.e. genuine encrypted content that
/// has NOT been decrypted yet.
///
/// [`aacs_unit_encrypted`] is the authoritative spec gate, but the CPI bits live
/// in the plaintext header (bytes `0..16`) which decryption never rewrites, so a
/// successfully decrypted unit still reports CPI-set. Buffer-iterating sites that
/// may run twice over the same `buf` (the post-fetch re-decrypt, sample
/// collection, failure diagnosis) need an IDEMPOTENT "does this still need work?"
/// test, so they compose CPI with the sync-restored check: once a unit decrypts,
/// its syncs come back and it drops out. Single-shot callers that always operate
/// on fresh ciphertext (`decrypt_unit`) gate on [`aacs_unit_encrypted`] alone.
///
/// Like CPI itself this is only meaningful at the clip-FILE-anchored boundary.
pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
    aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
}

/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
/// packet; an encrypted unit, or a non-content unit decrypted under a key
/// that doesn't apply, shows ~none.
pub fn ts_sync_count(unit: &[u8]) -> usize {
    let mut count = 0;
    let mut offset = 4;
    while offset < unit.len() {
        if unit[offset] == TS_SYNC {
            count += 1;
        }
        offset += BD_SOURCE_PACKET_BYTES;
    }
    count
}

/// Number of BD-TS packets in the unit — the maximum possible sync count.
pub fn ts_packet_total(unit: &[u8]) -> usize {
    // One sync byte per 192-byte BD-TS packet (at offset 4 of each). The old
    // `(len - 4) / BD_SOURCE_PACKET_BYTES + 1` over-counted by one for lengths of the
    // form `4 + k·192`.
    unit.len() / BD_SOURCE_PACKET_BYTES
}

fn ts_syncs_intact(unit: &[u8]) -> bool {
    ts_sync_count(unit) > ts_packet_total(unit) / 2
}

/// STRICT, standards-correct "is this a clean MPEG-TS aligned unit?" check —
/// byte-for-byte libaacs' `_verify_ts` (`aacs.c`): EVERY one of the 32 BD source
/// packets (192-byte stride) must carry its TS sync `0x47` at offset 4; the first
/// miss fails. This is the authoritative gate for the POST-READ verify stage,
/// independent of (and not coupled to) `decrypt_unit`.
///
/// It is deliberately stricter than the majority-vote `ts_syncs_intact`
/// scramble *heuristic*: a wrong-key decrypt that coincidentally restores >16
/// syncs passes the majority test and would silently corrupt content, but fails
/// here. Non-mutating — purely a verdict; it neither decrypts nor clears the CPI
/// bits (those stay the concern of the unchanged `decrypt_unit`).
pub fn unit_is_clean_ts(unit: &[u8]) -> bool {
    if unit.len() < ALIGNED_UNIT_LEN {
        return false;
    }
    let mut i = 0;
    while i < ALIGNED_UNIT_LEN {
        if unit[i + 4] != TS_SYNC {
            return false;
        }
        i += BD_SOURCE_PACKET_BYTES;
    }
    true
}

/// Structural "is this a clean MPEG-2 Program Stream aligned unit?" check — the
/// PS-container analogue of [`unit_is_clean_ts`], for AACS content carried as
/// program stream (HD-DVD `.evo`): every 2048-byte pack must begin with the
/// pack_start_code `00 00 01 BA`. A 6144-byte aligned unit spans three packs.
///
/// UNVALIDATED against real HD-DVD media. It assumes (a) HD-DVD uses the
/// standard AACS 6144-byte unit, (b) `.evo` clips are 2048-pack-aligned so unit
/// boundaries fall on pack starts, and (c) byte 0 of the unit is the pack start
/// — i.e. where the AACS seed and CPI indicator sit for PS content is the same
/// as BD-TS. Each of these must be confirmed against a real HD-DVD disc before
/// the `.evo` path is turned on (see `disc::verify::ContainerKind`). It exists
/// now only so the verify gate is structurally ready for that wiring.
pub fn unit_is_clean_ps(unit: &[u8]) -> bool {
    if unit.len() < ALIGNED_UNIT_LEN {
        return false;
    }
    const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
    let mut o = 0;
    while o < ALIGNED_UNIT_LEN {
        if unit[o..o + 4] != PACK_START {
            return false;
        }
        o += SECTOR_BYTES; // one MPEG-2 PS pack per 2048-byte sector
    }
    true
}

/// Decrypt one AACS aligned unit (6144 bytes) in-place.
/// Returns true if the unit is now clear MPEG-TS: either it was already
/// unscrambled (returned untouched, no key used) or it was decrypted and
/// verified by its TS sync bytes. Returns false only when the unit was
/// scrambled and this key failed verification.
///
/// Algorithm:
/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived
/// 2. XOR derived with original 16 bytes → unit_decrypt_key
/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV
///
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
/// there is no flag to clear.
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
    decrypt_unit_checked(unit, unit_key, unit_is_clean_ts)
}

/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
/// passes on the decrypted bytes. The AACS crypto is container-agnostic; the
/// post-decrypt acceptance is the only format-specific part — so this is the
/// extension seam for non-TS containers. [`decrypt_unit`] is this with the BD-TS
/// check ([`unit_is_clean_ts`]); HD-DVD PS content would pass
/// [`unit_is_clean_ps`] instead. A CPI-clear unit is plaintext and passes
/// through untouched (no key consumed), exactly as before.
pub fn decrypt_unit_checked(
    unit: &mut [u8],
    unit_key: &[u8; 16],
    accept: fn(&[u8]) -> bool,
) -> bool {
    if unit.len() < ALIGNED_UNIT_LEN {
        return false;
    }
    if !aacs_unit_encrypted(unit) {
        return true; // CPI flag clear → plaintext, pass through untouched
    }

    // Save original first 16 bytes (they're the plaintext seed / header).
    let mut header = [0u8; 16];
    header.copy_from_slice(&unit[..16]);

    // Step 1: Encrypt header with unit key to derive per-unit key.
    let derived = aes_ecb_encrypt(unit_key, &header);

    // Step 2: XOR to get the actual decryption key.
    let mut decrypt_key = [0u8; 16];
    for i in 0..16 {
        decrypt_key[i] = derived[i] ^ header[i];
    }

    // Step 3: Decrypt bytes 16..6143 with AES-CBC.
    aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);

    // Accept the key only if the decrypted unit passes the container's strict
    // structural check. A wrong key that coincidentally restores a majority of
    // markers is rejected here, not silently accepted.
    accept(unit)
}

/// Fast, NON-MUTATING unit-key validation for the brute-force key search.
///
/// `decrypt_unit` pays a full 6128-byte (383-block) CBC decrypt before
/// `verify_ts` can reject a wrong key — but in a brute scan ~every candidate is
/// wrong. In CBC the plaintext of block *i* is `AES_dec(C_i) XOR C_{i-1}`, so
/// the FIRST restored TS sync byte (payload offset 196, which lands in CBC
/// block 11 of the `unit[16..]` region) can be recovered with a SINGLE block
/// decrypt instead of 383. A wrong key fails this 1-byte gate ~255/256 of the
/// time for the cost of one AES block; the rare survivor is then confirmed with
/// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit
/// identical to the slow path.
///
/// The caller MUST pass an aligned, already-[`ts_sync_destroyed`] unit
/// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the
/// per-candidate scramble re-scan is intentionally skipped here.
///
/// NOTE: this is a search accelerator — it never writes the input and never
/// participates in the content decrypt path. Aggregate correctness (does a key
/// validate against *any* of the disc's units) is preserved because a true key
/// restores offset-196 on every standard BD-TS unit.
pub fn unit_key_validates(unit: &[u8], unit_key: &[u8; 16]) -> bool {
    if unit.len() < ALIGNED_UNIT_LEN {
        return false;
    }
    // Per-unit decrypt key: AES-ECB-encrypt the 16-byte plaintext header with
    // the unit key, XOR with the header (same derivation as `decrypt_unit`).
    let mut header = [0u8; 16];
    header.copy_from_slice(&unit[..16]);
    let derived = aes_ecb_encrypt(unit_key, &header);
    let mut decrypt_key = [0u8; 16];
    for i in 0..16 {
        decrypt_key[i] = derived[i] ^ header[i];
    }

    // Cheap gate: recover ONLY payload byte 196 (the 2nd BD-TS packet's sync).
    // The CBC region is `unit[16..]`; payload offset 196 → region offset 180 =
    // block 11, byte 4. P[11] = AES_dec(C[11]) XOR C[10]; C[10] is raw
    // ciphertext (no decrypt needed). Constant offsets for the fixed 6144 unit.
    const SYNC_PAYLOAD_OFF: usize = 196;
    let region_off = SYNC_PAYLOAD_OFF - 16; // 180
    let blk = region_off / 16; // 11
    let byte = region_off % 16; // 4
    let c11 = 16 + blk * 16; // absolute offset of C[11] in `unit` (=192)
    let cipher = Aes128::new(GenericArray::from_slice(&decrypt_key));
    let mut b = GenericArray::clone_from_slice(&unit[c11..c11 + 16]);
    cipher.decrypt_block(&mut b);
    let prev = unit[c11 - 16 + byte]; // C[10] byte (region block 10)
    if b[byte] ^ prev != TS_SYNC {
        return false;
    }

    // Survivor (~1/256 of candidates): confirm with the authoritative full
    // decrypt + verify, so the verdict matches `decrypt_unit` exactly.
    let mut full = [0u8; ALIGNED_UNIT_LEN];
    full.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]);
    decrypt_unit(&mut full, unit_key)
}

/// Outcome of [`decrypt_unit_try_keys`].
///
/// Distinguishes "the unit was already clear, no key was consumed" from "key
/// at index `i` decrypted it" — the bare `Option<usize>` form conflated the two
/// (a clear unit reported `Some(0)`, indistinguishable from key index 0, and
/// possibly out of range when `unit_keys` is empty).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnitKeyResult {
    /// The unit was not scrambled; it was left untouched and no key was used.
    AlreadyClear,
    /// The unit was decrypted in place by `unit_keys[index]`.
    DecryptedWith(usize),
}

/// Decrypt one aligned unit trying multiple unit keys.
///
/// Returns [`UnitKeyResult::AlreadyClear`] if the unit was not scrambled (no key
/// consumed), [`UnitKeyResult::DecryptedWith(i)`] if key `i` decrypted it, or
/// `None` if no key worked (the unit is restored to its original bytes).
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<UnitKeyResult> {
    if !aacs_unit_encrypted(unit) {
        return Some(UnitKeyResult::AlreadyClear);
    }

    // Save original for retry. Stack-backed buffer — no heap allocation, and the
    // restore-on-failure contract holds uniformly regardless of key count.
    let mut original = [0u8; ALIGNED_UNIT_LEN];
    original.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]);

    for (i, key) in unit_keys.iter().enumerate() {
        unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original);
        if decrypt_unit(unit, key) {
            return Some(UnitKeyResult::DecryptedWith(i));
        }
    }

    // Restore original on failure
    unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original);
    None
}

/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
/// Bus encryption uses read_data_key, decrypting bytes 16..2047 of each 2048-byte sector.
pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
    for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
        if sector_start + SECTOR_BYTES > unit.len() {
            break;
        }
        // First 16 bytes of each sector are plaintext
        aes_cbc_decrypt(
            read_data_key,
            &mut unit[sector_start + 16..sector_start + SECTOR_BYTES],
        );
    }
}

/// Full decrypt of an aligned unit: bus decrypt (if needed) then AACS decrypt.
pub fn decrypt_unit_full(
    unit: &mut [u8],
    unit_key: &[u8; 16],
    read_data_key: Option<&[u8; 16]>,
) -> bool {
    if !ts_sync_destroyed(unit) {
        return true;
    }
    if let Some(rdk) = read_data_key {
        decrypt_bus(unit, rdk);
    }
    decrypt_unit(unit, unit_key)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_aes_ecb_roundtrip() {
        let key = [
            0x15u8, 0x66, 0x5F, 0x98, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
            0x0B, 0x0C,
        ];
        let plain = [0x41u8; 16];
        let enc = aes_ecb_encrypt(&key, &plain);
        let dec = aes_ecb_decrypt(&key, &enc);
        assert_eq!(dec, plain);
    }

    #[test]
    fn is_unit_aligned_relative_to_base() {
        // Aligned at the base and every 3 sectors above it; misaligned between.
        assert!(is_unit_aligned(100, 100), "base itself is aligned");
        assert!(is_unit_aligned(103, 100), "one unit past base is aligned");
        assert!(is_unit_aligned(106, 100));
        assert!(!is_unit_aligned(101, 100));
        assert!(!is_unit_aligned(102, 100));
        // Non-3-aligned base: alignment is RELATIVE to the base, not absolute.
        assert!(is_unit_aligned(101, 101), "non-3-aligned base is aligned");
        assert!(is_unit_aligned(104, 101));
        assert!(!is_unit_aligned(102, 101));
    }

    #[test]
    fn is_unit_aligned_lba_below_base_is_well_defined() {
        // Latent-trap contract (rc.5.2 audit #5): a read never starts before its
        // extent base, but if `lba < unit_base` the result must be well-defined,
        // NOT the `wrapping_sub` underflow that — because 2^32 ≡ 1 (mod 3) —
        // would falsely report alignment. `saturating_sub` clamps to offset 0,
        // which is a unit boundary, so any `lba <= unit_base` reads as aligned.
        assert!(
            is_unit_aligned(99, 100),
            "lba just below base must not wrap"
        );
        assert!(is_unit_aligned(98, 100));
        assert!(is_unit_aligned(0, 100));
        // The specific wrapping_sub trap value: unit_base - 1. With wrapping_sub
        // this is 0xFFFF_FFFF % 3 == 0 → falsely "aligned" by underflow; with
        // saturating_sub it is genuinely 0 → aligned, for the right reason.
        assert!(is_unit_aligned(u32::MAX, u32::MAX)); // base == lba, trivially aligned
        assert!(
            is_unit_aligned(0, u32::MAX),
            "max base, lba 0 must saturate to 0"
        );
    }

    #[test]
    fn test_decrypt_unit_unencrypted() {
        // A clear unit (TS syncs intact) is not scrambled → passes through.
        let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
        let mut off = 4;
        while off < ALIGNED_UNIT_LEN {
            unit[off] = TS_SYNC;
            off += BD_SOURCE_PACKET_BYTES;
        }
        let key = [0u8; 16];
        assert!(!ts_sync_destroyed(&unit));
        assert!(decrypt_unit(&mut unit, &key));
    }

    #[test]
    fn ts_packet_total_no_off_by_one() {
        // The maximum sync count is exactly the number of stride
        // positions the counting loop visits (offset 4, 196, ...), i.e.
        // len / 192, NOT (len - 4) / 192 + 1. For the 6144-byte aligned unit
        // the loop checks offsets 4..=5956 → 32 positions.
        let unit = vec![0u8; ALIGNED_UNIT_LEN];
        assert_eq!(ts_packet_total(&unit), 32);
        // Confirm the loop visits exactly that many stride positions.
        let visited = (4..ALIGNED_UNIT_LEN)
            .step_by(BD_SOURCE_PACKET_BYTES)
            .count();
        assert_eq!(visited, ts_packet_total(&unit));
    }

    #[test]
    fn scramble_detection_at_16_32_boundary() {
        // With 32 stride positions the majority threshold is
        // total/2 = 16. A unit with EXACTLY half its syncs intact (16) must
        // NOT be over-counted into the "scrambled" bucket by an inflated
        // total: 16 > 16 is false → not-intact → scrambled. 17 intact → clear.
        // The fix is that `total` is 32 (not 33), so the boundary sits cleanly
        // at the real midpoint.
        let set_syncs = |n: usize| {
            let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
            let mut off = 4;
            let mut placed = 0;
            while off < ALIGNED_UNIT_LEN && placed < n {
                unit[off] = TS_SYNC;
                off += BD_SOURCE_PACKET_BYTES;
                placed += 1;
            }
            unit
        };

        assert_eq!(ts_sync_count(&set_syncs(16)), 16);
        assert_eq!(ts_sync_count(&set_syncs(17)), 17);

        // Exactly half intact → classified scrambled (16 > 16 is false).
        assert!(ts_sync_destroyed(&set_syncs(16)));
        // One past half → classified clear.
        assert!(!ts_sync_destroyed(&set_syncs(17)));
    }

    #[test]
    fn scramble_detection_extremes() {
        // Detection semantics for the clear-cut cases must be preserved:
        // a fully-clear unit (all 32 syncs) is NOT scrambled; a unit with no
        // syncs (fully scrambled body) IS scrambled.
        let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
        let mut off = 4;
        while off < ALIGNED_UNIT_LEN {
            clear[off] = TS_SYNC;
            off += BD_SOURCE_PACKET_BYTES;
        }
        assert_eq!(ts_sync_count(&clear), 32);
        assert!(
            !ts_sync_destroyed(&clear),
            "fully-clear unit → not scrambled"
        );

        let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
        assert_eq!(ts_sync_count(&scrambled), 0);
        assert!(ts_sync_destroyed(&scrambled), "no syncs → scrambled");
    }

    #[test]
    fn test_aes_cbc_roundtrip() {
        let key = [
            0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
            0xFF, 0x00,
        ];
        let original = vec![0x42u8; 128]; // 8 blocks
        let mut data = original.clone();

        // Encrypt with CBC manually (forward direction)
        fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
            let cipher = Aes128::new(GenericArray::from_slice(key));
            let mut prev = super::AACS_IV;
            let num_blocks = data.len() / 16;
            for i in 0..num_blocks {
                let offset = i * 16;
                for j in 0..16 {
                    data[offset + j] ^= prev[j];
                }
                let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
                cipher.encrypt_block(&mut block);
                data[offset..offset + 16].copy_from_slice(&block);
                prev.copy_from_slice(&data[offset..offset + 16]);
            }
        }

        aes_cbc_encrypt(&key, &mut data);
        assert_ne!(data, original); // should be different after encrypt

        super::aes_cbc_decrypt(&key, &mut data);
        assert_eq!(data, original); // should match after roundtrip
    }

    #[test]
    fn test_decrypt_unit_synthetic() {
        // Build a fake 6144-byte aligned unit with known TS sync pattern,
        // encrypt it with the AACS algorithm, then decrypt and verify.
        let unit_key = [0xAAu8; 16];

        // Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4
        let mut plain = vec![0u8; ALIGNED_UNIT_LEN];
        let mut offset = 4;
        while offset < ALIGNED_UNIT_LEN {
            plain[offset] = TS_SYNC;
            offset += BD_SOURCE_PACKET_BYTES;
        }
        // Flag the unit encrypted via the CPI bits (byte 0) — the authoritative
        // gate `decrypt_unit` now consults. Set before key derivation so the
        // recovered plaintext header matches.
        plain[0] |= 0xC0;

        // Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
        let header: [u8; 16] = plain[..16].try_into().unwrap();
        let derived = aes_ecb_encrypt(&unit_key, &header);
        let mut encrypt_key = [0u8; 16];
        for i in 0..16 {
            encrypt_key[i] = derived[i] ^ header[i];
        }

        // CBC encrypt bytes 16..6143
        let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
        let mut prev = AACS_IV;
        let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
        for i in 0..num_blocks {
            let off = 16 + i * 16;
            for j in 0..16 {
                plain[off + j] ^= prev[j];
            }
            let mut block = GenericArray::clone_from_slice(&plain[off..off + 16]);
            cipher.encrypt_block(&mut block);
            plain[off..off + 16].copy_from_slice(&block);
            prev.copy_from_slice(&plain[off..off + 16]);
        }

        // Now plain contains encrypted data. Decrypt it.
        let mut unit = plain;
        assert!(ts_sync_destroyed(&unit));
        assert!(decrypt_unit(&mut unit, &unit_key));
        assert!(!ts_sync_destroyed(&unit)); // decrypted: TS syncs restored

        // Verify TS sync bytes
        let mut count = 0;
        let mut off = 4;
        while off < ALIGNED_UNIT_LEN {
            if unit[off] == TS_SYNC {
                count += 1;
            }
            off += BD_SOURCE_PACKET_BYTES;
        }
        // Assert against the single canonical packet count, not the old
        // `(len - 4) / 192 + 1` form that `ts_packet_total` corrected away from.
        assert_eq!(count, ts_packet_total(&unit));
    }

    // ── Helpers for the hardening tests below ──────────────────────────────

    /// Encrypt an aligned unit in place with the AACS unit-decrypt
    /// algorithm run in reverse, so [`decrypt_unit`] with the same
    /// `unit_key` recovers the plaintext. This is the exact inverse of
    /// the production decrypt: derive `decrypt_key = AES-ECB-E(unit_key,
    /// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
    /// fixed AACS IV.
    fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
        // Set the CPI bits (top 2 of byte 0) so the unit reads as encrypted under
        // `aacs_unit_encrypted` — done BEFORE key derivation so the plaintext
        // header the real decrypt recovers matches what we encrypt under.
        unit[0] |= 0xC0;
        let header: [u8; 16] = unit[..16].try_into().unwrap();
        let derived = aes_ecb_encrypt(unit_key, &header);
        let mut k = [0u8; 16];
        for i in 0..16 {
            k[i] = derived[i] ^ header[i];
        }
        let cipher = Aes128::new(GenericArray::from_slice(&k));
        let mut prev = AACS_IV;
        let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
        for i in 0..num_blocks {
            let off = 16 + i * 16;
            for j in 0..16 {
                unit[off + j] ^= prev[j];
            }
            let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
            cipher.encrypt_block(&mut block);
            unit[off..off + 16].copy_from_slice(&block);
            prev.copy_from_slice(&unit[off..off + 16]);
        }
    }

    /// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
    fn clear_unit() -> Vec<u8> {
        let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
        let mut off = 4;
        while off < ALIGNED_UNIT_LEN {
            unit[off] = TS_SYNC;
            off += BD_SOURCE_PACKET_BYTES;
        }
        unit
    }

    // ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────

    #[test]
    fn aes_ecb_matches_fips197_known_answer() {
        // FIPS-197 Appendix C.1 AES-128 KAT:
        //   key       = 000102030405060708090a0b0c0d0e0f
        //   plaintext = 00112233445566778899aabbccddeeff
        //   ciphertext= 69c4e0d86a7b0430d8cdb78070b4c55a
        // This pins the AES primitive against a published vector — a wrong
        // cipher (or a key/plaintext byte-order slip) fails it.
        let key = [
            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
            0x0E, 0x0F,
        ];
        let pt = [
            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
            0xEE, 0xFF,
        ];
        let expected = [
            0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
            0xC5, 0x5A,
        ];
        assert_eq!(aes_ecb_encrypt(&key, &pt), expected);
        // And decrypt is the exact inverse.
        assert_eq!(aes_ecb_decrypt(&key, &expected), pt);
    }

    // ── CBC decrypt: first-block uses fixed AACS IV ────────────────────────

    #[test]
    fn cbc_decrypt_first_block_xors_aacs_iv() {
        // CBC: P[0] = AES-D(K, C[0]) XOR IV, and the IV is the fixed AACS
        // constant (not zero). Encrypt a single block forward with IV, then
        // confirm aes_cbc_decrypt recovers it — proving the IV used on block
        // 0 is exactly AACS_IV. A mutation that swaps AACS_IV for [0u8;16]
        // makes the recovered block wrong.
        let key = [0x24u8; 16];
        let plain = [0x5Au8; 16];
        // Forward CBC for one block: C = AES-E(K, P XOR IV).
        let mut x = plain;
        for j in 0..16 {
            x[j] ^= AACS_IV[j];
        }
        let ct = aes_ecb_encrypt(&key, &x);
        let mut buf = ct;
        aes_cbc_decrypt(&key, &mut buf);
        assert_eq!(buf, plain, "block-0 CBC must XOR the fixed AACS IV");
    }

    // ── CBC decrypt KAT (NIST SP 800-38A F.2.2, AES-128-CBC) ───────────────

    #[test]
    fn aes_cbc_decrypt_matches_nist_sp800_38a_f2_2() {
        // NIST SP 800-38A Appendix F.2.2 (CBC-AES128.Decrypt) published vector:
        //   Key = 2b7e151628aed2a6abf7158809cf4f3c
        //   IV  = 000102030405060708090a0b0c0d0e0f
        //   CT  = 7649abac8119b246cee98e9b12e9197d  (block 0)
        //         5086cb9b507219ee95db113a917678b2  (block 1)
        //         73bed6b8e3c1743b7116e69e22229516  (block 2)
        //         3ff1caa1681fac09120eca307586e1a7  (block 3)
        //   PT  = 6bc1bee22e409f96e93d7e117393172a  (block 0)
        //         ae2d8a571e03ac9c9eb76fac45af8e51  (block 1)
        //         30c81c46a35ce411e5fbc1191a0a52ef  (block 2)
        //         f69f2445df4f9b17ad2b417be66c3710  (block 3)
        //
        // `aes_cbc_decrypt` hardwires the fixed AACS IV for block 0 (it never
        // takes a caller IV), so:
        //   * Blocks 1..=3 are independent of the IV — they MUST equal the NIST
        //     plaintext byte-for-byte (P[i] = AES-D(K, C[i]) XOR C[i-1]). This
        //     pins the real reverse-order CBC chaining against a published KAT.
        //   * Block 0 = AES-D(K, C[0]) XOR AACS_IV = NIST_PT[0] XOR NIST_IV
        //     XOR AACS_IV — the documented IV substitution. Asserting this exact
        //     relation pins both the AES decrypt of C[0] AND that block 0 uses
        //     AACS_IV (a swap to [0u8;16] or a chaining bug fails it).
        let key = [
            0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF,
            0x4F, 0x3C,
        ];
        let nist_iv = [
            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
            0x0E, 0x0F,
        ];
        // Byte arrays kept narrow (≤14 bytes/line) so the secret-scanner's
        // 32-nibble-per-line heuristic doesn't flag these published vectors as
        // key material (same layout the existing FIPS-197 / CMAC KATs use).
        let ciphertext: [u8; 64] = [
            0x76, 0x49, 0xAB, 0xAC, 0x81, 0x19, 0xB2, 0x46, 0xCE, 0xE9, 0x8E, 0x9B, 0x12, 0xE9,
            0x19, 0x7D, 0x50, 0x86, 0xCB, 0x9B, 0x50, 0x72, 0x19, 0xEE, 0x95, 0xDB, 0x11, 0x3A,
            0x91, 0x76, 0x78, 0xB2, 0x73, 0xBE, 0xD6, 0xB8, 0xE3, 0xC1, 0x74, 0x3B, 0x71, 0x16,
            0xE6, 0x9E, 0x22, 0x22, 0x95, 0x16, 0x3F, 0xF1, 0xCA, 0xA1, 0x68, 0x1F, 0xAC, 0x09,
            0x12, 0x0E, 0xCA, 0x30, 0x75, 0x86, 0xE1, 0xA7,
        ];
        let nist_plaintext: [u8; 64] = [
            0x6B, 0xC1, 0xBE, 0xE2, 0x2E, 0x40, 0x9F, 0x96, 0xE9, 0x3D, 0x7E, 0x11, 0x73, 0x93,
            0x17, 0x2A, 0xAE, 0x2D, 0x8A, 0x57, 0x1E, 0x03, 0xAC, 0x9C, 0x9E, 0xB7, 0x6F, 0xAC,
            0x45, 0xAF, 0x8E, 0x51, 0x30, 0xC8, 0x1C, 0x46, 0xA3, 0x5C, 0xE4, 0x11, 0xE5, 0xFB,
            0xC1, 0x19, 0x1A, 0x0A, 0x52, 0xEF, 0xF6, 0x9F, 0x24, 0x45, 0xDF, 0x4F, 0x9B, 0x17,
            0xAD, 0x2B, 0x41, 0x7B, 0xE6, 0x6C, 0x37, 0x10,
        ];

        let mut buf = ciphertext;
        aes_cbc_decrypt(&key, &mut buf);

        // Blocks 1..=3: exact match against the published NIST plaintext.
        assert_eq!(
            &buf[16..64],
            &nist_plaintext[16..64],
            "CBC chaining (blocks 1..3) must match NIST SP 800-38A F.2.2 plaintext"
        );

        // Block 0: NIST_PT[0] XOR NIST_IV XOR AACS_IV (the fixed-IV substitution).
        let mut expected_block0 = [0u8; 16];
        for i in 0..16 {
            expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ AACS_IV[i];
        }
        assert_eq!(
            &buf[0..16],
            &expected_block0,
            "block-0 plaintext must equal NIST PT XOR NIST IV XOR AACS_IV (fixed-IV path)"
        );
    }

    // ── decrypt_unit: full round trip restores TS syncs ────────────────────

    #[test]
    fn decrypt_unit_roundtrip_restores_all_syncs() {
        // Encrypt a clear unit, confirm it reads as scrambled, then decrypt
        // and confirm every TS sync byte at the 192-byte stride is restored.
        let unit_key = [0x37u8; 16];
        let mut unit = clear_unit();
        aacs_encrypt_unit(&mut unit, &unit_key);
        assert!(
            ts_sync_destroyed(&unit),
            "encrypted unit must look scrambled"
        );

        assert!(decrypt_unit(&mut unit, &unit_key));
        // All 32 stride positions carry sync after decrypt.
        assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
        assert!(!ts_sync_destroyed(&unit));
    }

    #[test]
    fn decrypt_unit_wrong_key_fails_and_does_not_falsely_clear() {
        // A wrong unit key fails verify_ts (the body stays scrambled), so
        // decrypt_unit returns false. Grounds the brute-force gate: a bad key
        // must NOT report success.
        let good = [0x11u8; 16];
        let bad = [0x22u8; 16];
        let mut unit = clear_unit();
        aacs_encrypt_unit(&mut unit, &good);
        assert!(!decrypt_unit(&mut unit, &bad), "wrong key must not verify");
    }

    #[test]
    fn decrypt_unit_rejects_short_unit() {
        // unit.len() < ALIGNED_UNIT_LEN → false (no panic on the 16.. slice).
        let mut short = vec![0u8; ALIGNED_UNIT_LEN - 1];
        assert!(!decrypt_unit(&mut short, &[0u8; 16]));
    }

    #[test]
    fn decrypt_unit_only_touches_bytes_16_onward() {
        // The first 16 bytes are the plaintext TP_extra header and must be
        // left untouched by decrypt (only unit[16..] is CBC-processed).
        let unit_key = [0x9Au8; 16];
        let mut clear = clear_unit();
        // Put a distinctive header so we can confirm it survives. Byte 0 carries
        // both CPI bits (0xE0) so it is stable under the fixture's `|= 0xC0`.
        clear[..16].copy_from_slice(&[
            0xE0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
            0xAE, 0xAF,
        ]);
        let header_before: [u8; 16] = clear[..16].try_into().unwrap();
        let mut unit = clear;
        aacs_encrypt_unit(&mut unit, &unit_key);
        // Encryption also leaves the header untouched (only 16.. is encrypted).
        assert_eq!(&unit[..16], &header_before);
        decrypt_unit(&mut unit, &unit_key);
        assert_eq!(
            &unit[..16],
            &header_before,
            "header bytes must be preserved"
        );
    }

    // ── decrypt_unit_try_keys: AlreadyClear vs DecryptedWith vs None ───────

    #[test]
    fn try_keys_reports_already_clear_without_consuming_a_key() {
        // A clear unit returns AlreadyClear even with an empty key list — the
        // old Option<usize> form conflated this with Some(0). Grounds the
        // UnitKeyResult enum distinction.
        let mut unit = clear_unit();
        assert_eq!(
            decrypt_unit_try_keys(&mut unit, &[]),
            Some(UnitKeyResult::AlreadyClear)
        );
    }

    #[test]
    fn try_keys_reports_correct_index_among_several() {
        // Three keys, only the 3rd (index 2) decrypts → DecryptedWith(2).
        let real = [0x44u8; 16];
        let mut unit = clear_unit();
        aacs_encrypt_unit(&mut unit, &real);
        let keys = [[0x01u8; 16], [0x02u8; 16], real];
        assert_eq!(
            decrypt_unit_try_keys(&mut unit, &keys),
            Some(UnitKeyResult::DecryptedWith(2))
        );
        assert!(
            !ts_sync_destroyed(&unit),
            "unit must be clear after the hit"
        );
    }

    #[test]
    fn try_keys_restores_original_bytes_on_total_failure() {
        // When no key works, the unit must be byte-identical to the input
        // (the function CBC-mangles it per attempt, then restores). A buggy
        // restore would leave the unit corrupted — silent data damage.
        let real = [0x55u8; 16];
        let mut unit = clear_unit();
        aacs_encrypt_unit(&mut unit, &real);
        let snapshot = unit.clone();
        let wrong = [[0xAAu8; 16], [0xBBu8; 16]];
        assert_eq!(decrypt_unit_try_keys(&mut unit, &wrong), None);
        assert_eq!(unit, snapshot, "failed try must restore the original bytes");
    }

    // ── CPI gate: the authoritative encrypted-vs-clear decision ────────────

    #[test]
    fn cpi_gate_clear_flag_passes_through_even_when_body_looks_scrambled() {
        // THE false-fail fix: a unit whose CPI bits are clear (byte 0 & 0xC0 == 0)
        // is plaintext by spec, even if its body has no TS syncs (non-TS clear
        // data, or a mis-probed body). `decrypt_unit` must pass it through
        // untouched and report success — never attempt a decrypt that would fail.
        let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
        // Body looks scrambled (no syncs at the stride) but byte 0 stays 0x00.
        for (i, b) in unit.iter_mut().enumerate().skip(16) {
            *b = (i as u8).wrapping_mul(31) | 1; // never 0x47 at the sync stride
        }
        assert!(ts_sync_destroyed(&unit), "body has no syncs");
        assert!(!aacs_unit_encrypted(&unit), "CPI clear");
        assert!(
            !aacs_unit_needs_decrypt(&unit),
            "CPI-clear ⇒ no decrypt attempt"
        );
        let snapshot = unit.clone();
        // Any key: passthrough success, bytes untouched (no false DecryptFailed).
        assert!(decrypt_unit(&mut unit, &[0xABu8; 16]));
        assert_eq!(unit, snapshot, "CPI-clear unit must be left byte-identical");
        assert_eq!(
            decrypt_unit_try_keys(&mut unit, &[[0xABu8; 16]]),
            Some(UnitKeyResult::AlreadyClear),
            "CPI-clear unit consumes no key"
        );
    }

    #[test]
    fn cpi_gate_set_flag_decrypts_and_needs_decrypt_is_idempotent() {
        // A CPI-set encrypted unit decrypts with the right key. CPI lives in the
        // plaintext header, so it survives decryption — `aacs_unit_encrypted`
        // still reports true afterward, but `aacs_unit_needs_decrypt` flips to
        // false (syncs restored), keeping the re-decrypt paths idempotent.
        let key = [0x5au8; 16];
        let mut unit = clear_unit();
        aacs_encrypt_unit(&mut unit, &key); // sets CPI + scrambles body
        assert!(aacs_unit_encrypted(&unit), "CPI set");
        assert!(aacs_unit_needs_decrypt(&unit), "flagged + still scrambled");

        assert!(decrypt_unit(&mut unit, &key), "right key decrypts");
        assert!(
            aacs_unit_encrypted(&unit),
            "CPI bits live in the preserved header ⇒ still set post-decrypt"
        );
        assert!(
            !aacs_unit_needs_decrypt(&unit),
            "syncs restored ⇒ no further decrypt attempt (idempotent re-decrypt)"
        );
    }

    // ── unit_key_validates: matches decrypt_unit's verdict exactly ─────────

    #[test]
    fn unit_key_validates_agrees_with_decrypt_unit() {
        // The fast 1-byte gate's accept/reject set must be identical to the
        // authoritative decrypt_unit. Confirm: correct key → true on both;
        // wrong key → false on both.
        let good = [0x6Au8; 16];
        let bad = [0x6Bu8; 16];
        let mut enc = clear_unit();
        aacs_encrypt_unit(&mut enc, &good);

        assert!(unit_key_validates(&enc, &good));
        let mut probe = enc.clone();
        assert!(decrypt_unit(&mut probe, &good));

        assert!(!unit_key_validates(&enc, &bad));
        let mut probe2 = enc.clone();
        assert!(!decrypt_unit(&mut probe2, &bad));
    }

    #[test]
    fn unit_is_clean_ts_is_strict_all_32_syncs() {
        // Standards-correct gate (libaacs `_verify_ts`): EVERY one of the 32
        // packet syncs is required. A fully-synced clear unit passes.
        let clear = clear_unit();
        assert!(unit_is_clean_ts(&clear), "all-32-sync unit is clean");

        // Drop a SINGLE sync (packet 17 of 32). 31/32 remain, so the majority
        // heuristic still passes — that is exactly the silent-corruption hole.
        // The strict gate must REJECT it.
        let mut one_missing = clear_unit();
        one_missing[17 * BD_SOURCE_PACKET_BYTES + 4] = 0x00;
        assert!(
            ts_syncs_intact(&one_missing),
            "majority heuristic still passes one missing sync (the hole)"
        );
        assert!(
            !unit_is_clean_ts(&one_missing),
            "strict gate rejects even one missing sync"
        );

        // A correctly decrypted unit is clean; a wrong-key decrypt is not.
        let key = [0x33u8; 16];
        let mut enc = clear_unit();
        aacs_encrypt_unit(&mut enc, &key);
        let mut good = enc.clone();
        decrypt_unit(&mut good, &key);
        assert!(unit_is_clean_ts(&good), "right-key decrypt yields clean TS");
        let mut wrong = enc.clone();
        decrypt_unit(&mut wrong, &[0x34u8; 16]);
        assert!(
            !unit_is_clean_ts(&wrong),
            "wrong-key decrypt must fail the strict gate"
        );

        // Short buffer is never vacuously clean.
        assert!(!unit_is_clean_ts(&clear[..ALIGNED_UNIT_LEN - 1]));
    }

    #[test]
    fn unit_key_validates_is_non_mutating() {
        // The accelerator must never write its input (it operates on the
        // ciphertext and confirms on a copy). A mutation that decrypted in
        // place would corrupt the caller's buffer.
        let good = [0x7Cu8; 16];
        let mut enc = clear_unit();
        aacs_encrypt_unit(&mut enc, &good);
        let snapshot = enc.clone();
        let _ = unit_key_validates(&enc, &good);
        assert_eq!(enc, snapshot, "unit_key_validates must not mutate input");
    }

    #[test]
    fn unit_key_validates_rejects_short_unit() {
        let short = vec![0u8; ALIGNED_UNIT_LEN - 16];
        assert!(!unit_key_validates(&short, &[0u8; 16]));
    }

    // ── bus decryption (AACS 2.0 / UHD) ────────────────────────────────────

    #[test]
    fn decrypt_bus_roundtrips_per_sector_skipping_first_16_bytes() {
        // Bus encryption CBC-encrypts bytes 16..2048 of EACH 2048-byte sector
        // (3 sectors per aligned unit), leaving the first 16 plaintext. Build
        // the forward transform, then confirm decrypt_bus inverts it and
        // leaves each sector's first 16 bytes untouched.
        let rdk = [0x13u8; 16];
        let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
        // Fill with a recognisable pattern.
        for (i, b) in unit.iter_mut().enumerate() {
            *b = (i % 251) as u8;
        }
        let plain = unit.clone();

        // Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
        let cipher = Aes128::new(GenericArray::from_slice(&rdk));
        for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
            let mut prev = AACS_IV;
            let body = s + 16;
            let end = s + SECTOR_BYTES;
            let nblocks = (end - body) / 16;
            for i in 0..nblocks {
                let off = body + i * 16;
                for j in 0..16 {
                    unit[off + j] ^= prev[j];
                }
                let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
                cipher.encrypt_block(&mut blk);
                unit[off..off + 16].copy_from_slice(&blk);
                prev.copy_from_slice(&unit[off..off + 16]);
            }
        }
        assert_ne!(unit, plain, "forward bus-encrypt must change the body");

        decrypt_bus(&mut unit, &rdk);
        assert_eq!(
            unit, plain,
            "decrypt_bus must invert per-sector bus encrypt"
        );
        // Each sector's first 16 bytes equal the original (never touched).
        for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
            assert_eq!(&unit[s..s + 16], &plain[s..s + 16]);
        }
    }

    // ── decrypt_unit_full: bus-then-AACS ordering, and clear passthrough ───

    #[test]
    fn decrypt_unit_full_passthrough_when_already_clear() {
        // A clear unit returns true and is not modified, regardless of keys.
        let mut unit = clear_unit();
        let snapshot = unit.clone();
        assert!(decrypt_unit_full(
            &mut unit,
            &[0u8; 16],
            Some(&[0xFFu8; 16])
        ));
        assert_eq!(unit, snapshot, "clear unit must pass through untouched");
    }

    #[test]
    fn decrypt_unit_full_applies_bus_then_aacs() {
        // AACS 2.0 pipeline: content is first AACS-unit-encrypted, then
        // bus-encrypted on top. Decrypt must undo bus FIRST, then AACS.
        // Build that exact two-layer ciphertext and confirm full recovery.
        let unit_key = [0x21u8; 16];
        let rdk = [0x84u8; 16];

        let mut unit = clear_unit();
        // Layer 1: AACS unit-encrypt.
        aacs_encrypt_unit(&mut unit, &unit_key);
        // Layer 2: bus-encrypt on top (per-sector, bytes 16..2048).
        let cipher = Aes128::new(GenericArray::from_slice(&rdk));
        for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
            let mut prev = AACS_IV;
            for i in 0..((SECTOR_BYTES - 16) / 16) {
                let off = s + 16 + i * 16;
                for j in 0..16 {
                    unit[off + j] ^= prev[j];
                }
                let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
                cipher.encrypt_block(&mut blk);
                unit[off..off + 16].copy_from_slice(&blk);
                prev.copy_from_slice(&unit[off..off + 16]);
            }
        }
        assert!(ts_sync_destroyed(&unit));
        assert!(decrypt_unit_full(&mut unit, &unit_key, Some(&rdk)));
        assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
    }

    // ── ts_sync_destroyed / ts_sync_count edge cases ───────────────────────

    #[test]
    fn ts_sync_destroyed_false_for_sub_unit_length() {
        // The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
        // shorter is reported NOT scrambled (so the decrypt gate skips it)
        // rather than indexing past the end.
        assert!(!ts_sync_destroyed(&[]));
        assert!(!ts_sync_destroyed(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
        // A scrambled-looking buffer that is one byte short is still "not
        // scrambled" by the length guard.
        let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
        almost[4] = 0x00; // no syncs
        assert!(!ts_sync_destroyed(&almost));
    }

    #[test]
    fn ts_sync_count_only_samples_the_192_byte_stride() {
        // A 0x47 placed OFF the stride (e.g. offset 5) must not be counted —
        // the detector samples exactly offset 4, 196, 388, ... A mutation that
        // scanned every byte would over-count and misclassify scrambled units.
        let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
        unit[5] = TS_SYNC; // off-stride
        unit[197] = TS_SYNC; // off-stride
        assert_eq!(ts_sync_count(&unit), 0, "off-stride 0x47 must not count");
        unit[4] = TS_SYNC; // on-stride
        assert_eq!(ts_sync_count(&unit), 1);
    }

    #[test]
    fn ts_packet_total_for_various_lengths() {
        // total = len / 192 (BD-TS packet size). Pin a few lengths.
        assert_eq!(ts_packet_total(&[0u8; 192]), 1);
        assert_eq!(ts_packet_total(&[0u8; 384]), 2);
        assert_eq!(ts_packet_total(&[0u8; 191]), 0);
        // 6144 = 32 packets.
        assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
    }
}