diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index 78cc63e..60ac2a5 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -787,3 +787,260 @@ mod resolve_candidate_tests { ); } } + +/// Device-key POSITION recovery and the MKB probe accessors. +/// +/// This file holds the whole subset-difference walk and had five tests for it. +/// There are no published AACS test vectors, but none are needed: the AACS +/// relations ([C] §3.2.3–§3.2.5) are invertible, so a valid MKB for a CHOSEN +/// key can be constructed with `aes_ecb_encrypt` and the same `aesg3` the walk +/// uses as its node function. That is what `plant_mkb` below does — no real +/// key material, and the assertions check the DERIVED Media Key, not any +/// intermediate the code under test also produces. +#[cfg(test)] +mod position_recovery_tests { + use super::*; + use crate::aacs::crypto::aes_ecb_encrypt; + + /// [C] §3.2.5.1.4 Verify-Media-Key plaintext prefix. + const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; + + /// An MKB record: 1-byte type + BE24 total length (header included) + body. + fn rec(t: u8, body: &[u8]) -> Vec { + let total = 4 + body.len(); + let mut r = vec![ + t, + ((total >> 16) & 0xFF) as u8, + ((total >> 8) & 0xFF) as u8, + (total & 0xFF) as u8, + ]; + r.extend_from_slice(body); + r + } + + /// The planted fixture: an MKB whose single subset-difference slot is opened + /// by `dkey` sitting EXACTLY at that slot (zero descent), yielding `mk`. + struct Planted { + mkb: Vec, + dkey: [u8; 16], + mk: [u8; 16], + mk_dv: [u8; 16], + cv: [u8; 16], + uv: u32, + u_mask_shift: u8, + } + + /// Build the fixture by inverting the AACS relations. + /// + /// `uv = 0x0400` (lowest set bit 10) with `u_mask_shift = 12` is chosen so a + /// gating device node exists: `resolve_dk_node` flips one bit `b < 12`, and + /// the walk's gate ([C] §3.2.4) needs that bit inside `v_mask` + /// (`0xFFFF_FFFF << 11`) and outside `u_mask` (`0xFFFF_FFFF << 12`) — i.e. + /// `b == 11`. `uv` is kept under 0x10000 because `DeviceKey::node` is a u16. + fn plant_mkb() -> Planted { + let dkey: [u8; 16] = [ + 0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, + 0xE1, 0xF0, + ]; + let mk: [u8; 16] = [ + 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, + 0xAE, 0xAF, + ]; + let uv: u32 = 0x0000_0400; + let u_mask_shift: u8 = 12; + + // The Processing Key a device sitting AT the slot produces: [C] §3.2.4 + // makes it the AES-G3(.,1) of its own node, with no descent. + let pk = aesg3(&dkey, 1); + + // Invert [C] §3.2.4: mk = AES-D(pk, cvalue) then XOR uv into mk[12..16]. + let mut mk_raw = mk; + for (a, b) in mk_raw[12..16].iter_mut().zip(uv.to_be_bytes()) { + *a ^= b; + } + let cv = aes_ecb_encrypt(&pk, &mk_raw); + + // Invert [C] §3.2.5.1.4: AES-D(mk, mk_dv) must start with the magic. + let mut vd = [0x5Au8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mk_dv = aes_ecb_encrypt(&mk, &vd); + + let mut subdiff = vec![u_mask_shift]; + subdiff.extend_from_slice(&uv.to_be_bytes()); + + let mut mkb = Vec::new(); + mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + mkb.extend_from_slice(&rec(0x86, &mk_dv)); + mkb.extend_from_slice(&rec(0x04, &subdiff)); + mkb.extend_from_slice(&rec(0x05, &cv)); + + Planted { + mkb, + dkey, + mk, + mk_dv, + cv, + uv, + u_mask_shift, + } + } + + /// Sanity-check the fixture itself before anything is asserted about the + /// functions under test: an MKB the parser cannot read would make every + /// "returns None" body look correct. + #[test] + fn the_planted_mkb_is_a_parseable_mkb() { + let p = plant_mkb(); + assert_eq!(mkb_find_mk_dv(&p.mkb), Some(p.mk_dv), "verify record"); + assert_eq!( + mkb_find_cvalues(&p.mkb).as_deref(), + Some(&p.cv[..]), + "cvalue record" + ); + assert_eq!( + mkb_find_subdiff_records(&p.mkb).map(|v| v.len()), + Some(5), + "one 5-byte subset-difference slot" + ); + } + + /// `recover_dk_position` turns an UNPOSITIONED 16-byte device key into a + /// bankable `DeviceKey`. Returning `None` means "this key does not apply to + /// this disc" — indistinguishable, to every caller, from a key that does + /// apply but whose position was never found. The whole feature silently + /// stops working: the key is discarded, the disc reports no usable key, and + /// the operator is pointed at their keydb. + /// + /// The load-bearing assertion is the last one: the recovered position must + /// actually drive `derive_media_key_from_dk` to the planted Media Key. That + /// is the property the caller depends on, and it cannot be satisfied by a + /// position that merely looks plausible. + #[test] + fn recover_dk_position_finds_a_position_that_derives_the_planted_media_key() { + let p = plant_mkb(); + + let recovered = + recover_dk_position(&p.mkb, &p.dkey).expect("the planted key applies to this MKB"); + + assert_eq!( + recovered.uv, p.uv, + "uv is invariant for the key across discs and must be the slot's" + ); + assert_eq!( + recovered.u_mask_shift, p.u_mask_shift, + "u_mask_shift must be the slot's" + ); + assert_eq!(recovered.key, p.dkey, "the key bytes are carried through"); + + assert_eq!( + derive_media_key_from_dk(&p.mkb, std::slice::from_ref(&recovered)), + Some(p.mk), + "the recovered position must walk the MKB to the planted Media Key \ + — a position that does not is no better than None" + ); + } + + /// The other direction: a key the MKB does NOT open must not be given a + /// position. A device key wrongly declared as applying would be banked and + /// reused on every future disc, and each of those discs would derive a wrong + /// Media Key. + #[test] + fn recover_dk_position_rejects_a_key_the_mkb_does_not_open() { + let p = plant_mkb(); + let mut stranger = p.dkey; + stranger[0] ^= 0x01; // one bit off — the strongest form of wrong key + assert!( + recover_dk_position(&p.mkb, &stranger).is_none(), + "a key differing by one bit must not be handed a position" + ); + } + + /// `resolve_dk_node` picks the `device_number` that passes the walk's + /// subset-difference gate ([C] §3.2.4). `None` here strands a key whose + /// position was already successfully recovered, so it is the last step of + /// position recovery and fails the same way: usable key, discarded. + /// + /// Asserted through the Media Key the chosen node derives, not through the + /// node value itself — the doc comment's own claim is that any gating node + /// works, so pinning a specific number would test the wrong thing. + #[test] + fn resolve_dk_node_returns_a_node_that_passes_the_walk_gate() { + let p = plant_mkb(); + + let dk = resolve_dk_node(&p.mkb, &p.dkey, p.uv, p.u_mask_shift) + .expect("a gating node exists for the planted slot"); + + assert_eq!(dk.uv, p.uv); + assert_eq!(dk.u_mask_shift, p.u_mask_shift); + assert_eq!( + derive_media_key_from_dk(&p.mkb, std::slice::from_ref(&dk)), + Some(p.mk), + "the resolved node must actually pass the gate and derive the \ + planted Media Key" + ); + + // The gate is the point: the node must differ from uv inside v_mask. + // (v_mask for uv=0x400 is 0xFFFF_F800.) + let v_mask = calc_v_mask(p.uv); + assert_ne!( + (dk.node as u32) & v_mask, + p.uv & v_mask, + "a node equal to uv under v_mask does not gate — the walk would \ + skip the slot entirely" + ); + } + + /// `probe::mkb_mk_dv` is the reproduction harnesses' view of the MKB's + /// Verify-Media-Key record. It feeds `km_verifies`, so a body returning a + /// fixed block would make an independent harness "verify" Media Keys + /// against a record no disc ever carried, and a body returning `None` would + /// make every verification report "unverifiable". + #[test] + fn probe_mkb_mk_dv_returns_the_records_actual_bytes() { + let p = plant_mkb(); + assert_eq!( + probe::mkb_mk_dv(&p.mkb), + Some(p.mk_dv), + "mk_dv must be the bytes the 0x86 record carries" + ); + assert_ne!( + probe::mkb_mk_dv(&p.mkb), + Some([0u8; 16]), + "and not a constant block" + ); + assert_eq!( + probe::mkb_mk_dv(&[0x10, 0x00, 0x00, 0x04]), + None, + "an MKB with no verify record has no mk_dv" + ); + } + + /// `probe::mkb_cvalues` is the Media-Key-Data table the whole PK×cvalue + /// scan iterates. An empty or one-byte table makes every scan find nothing, + /// so a harness would report a good key as non-working. + #[test] + fn probe_mkb_cvalues_returns_the_records_actual_bytes() { + let p = plant_mkb(); + let cvalues = probe::mkb_cvalues(&p.mkb).expect("the 0x05 record is present"); + assert_eq!( + cvalues.len(), + 16, + "one 16-byte cvalue was planted; the table must be that long" + ); + assert_eq!( + &cvalues[..], + &p.cv[..], + "cvalue bytes must be the planted ones" + ); + + // The table is what the terminal-PK scan consumes; prove it drives the + // real scan to the planted Media Key. + let uvs = probe::mkb_subdiff(&p.mkb).expect("subdiff record present"); + let pk = aesg3(&p.dkey, 1); + assert_eq!( + try_pk_against_tables(&[pk], &uvs, &cvalues, &p.mk_dv), + Some(p.mk), + "the probe's cvalue table must be the one the PK scan can use" + ); + } +} diff --git a/src/aacs/inf.rs b/src/aacs/inf.rs index df5e91c..c069d0f 100644 --- a/src/aacs/inf.rs +++ b/src/aacs/inf.rs @@ -549,3 +549,163 @@ mod vtkf_tests { assert!(dbg.contains("encrypted_keys_len: 2"), "{dbg}"); } } + +#[cfg(test)] +mod read_mkb_tests { + use super::*; + use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE, ScsiResult, ScsiTransport}; + + /// A drive that answers READ DISC STRUCTURE format 0x83 from a scripted set + /// of packs and records every CDB it was handed. + struct MkbDrive { + /// One entry per pack: the pack's MKB payload bytes. + packs: Vec>, + cdbs: Vec>, + } + + impl ScsiTransport for MkbDrive { + fn execute( + &mut self, + cdb: &[u8], + _direction: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> crate::error::Result { + self.cdbs.push(cdb.to_vec()); + // Pack number is carried in the CDB address field (bytes 2..6), + // MMC-6 READ DISC STRUCTURE. + let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]) as usize; + let body = self.packs.get(pack).cloned().unwrap_or_default(); + // Header: BE16 data length (counts the 2 header bytes that follow + // it plus the payload), reserved byte, pack count, then payload. + let data_len = body.len() + 2; + data[0..2].copy_from_slice(&(data_len as u16).to_be_bytes()); + data[2] = 0x00; + data[3] = self.packs.len() as u8; + data[4..4 + body.len()].copy_from_slice(&body); + Ok(ScsiResult { + status: 0, + bytes_transferred: 4 + body.len(), + sense: [0u8; 32], + }) + } + } + + /// `read_mkb_from_drive` is the in-drive MKB source: every AACS derivation + /// downstream (`mkb_find_mk_dv`, the subset-difference walk, the whole + /// Media Key ladder) consumes exactly what it returns. An empty return is + /// not a benign "no MKB" — it is a total read failure reported as success, + /// and every derivation then fails with a key-not-found code that points + /// the operator at their keydb rather than at the drive. + /// + /// This pins the CONTENT: the concatenated payload of all packs, in pack + /// order, byte for byte. + #[test] + fn read_mkb_from_drive_returns_the_concatenated_pack_payload() { + let pack0: Vec = (0..600u32).map(|i| (i % 251) as u8).collect(); + let pack1: Vec = (0..300u32).map(|i| (i % 253) as u8 ^ 0xA5).collect(); + let mut drive = MkbDrive { + packs: vec![pack0.clone(), pack1.clone()], + cdbs: Vec::new(), + }; + + let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers"); + + let mut expected = pack0.clone(); + expected.extend_from_slice(&pack1); + assert_eq!( + mkb.len(), + expected.len(), + "every pack's payload must be concatenated, none dropped" + ); + assert!( + mkb == expected, + "MKB bytes must be the drive's payload in pack order; first \ + mismatch at {:?}", + (0..expected.len()).find(|&i| mkb[i] != expected[i]) + ); + + // MMC-6 READ DISC STRUCTURE with the AACS MKB format code, one command + // per pack, pack number in the address field. + assert_eq!(drive.cdbs.len(), 2, "one command per declared pack"); + for (i, cdb) in drive.cdbs.iter().enumerate() { + assert_eq!(cdb[0], SCSI_READ_DISC_STRUCTURE, "opcode"); + assert_eq!(cdb[7], 0x83, "AACS MKB disc-structure format code"); + assert_eq!( + u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]), + i as u32, + "pack {i} must be requested by number" + ); + } + } + + /// A single-pack disc still yields that pack's bytes — the common case, and + /// the one where a body returning an empty vector looks most plausible. + #[test] + fn read_mkb_from_drive_returns_a_single_packs_payload() { + let pack: Vec = (0..1024u32).map(|i| (i * 7 % 256) as u8).collect(); + let mut drive = MkbDrive { + packs: vec![pack.clone()], + cdbs: Vec::new(), + }; + let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers"); + assert_eq!(mkb.len(), pack.len(), "single pack payload length"); + assert!(mkb == pack, "single pack payload bytes"); + } + + /// A drive that reports a header-only response (`data_len < 2`) has no MKB + /// to give. That must be an EMPTY vec, not a partial one — the distinction + /// matters because the AACS paths treat a non-empty MKB as parseable. + #[test] + fn read_mkb_from_drive_empty_response_is_empty() { + struct NoMkb; + impl ScsiTransport for NoMkb { + fn execute( + &mut self, + _cdb: &[u8], + _direction: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> crate::error::Result { + data[0..2].copy_from_slice(&0u16.to_be_bytes()); + Ok(ScsiResult { + status: 0, + bytes_transferred: 4, + sense: [0u8; 32], + }) + } + } + let mkb = read_mkb_from_drive(&mut NoMkb).expect("no-MKB drive still returns Ok"); + assert!( + mkb.is_empty(), + "a header-only response carries no MKB bytes" + ); + } + + /// A transport failure on the FIRST pack must propagate as an error — the + /// MKB is the root of the whole AACS ladder, so an unreadable one cannot be + /// downgraded to "an MKB with no records". + #[test] + fn read_mkb_from_drive_propagates_the_first_pack_failure() { + struct DeadDrive; + impl ScsiTransport for DeadDrive { + fn execute( + &mut self, + _cdb: &[u8], + _direction: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> crate::error::Result { + Err(crate::error::Error::ScsiError { + opcode: SCSI_READ_DISC_STRUCTURE, + status: 0x02, + sense: None, + }) + } + } + assert!( + read_mkb_from_drive(&mut DeadDrive).is_err(), + "an unreadable MKB must surface as an error, not an empty MKB" + ); + } +} diff --git a/src/css/mod.rs b/src/css/mod.rs index b4844ac..21654de 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -960,6 +960,71 @@ mod tests { ); } + /// `is_scrambled_uncracked` is the predicate form of the Cracked / + /// Unencrypted / ScrambledUncracked split that round 7 introduced precisely + /// because conflating those cases made an uncrackable disc exit 0 with + /// garbage output. It is a public API predicate, so a consumer of this crate + /// can route on it in place of matching the enum. + /// + /// Every existing use of it asserts only the TRUE direction (the + /// ScrambledUncracked case). Nothing anywhere asserted it is FALSE for the + /// other two variants, so a body that answered "yes, uncrackable" to + /// everything was indistinguishable: a genuinely clear DVD and a + /// successfully cracked one would both be routed to `CssNoDiscKey` / + /// `CssKeyMissing` and refuse to rip. + /// + /// All three outcomes here come from real `crack_key_outcome` scans, not + /// hand-built enum values, so the predicate is checked against the verdicts + /// the scanner actually produces. + #[test] + fn is_scrambled_uncracked_is_true_for_that_case_and_false_for_the_other_two() { + let extents = [Extent { + start_lba: 1000, + sector_count: 50, + }]; + + // Cracked: a real Stevenson-crackable sector in an otherwise clear scan. + let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0x11, 0x22, 0x33, 0x44, 0x55]; + let mut cracked_src = MockSource::new(0x00); + cracked_src.crackable = Some((1003, crackable_sector(&title_key, &seed, 8))); + let cracked = crack_key_outcome(&mut cracked_src, &extents, 4, None); + assert!( + matches!(cracked, CrackOutcome::Cracked(_)), + "fixture malformed — expected a real crack, got {cracked:?}" + ); + assert!( + !cracked.is_scrambled_uncracked(), + "a disc whose key WAS recovered is not scrambled-uncracked; saying \ + so aborts a rip that had its key in hand" + ); + + // Unencrypted: scramble flag never set across the scan. + let mut clear_src = MockSource::new(0x00); + let clear = crack_key_outcome(&mut clear_src, &extents, 4, None); + assert!( + matches!(clear, CrackOutcome::Unencrypted), + "fixture malformed — expected Unencrypted, got {clear:?}" + ); + assert!( + !clear.is_scrambled_uncracked(), + "a genuinely plaintext disc is not scrambled-uncracked; saying so \ + turns every unencrypted DVD into a hard CSS key error" + ); + + // ScrambledUncracked: scrambled sectors seen, no crackable crib. + let mut locked_src = MockSource::new(0x30); + let locked = crack_key_outcome(&mut locked_src, &extents, 4, None); + assert!( + matches!(locked, CrackOutcome::ScrambledUncracked), + "fixture malformed — expected ScrambledUncracked, got {locked:?}" + ); + assert!( + locked.is_scrambled_uncracked(), + "scrambled sectors seen and no key recovered IS the hard-failure case" + ); + } + /// `resolve_dvd_title_key` is the SINGLE shared per-title CSS step both read /// paths (`build_iso_pipeline` multi-pass and `DiscStream::new` single-pass) /// call, so these pin its full contract at the shared boundary. diff --git a/src/decrypt.rs b/src/decrypt.rs index 94d0e99..97cb028 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -640,6 +640,92 @@ mod tests { ); } + /// `decrypt_sectors_in_content` is the entry point `DecryptingSectorSource` + /// dispatches to whenever a content map is installed (`sector/decrypting.rs` + /// line ~211), so it is on the live read path for every mapped rip. It must + /// actually DECRYPT. The two `_is_noop` tests above only assert its `usize` + /// return is `0` — which is what a body replaced by `Ok(0)` also returns, so + /// neither one constrains it at all. + /// + /// Here a genuinely scrambled CSS sector goes in and the CONSTRUCTED + /// plaintext must come out. Anything that skips `css::descramble_region` — + /// including a body that just reports `Ok(0)` — leaves ciphertext in the + /// buffer and the caller muxes scrambled MPEG at exit 0. + /// + /// Expected bytes come from the plaintext this test built BEFORE scrambling + /// (CSS scrambles only 0x80..2048; the header stays clear), not from + /// re-running any descramble routine. + #[test] + fn content_gate_css_actually_descrambles_the_buffer() { + const RUN_START: usize = 0x59; + const SEED_OFFSET: usize = 0x54; + const PERIOD: usize = 8; + let title_key = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + + let mut plaintext = vec![0u8; 2048]; + plaintext[0x00..0x04].copy_from_slice(&css::PACK_START); + plaintext[0x14] = 0x10; // CSS scramble flag (DVD-Video sector header) + let pat: Vec = (0..PERIOD) + .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) + .collect(); + for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) { + *b = pat[i % PERIOD]; + } + plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05]); + + let mut buf = plaintext.clone(); + css::lfsr::scramble_sector(&title_key, &mut buf); + let ciphertext = buf.clone(); + assert_ne!( + &ciphertext[0x80..], + &plaintext[0x80..], + "fixture malformed — the sector was not actually scrambled" + ); + + let mut keys = DecryptKeys::Css { title_key }; + decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 1)]) + .expect("CSS descramble must not fail"); + + // Report the first differing offset rather than dumping 1.9 KB. + let mismatch = (0x80..2048).find(|&i| buf[i] != plaintext[i]); + assert!( + mismatch.is_none(), + "the scrambled body must come back as the plaintext it was built \ + from; first mismatch at offset {mismatch:?} (buf={:#04x} \ + expected={:#04x}) — a wrapper that decrypts nothing leaves the \ + ciphertext in place and the caller muxes scrambled MPEG", + buf[mismatch.unwrap_or(0x80)], + plaintext[mismatch.unwrap_or(0x80)], + ); + } + + /// The AACS arm of the same entry point must fail LOUD. Under the + /// keymap-only model AACS decrypts exclusively through + /// `decrypt_sectors_mapped`; reaching this wrapper with AACS keys means a + /// reader was built without installing its key map, and continuing would + /// hand the caller ciphertext under an `Ok`. `DecryptFailed` is the correct + /// verdict per the function's own contract — it must not be softened into a + /// success with a zero count. + #[test] + fn content_gate_aacs_keys_fail_loud_not_ok_zero() { + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(1, [0xAB; 16])], + read_data_key: None, + format: crate::disc::ContentFormat::BdTs, + }; + let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN); + let mut buf = original.clone(); + let r = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]); + assert!( + matches!(r, Err(crate::error::Error::DecryptFailed)), + "AACS without an installed key map must be DecryptFailed, got {r:?}" + ); + assert_eq!( + buf, original, + "and it must not have half-decrypted the buffer on the way out" + ); + } + /// Build a Stevenson-crackable scrambled CSS sector for `title_key` (mirrors /// `crackable_sector` in the css::mod tests): a periodic run in the clear /// header continues past 0x80 into the encrypted region, so diff --git a/src/sector/mod.rs b/src/sector/mod.rs index 7d4ef5f..9791aae 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -353,4 +353,147 @@ mod tests { "set_unit_base must forward through &mut dyn" ); } + + /// Records every read that reaches it, including whether it arrived on the + /// FUA path and with which `fua` bit — `Spy` above leaves `read_sectors_fua` + /// to the trait default, so it cannot tell the two entry points apart. + /// One recorded read: `(lba, count, recovery, fua)`, where `fua` is `None` + /// when the plain `read_sectors` entry point was the one reached. + type ReadLog = Arc)>>>; + + #[derive(Default)] + struct ReadSpy { + calls: ReadLog, + fill: u8, + } + + impl SectorSource for ReadSpy { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result { + self.calls + .lock() + .unwrap() + .push((lba, count, recovery, None)); + let bytes = count as usize * 2048; + buf[..bytes].fill(self.fill); + Ok(bytes) + } + fn read_sectors_fua( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + fua: bool, + ) -> Result { + self.calls + .lock() + .unwrap() + .push((lba, count, recovery, Some(fua))); + let bytes = count as usize * 2048; + buf[..bytes].fill(self.fill); + Ok(bytes) + } + } + + /// Read through a generic `S: SectorSource` bound so the FORWARDING impl is + /// what runs. A direct `r.read_sectors(..)` on a `&mut dyn SectorSource` + /// receiver auto-derefs to the vtable and never touches the forwarding body, + /// which is exactly why `set_unit_base` needed `set_unit_base_generic` too. + fn read_generic( + mut s: S, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result { + s.read_sectors(lba, count, buf, recovery) + } + + /// Same, for the FUA entry point. + fn read_fua_generic( + mut s: S, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + fua: bool, + ) -> Result { + s.read_sectors_fua(lba, count, buf, recovery, fua) + } + + /// The `&mut dyn SectorSource` forwarding impl must actually DELEGATE + /// `read_sectors` — pass the args through unchanged, return the inner + /// source's byte count, and leave the inner source's bytes in the caller's + /// buffer. A body that returned a bare `Ok(n)` without calling the inner + /// source would be a delegating reader that reads NOTHING and reports + /// success: the caller sees `Ok` and consumes an untouched buffer. + /// + /// `mut_ref_dyn_forwards_all_methods` above does not cover this: its + /// `r.read_sectors(..)` call on a `&mut dyn` receiver dispatches through the + /// vtable to `Spy`, not through the forwarding impl. + #[test] + fn mut_ref_dyn_forwards_read_sectors_to_the_inner_source() { + let calls = Arc::new(Mutex::new(Vec::new())); + let mut inner = ReadSpy { + calls: calls.clone(), + fill: 0x5C, + }; + + let mut buf = vec![0u8; 4 * 2048]; + let r: &mut dyn SectorSource = &mut inner; + let n = read_generic(r, 0x1234, 4, &mut buf, true).expect("delegated read succeeds"); + + assert_eq!( + n, + 4 * 2048, + "the forwarding impl must return the INNER source's byte count" + ); + assert!( + buf.iter().all(|b| *b == 0x5C), + "the inner source's bytes must land in the caller's buffer; an \ + undelegated read leaves it untouched and the caller muxes zeroes" + ); + assert_eq!( + *calls.lock().unwrap(), + vec![(0x1234, 4, true, None)], + "lba/count/recovery must reach the inner source unchanged, on the \ + non-FUA entry point" + ); + } + + /// The same for `read_sectors_fua`: the forwarding impl must reach the inner + /// source's FUA entry point (not silently downgrade to the plain read, and + /// not fabricate a count), carrying the `fua` bit through. FUA is the + /// Pass-N lever that re-fetches a stochastic sector past the drive cache + /// (MMC-6 READ(10) FUA bit); a forwarder that dropped it would make every + /// FUA retry re-read the same cached bytes and "confirm" the bad sector. + #[test] + fn mut_ref_dyn_forwards_read_sectors_fua_to_the_inner_source() { + let calls = Arc::new(Mutex::new(Vec::new())); + let mut inner = ReadSpy { + calls: calls.clone(), + fill: 0x3B, + }; + + let mut buf = vec![0u8; 2 * 2048]; + let r: &mut dyn SectorSource = &mut inner; + let n = read_fua_generic(r, 42, 2, &mut buf, false, true).expect("delegated FUA read"); + + assert_eq!(n, 2 * 2048, "byte count must come from the inner source"); + assert!( + buf.iter().all(|b| *b == 0x3B), + "the inner source's bytes must land in the caller's buffer" + ); + assert_eq!( + *calls.lock().unwrap(), + vec![(42, 2, false, Some(true))], + "the FUA entry point must be the one reached, with fua=true intact" + ); + } }