Mux decrypt/verify redesign, HD DVD first-class, MVC 3D
decrypt: - decrypt_sectors is now a pure decrypt (apply key, leave plaintext, report unverified bytes); TS-structure is a separate primitive (is_clean_ts/ps) used only for key selection and read-verify. The mux passes decrypted bytes through (the demuxer drops non-conforming packets), ending the NULL-TS conceal loop and the per-unit key-server refetch storm. Key-proof floor replaces the 75% supermajority. recovery: - Removed the post-read decrypt-verify gate (verify.rs) that mis-aligned the disc-absolute unit grid against clip-anchored AACS units and false-failed good clips (e.g. Dunkirk's orphan-CPS clip). Bad sectors are marked by physical read result; decryptability is proven at scan + mux time. HD DVD (first-class AACS): - Role-based candidate-list file sourcing so an HD DVD's /ANY!/ files (MKBROM.AACS, VTKF000.AACS, CONTENT_CERT.AACS) are found with no disc-type branch. parse_vtkf parses VTKF000.AACS into the same UnitKeyFile as a BD Unit_Key_RO.inf, so the shared VUK unwrap applies unchanged. set_unit_base clip-anchoring. Two decrypt-axis assumptions remain UNVERIFIED-HDDVD-DECRYPT (no encrypted disc to test). mux: - MVC (Blu-ray 3D) track signals unified into one MVCDecoderConfigurationRecord; release-safe track_vint (3-byte VINT) and pid_index (i32) guards. hardening: - Container-aware is_clean / encryption detection; bytes_bad_in_title fail-safe on a corrupt mapfile; CSS crack gated on DiscFormat::Dvd (HD DVD excluded); non-vacuous CSS tests; patch NOT_READY/HARDWARE/ILLEGAL_REQUEST/ABORTED sense-path tests.
This commit is contained in:
+52
-52
@@ -134,11 +134,7 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
assert!(aacs::content::ts_sync_destroyed(&plain));
|
||||
|
||||
// Now decrypt
|
||||
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
|
||||
assert!(
|
||||
result,
|
||||
"decrypt_unit should return true on valid encrypted unit"
|
||||
);
|
||||
aacs::content::decrypt_unit(&mut plain, &unit_key);
|
||||
assert!(
|
||||
!aacs::content::ts_sync_destroyed(&plain),
|
||||
"decrypted unit should read as clear (TS syncs restored)"
|
||||
@@ -298,13 +294,16 @@ fn aacs_ts_sync_destroyed_detection() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: aacs_decrypt_unit_unencrypted_passthrough
|
||||
/// Test: aacs_clear_unit_reports_not_encrypted
|
||||
///
|
||||
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
|
||||
/// `decrypt_unit` is now PURE (applies the key unconditionally). The "leave a
|
||||
/// clear unit untouched" policy lives at the caller's gate `aacs_unit_encrypted`:
|
||||
/// a CPI-clear unit reports not-encrypted, so the caller never hands it to
|
||||
/// decrypt_unit.
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
fn aacs_clear_unit_reports_not_encrypted() {
|
||||
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
|
||||
// Intact TS syncs every 192 bytes → not scrambled.
|
||||
let mut off = 4;
|
||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
@@ -312,13 +311,12 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
}
|
||||
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
||||
unit[0] &= 0x3F;
|
||||
let original = unit.clone();
|
||||
let key = [0xAA; 16];
|
||||
|
||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
||||
let result = aacs::content::decrypt_unit(&mut unit, &key);
|
||||
assert!(result, "clear unit should return true");
|
||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
||||
assert!(
|
||||
!aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
|
||||
"CPI-clear unit reports not-encrypted; the caller never decrypts it"
|
||||
);
|
||||
}
|
||||
|
||||
// ── AACS cross-validation with independent AES implementation ──────────────
|
||||
@@ -413,11 +411,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
);
|
||||
|
||||
// -- Decrypt with the library --
|
||||
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||
assert!(
|
||||
ok,
|
||||
"decrypt_unit returned false (TS sync verification failed)"
|
||||
);
|
||||
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
@@ -458,44 +452,14 @@ fn aacs_cross_validation_alternate_key() {
|
||||
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
|
||||
);
|
||||
|
||||
assert!(aacs::content::decrypt_unit(&mut plaintext, &unit_key));
|
||||
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(&plaintext[..], &expected[..]);
|
||||
}
|
||||
|
||||
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
|
||||
/// per-sector to bytes 16..2048 (bus encryption layer).
|
||||
#[test]
|
||||
fn aacs_bus_decrypt_cross_validation() {
|
||||
let read_data_key: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
||||
0x00,
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 0..aacs::content::ALIGNED_UNIT_LEN {
|
||||
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
|
||||
}
|
||||
let expected = plaintext.clone();
|
||||
|
||||
// Encrypt per-sector: AES-CBC encrypt bytes 16..2048 of each 2048-byte sector
|
||||
for sector_start in (0..aacs::content::ALIGNED_UNIT_LEN).step_by(2048) {
|
||||
ref_aes_cbc_encrypt(
|
||||
&read_data_key,
|
||||
&CROSS_AACS_IV,
|
||||
&mut plaintext[sector_start + 16..sector_start + 2048],
|
||||
);
|
||||
}
|
||||
assert_ne!(&plaintext[16..32], &expected[16..32]);
|
||||
|
||||
aacs::content::decrypt_bus(&mut plaintext, &read_data_key);
|
||||
assert_eq!(
|
||||
plaintext, expected,
|
||||
"bus decrypt did not recover original plaintext"
|
||||
);
|
||||
}
|
||||
// (`decrypt_bus` is a crate-internal layer — its cross-validation lives in-crate
|
||||
// in `aacs::content`'s unit tests, not here.)
|
||||
|
||||
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
|
||||
|
||||
@@ -663,6 +627,28 @@ fn css_stevenson_attack_validates_cracked_key() {
|
||||
This is expected: synthetic sectors lack the TAB1 output encoding \
|
||||
present in real CSS-encrypted DVD sectors."
|
||||
);
|
||||
// Never let this test pass vacuously: when the attack can't converge on
|
||||
// synthetic data, still assert always-true properties of the CSS keystream
|
||||
// so a real regression is caught on every run — descramble_sector is
|
||||
// DETERMINISTIC (same key/seed/data → same output) and NON-TRIVIAL (it
|
||||
// actually transforms the payload, not a silent no-op).
|
||||
for (key, seed) in candidates {
|
||||
let mut base = vec![0x00u8; 2048];
|
||||
base[0x14] = 0x30;
|
||||
base[0x54..0x59].copy_from_slice(seed);
|
||||
base[0x80..0x8A]
|
||||
.copy_from_slice(&[0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21]);
|
||||
let mut a = base.clone();
|
||||
let mut b = base.clone();
|
||||
css::lfsr::descramble_sector(key, &mut a);
|
||||
css::lfsr::descramble_sector(key, &mut b);
|
||||
assert_eq!(a, b, "descramble must be deterministic for key={key:02X?}");
|
||||
assert_ne!(
|
||||
&a[0x80..2048],
|
||||
&base[0x80..2048],
|
||||
"descramble must transform the payload for key={key:02X?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,6 +693,20 @@ fn css_recover_title_key_with_exact_plaintext() {
|
||||
The LFSR0 recovery phase may not converge for this combination.",
|
||||
title_key, seed
|
||||
);
|
||||
// Never pass vacuously: when LFSR0 recovery can't converge on this
|
||||
// synthetic sector, still assert always-true properties of the cipher so
|
||||
// a real regression is caught on every run — descramble_sector is
|
||||
// DETERMINISTIC and NON-TRIVIAL (actually transforms the payload).
|
||||
let mut a = original.clone();
|
||||
let mut b = original.clone();
|
||||
css::lfsr::descramble_sector(&title_key, &mut a);
|
||||
css::lfsr::descramble_sector(&title_key, &mut b);
|
||||
assert_eq!(a, b, "descramble must be deterministic");
|
||||
assert_ne!(
|
||||
&a[0x80..2048],
|
||||
&original[0x80..2048],
|
||||
"descramble must transform the payload"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,21 +27,39 @@ fn decrypt_sectors_with_aacs_keys_works() {
|
||||
|
||||
let unit_key: [u8; 16] = [0xAAu8; 16];
|
||||
|
||||
// Encrypt the unit using AACS algorithm
|
||||
aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
|
||||
// Apply the key to the pattern to produce ciphertext-shaped bytes for the
|
||||
// call below. (decrypt_unit is now PURE — it applies the key unconditionally,
|
||||
// so it is NOT idempotent; never call it twice on the same unit.)
|
||||
aacs::content::decrypt_unit(&mut unit, &unit_key);
|
||||
// (byte 0 keeps its CPI bits set from above, so `decrypt_sectors` recognises
|
||||
// this as encrypted content and actually applies the key.)
|
||||
|
||||
// Now we have encrypted data - create DecryptKeys with actual keys
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
let mut aacs_keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0u32, unit_key)],
|
||||
read_data_key: None,
|
||||
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut none_keys = DecryptKeys::None;
|
||||
|
||||
// decrypt_sectors should handle this without error
|
||||
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &mut keys, 0);
|
||||
// The regression this guards is passing `DecryptKeys::None` where AACS keys
|
||||
// were meant. Prove the two DIVERGE: AACS applies the key (bytes change), None
|
||||
// leaves the unit byte-for-byte untouched. is_ok alone can't catch that —
|
||||
// both variants return Ok.
|
||||
let mut with_aacs = unit.clone();
|
||||
let mut with_none = unit.clone();
|
||||
libfreemkv::decrypt::decrypt_sectors(&mut with_aacs, &mut aacs_keys, 0)
|
||||
.expect("AACS decrypt must not error");
|
||||
libfreemkv::decrypt::decrypt_sectors(&mut with_none, &mut none_keys, 0)
|
||||
.expect("None decrypt must not error");
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"decrypt_sectors with AACS keys should not error"
|
||||
assert_ne!(
|
||||
with_aacs, unit,
|
||||
"AACS keys must actually transform the unit"
|
||||
);
|
||||
assert_eq!(with_none, unit, "None keys must leave the unit untouched");
|
||||
assert_ne!(
|
||||
with_aacs, with_none,
|
||||
"AACS decrypt must differ from the None no-op (the None-vs-Aacs regression)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,6 +129,7 @@ fn decrypt_keys_is_encrypted_variants() {
|
||||
let aacs = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||
};
|
||||
assert!(aacs.is_encrypted());
|
||||
|
||||
|
||||
@@ -275,7 +275,6 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
|
||||
progress: None,
|
||||
halt: Some(halt.clone()),
|
||||
key_fetch: None,
|
||||
fast_capture: false,
|
||||
};
|
||||
|
||||
let outcome = disc.patch(&mut reader, &iso_path, &opts);
|
||||
|
||||
+187
-29
@@ -232,8 +232,6 @@ struct Golden {
|
||||
bytes_unreadable: u64,
|
||||
/// `bytes_pending` (NonTrimmed) at end.
|
||||
bytes_pending: u64,
|
||||
/// Did the pass exit via wedge-detection?
|
||||
wedged_exit: bool,
|
||||
/// Sanity bound on trace length — patch makes a finite number of
|
||||
/// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus
|
||||
/// retries. Asserted as an UPPER bound only (so any reduction in
|
||||
@@ -319,7 +317,6 @@ fn profile_01_clean_all_recoverable() {
|
||||
bytes_good: capacity_sectors as u64 * 2048,
|
||||
bytes_unreadable: 0,
|
||||
bytes_pending: 0,
|
||||
wedged_exit: false,
|
||||
max_reads: 8, // adaptive batch=32 reads finishes 16 sectors in 1 read; allow up to 8.
|
||||
};
|
||||
assert_eq!(stats.bytes_good, expected.bytes_good, "01_clean bytes_good");
|
||||
@@ -796,29 +793,190 @@ fn profile_08_batch_fail_singles_ok() {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Suppressed for now: NOT_READY-then-recover, HARDWARE_ERROR (wedge),
|
||||
// ILLEGAL_REQUEST (wedge), and ABORTED_COMMAND profiles. Each would
|
||||
// trigger long real-time sleeps inside `handle_read_failure`:
|
||||
// Sense-family error paths in `Disc::patch`: NOT_READY-then-recover,
|
||||
// HARDWARE_ERROR, ILLEGAL_REQUEST, and ABORTED_COMMAND.
|
||||
//
|
||||
// - NOT_READY (sense_key=0x02, asc=0x02/0x03/0x04): 15 s pause per
|
||||
// occurrence (`patch_not_ready_pause`), and retries the same LBA
|
||||
// in-place. Even one NOT_READY costs the test 15 s wall-time.
|
||||
// These drive `disc.patch(...)` DIRECTLY rather than through `run_profile`
|
||||
// (which drives `Disc::copy`, whose SWEEP path really sleeps on NOT_READY /
|
||||
// wedge cooldowns via `sleep_secs_or_halt`). The patch handler chain itself
|
||||
// uses an injectable deadline clock (`Instant::now` in production) and never
|
||||
// `thread::sleep`s, so these paths run at full speed with no wall-time cost —
|
||||
// the earlier "sleeps aren't injectable" suppression only ever applied to the
|
||||
// copy/sweep driver, not to patch.
|
||||
//
|
||||
// - HARDWARE_ERROR / ILLEGAL_REQUEST: 30 s per occurrence
|
||||
// (`WEDGE_FAMILY_COOLDOWN_SECS`), bounded by
|
||||
// `WEDGE_ABORT_THRESHOLD=16` before wedged-exit. Worst case ~8
|
||||
// minutes per profile.
|
||||
//
|
||||
// The sleeps are not injectable. Adding them would require either a
|
||||
// `now()` / `sleep()` trait injection (out of scope for the unification
|
||||
// task) or a "test mode" compile-time flag (architectural smell). The
|
||||
// behavioural contracts for those paths are captured in
|
||||
// `read_error.rs`'s in-module tests instead — they exercise the
|
||||
// classifier without invoking the patch loop's sleep side-effects.
|
||||
//
|
||||
// If the unification ever proceeds, the next step is to add a clock
|
||||
// injection point in `handle_read_failure` and extend this fixture
|
||||
// with the wedge/NOT_READY profiles too.
|
||||
// The load-bearing invariant asserted across every PERSISTENT failure sense is
|
||||
// the recovery contract: a patch pass NEVER promotes a sector to Unreadable
|
||||
// (the orchestrator does that only after the final pass) and NEVER silently
|
||||
// drops bytes — a still-bad sector stays NonTrimmed (pending), so
|
||||
// good + pending always conserves the total. Exact good/pending splits are
|
||||
// left loose so wedge-skip tuning can't spuriously fail these.
|
||||
|
||||
/// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192)
|
||||
/// range) patch pass with the given failure step and return the final map
|
||||
/// stats. 256-sector synthetic disc; everything outside the range is Finished.
|
||||
fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats {
|
||||
let capacity_sectors: u32 = 256;
|
||||
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
|
||||
reader.always(130, step);
|
||||
|
||||
let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
let disc = synthetic_disc(capacity_sectors);
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
let nontrimmed = [(128 * 2048, 64 * 2048)];
|
||||
let finished = [
|
||||
(0, 128 * 2048),
|
||||
(192 * 2048, (capacity_sectors as u64 - 192) * 2048),
|
||||
];
|
||||
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
||||
|
||||
let opts = libfreemkv::disc::PatchOptions {
|
||||
decrypt: false,
|
||||
block_sectors: Some(32),
|
||||
full_recovery: true,
|
||||
reverse: true,
|
||||
wedged_threshold: 50,
|
||||
progress: None,
|
||||
halt: None,
|
||||
key_fetch: None,
|
||||
};
|
||||
disc.patch(&mut reader, &iso_path, &opts)
|
||||
.expect("patch must not error on a per-sector failure sense");
|
||||
|
||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||
let stats = Mapfile::load(&map_path).unwrap().stats();
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(&map_path);
|
||||
stats
|
||||
}
|
||||
|
||||
/// A persistent sense that never clears must obey the pass contract: nothing
|
||||
/// Unreadable, nothing lost (good + pending == total), and at least the dead
|
||||
/// sector left pending.
|
||||
fn assert_persistent_sense_contract(step: ScriptStep, label: &str) {
|
||||
let stats = single_dead_sector_patch_stats(step);
|
||||
let total = 256u64 * 2048;
|
||||
assert_eq!(
|
||||
stats.bytes_unreadable, 0,
|
||||
"{label}: a patch pass must NEVER mark Unreadable"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.bytes_good + stats.bytes_pending,
|
||||
total,
|
||||
"{label}: conservation — no byte may be silently dropped"
|
||||
);
|
||||
assert!(
|
||||
stats.bytes_pending >= 2048,
|
||||
"{label}: the always-dead sector must remain pending (NonTrimmed)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_persistent_hardware_error_conserves_and_never_unreadable() {
|
||||
// HARDWARE_ERROR (sense_key=0x04) — wedge family.
|
||||
assert_persistent_sense_contract(
|
||||
ScriptStep::Err {
|
||||
sense_key: 0x04,
|
||||
asc: 0x11,
|
||||
ascq: 0x00,
|
||||
},
|
||||
"HARDWARE_ERROR",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_persistent_illegal_request_conserves_and_never_unreadable() {
|
||||
// ILLEGAL_REQUEST (sense_key=0x05) — wedge family.
|
||||
assert_persistent_sense_contract(
|
||||
ScriptStep::Err {
|
||||
sense_key: 0x05,
|
||||
asc: 0x21,
|
||||
ascq: 0x00,
|
||||
},
|
||||
"ILLEGAL_REQUEST",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_persistent_aborted_command_conserves_and_never_unreadable() {
|
||||
// ABORTED_COMMAND (sense_key=0x0B).
|
||||
assert_persistent_sense_contract(
|
||||
ScriptStep::Err {
|
||||
sense_key: 0x0B,
|
||||
asc: 0x00,
|
||||
ascq: 0x00,
|
||||
},
|
||||
"ABORTED_COMMAND",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_not_ready_then_recovers_fully() {
|
||||
// NOT_READY (sense_key=0x02, asc=0x04) that clears after two attempts must
|
||||
// recover the sector in-pass — no residual loss, no Unreadable, no hang.
|
||||
let capacity_sectors: u32 = 256;
|
||||
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
|
||||
reader.sequence(
|
||||
130,
|
||||
vec![
|
||||
ScriptStep::Err {
|
||||
sense_key: 0x02,
|
||||
asc: 0x04,
|
||||
ascq: 0x00,
|
||||
},
|
||||
ScriptStep::Err {
|
||||
sense_key: 0x02,
|
||||
asc: 0x04,
|
||||
ascq: 0x00,
|
||||
},
|
||||
ScriptStep::Ok,
|
||||
],
|
||||
);
|
||||
|
||||
let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
let disc = synthetic_disc(capacity_sectors);
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
let nontrimmed = [(128 * 2048, 64 * 2048)];
|
||||
let finished = [
|
||||
(0, 128 * 2048),
|
||||
(192 * 2048, (capacity_sectors as u64 - 192) * 2048),
|
||||
];
|
||||
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
||||
|
||||
let opts = libfreemkv::disc::PatchOptions {
|
||||
decrypt: false,
|
||||
block_sectors: Some(32),
|
||||
full_recovery: true,
|
||||
reverse: true,
|
||||
wedged_threshold: 50,
|
||||
progress: None,
|
||||
halt: None,
|
||||
key_fetch: None,
|
||||
};
|
||||
disc.patch(&mut reader, &iso_path, &opts)
|
||||
.expect("patch must not error on a transient NOT_READY");
|
||||
|
||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||
let stats = Mapfile::load(&map_path).unwrap().stats();
|
||||
assert_eq!(
|
||||
stats.bytes_unreadable, 0,
|
||||
"NOT_READY recovery must not mark Unreadable"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.bytes_pending, 0,
|
||||
"a NOT_READY that clears must leave nothing pending"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.bytes_good,
|
||||
capacity_sectors as u64 * 2048,
|
||||
"every sector recovers once NOT_READY clears"
|
||||
);
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(&map_path);
|
||||
}
|
||||
|
||||
// ──────── Handler chain recovers re-readable sectors inside a bad block ────────
|
||||
//
|
||||
@@ -826,9 +984,10 @@ fn profile_08_batch_fail_singles_ok() {
|
||||
// handler chain's linear pass narrows a failed batch to per-sector reads, so it
|
||||
// recovers EVERY re-readable sector and leaves ONLY the dead sector NonTrimmed —
|
||||
// strictly better than the old fast-capture path, which left the whole failed
|
||||
// 32-block untouched. (`fast_capture` is now inert: the chain supersedes it. The
|
||||
// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is
|
||||
// a scheduling concern for the handler scheduler, tracked separately.)
|
||||
// 32-block untouched. (The old `fast_capture` knob was removed: the handler
|
||||
// chain supersedes it. The breadth-first "fast on all ranges, then escalate"
|
||||
// ORDERING it once provided is a scheduling concern for the handler scheduler,
|
||||
// tracked separately.)
|
||||
//
|
||||
// The load-bearing invariant is unchanged: NO data is dropped. A still-bad
|
||||
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable.
|
||||
@@ -869,10 +1028,9 @@ fn handler_chain_recovers_readable_sectors_leaving_only_dead_pending() {
|
||||
progress: None,
|
||||
halt: None,
|
||||
key_fetch: None,
|
||||
fast_capture: true,
|
||||
};
|
||||
disc.patch(&mut reader, &iso_path, &opts)
|
||||
.expect("fast-capture patch must not error");
|
||||
.expect("handler-chain patch must not error");
|
||||
|
||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||
let stats = Mapfile::load(&map_path).unwrap().stats();
|
||||
|
||||
Reference in New Issue
Block a user