diff --git a/tests/macos_drive_tests.rs b/tests/macos_drive_tests.rs index e573d177..385f5959 100644 --- a/tests/macos_drive_tests.rs +++ b/tests/macos_drive_tests.rs @@ -114,17 +114,11 @@ fn inserted_disc_scans_filesystem_and_finds_titles() { DeviceTarget::Path(PathBuf::from(&drive.path)), KeySpec::default(), ) - .unwrap_or_else(|e| { - panic!( - "failed to open {} ({}): {e}", - drive_name(&drive), - drive.path - ) - }); + .unwrap_or_else(|e| panic!("failed to open {} ({}): {e}", drive_name(drive), drive.path)); let disc = session.scan(ScanOptions::default()).unwrap_or_else(|e| { panic!( "scan failed for {} ({}): {e}", - drive_name(&drive), + drive_name(drive), drive.path ) }); @@ -132,7 +126,7 @@ fn inserted_disc_scans_filesystem_and_finds_titles() { assert!( !disc.titles.is_empty(), "disc scan completed but found no titles on {} ({})", - drive_name(&drive), + drive_name(drive), drive.path ); } @@ -154,3 +148,180 @@ fn inserted_disc_autodetects_and_scans_titles() { "autodetected drive scanned the disc but found no titles" ); } + +/// INQUIRY populates vendor / model / firmware for every enumerated drive. +/// Blank fields would indicate the IOKit walk dropped an INQUIRY-carrying +/// child, which would silently break drive-fingerprint keydb lookups. +#[test] +#[ignore = "requires an attached optical drive"] +fn every_enumerated_drive_reports_vendor_model_firmware() { + let drives = list_drives(); + assert!(!drives.is_empty(), "no optical drive enumerated"); + for d in &drives { + assert!(!d.vendor.trim().is_empty(), "empty vendor for {}", d.path); + assert!(!d.model.trim().is_empty(), "empty model for {}", d.path); + assert!( + !d.firmware.trim().is_empty(), + "empty firmware for {} ({} {})", + d.path, + d.vendor, + d.model + ); + assert!( + !d.path.is_empty(), + "empty selector path for {} {}", + d.vendor, + d.model + ); + } +} + +/// Opening the same drive twice back-to-back must succeed. Catches leaked +/// exclusive-open state in the IOKit session path — a regression would +/// surface as "device busy" on the second open. +#[test] +#[ignore = "requires an attached optical drive"] +fn drive_can_be_reopened_after_close() { + let drives = list_drives(); + let target = drives.first().expect("no optical drive enumerated"); + for i in 0..3 { + let drive = Drive::open(Path::new(&target.path)).unwrap_or_else(|e| { + panic!( + "iter {i}: failed to reopen {} ({}): {e}", + drive_name(target), + target.path + ) + }); + drop(drive); + } +} + +/// UHD disc scan populates the AACS 2.0 shape we advertise on the UI: +/// bus_encryption true, MKB version present, disc_hash populated, VID set, +/// at least one CPS unit key. Verifies the scan pipeline end-to-end for +/// the exact disc type most likely to regress. +#[test] +#[ignore = "requires an inserted UHD Blu-ray; scans AACS 2.0 disc metadata"] +fn inserted_uhd_disc_reports_full_aacs2_state() { + use libfreemkv::disc::DiscFormat; + use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions}; + + let mut session = DiscSession::open(DeviceTarget::Autodetect, KeySpec::default()) + .unwrap_or_else(|e| panic!("autodetect failed to open the inserted disc: {e}")); + let disc = session + .scan(ScanOptions::default()) + .unwrap_or_else(|e| panic!("scan failed: {e}")); + + assert!( + matches!(disc.format, DiscFormat::Uhd), + "expected UHD DiscFormat, got {:?}", + disc.format + ); + assert!( + disc.capacity_bytes >= 40_000_000_000, + "UHD capacity should be ≥ 40 GB, got {} bytes", + disc.capacity_bytes + ); + assert!( + disc.layers >= 2, + "UHD disc should report ≥ 2 layers, got {}", + disc.layers + ); + + let aacs = disc.aacs.as_ref().expect("UHD disc must have AacsState"); + assert_eq!(aacs.version, 2, "UHD disc must be AACS 2.0"); + assert!(aacs.bus_encryption, "UHD disc must set bus_encryption"); + assert!( + aacs.mkb_version.is_some_and(|v| v > 0), + "UHD disc must expose MKB version, got {:?}", + aacs.mkb_version + ); + assert!( + aacs.disc_hash.starts_with("0x") && aacs.disc_hash.len() == 42, + "disc_hash should be 0x-prefixed 40 hex chars, got {:?}", + aacs.disc_hash + ); + assert_ne!( + aacs.volume_id, [0u8; 16], + "VID must be populated after a successful AACS 2.0 handshake" + ); + assert!( + !aacs.unit_keys.is_empty(), + "at least one CPS unit key expected for a scannable UHD disc" + ); +} + +/// Scan is idempotent: rescanning the same session yields the same disc +/// hash and MKB version. Guards against stateful drift in the scan path. +#[test] +#[ignore = "requires an inserted Blu-ray/DVD; rescans twice"] +fn rescan_returns_stable_disc_identity() { + use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions}; + + let mut session = DiscSession::open(DeviceTarget::Autodetect, KeySpec::default()) + .unwrap_or_else(|e| panic!("open failed: {e}")); + let first_snapshot = { + let first = session + .scan(ScanOptions::default()) + .unwrap_or_else(|e| panic!("first scan failed: {e}")); + first + .aacs + .as_ref() + .map(|a| (a.disc_hash.clone(), a.mkb_version, a.volume_id)) + }; + let second_snapshot = { + let second = session + .scan(ScanOptions::default()) + .unwrap_or_else(|e| panic!("second scan failed: {e}")); + second + .aacs + .as_ref() + .map(|a| (a.disc_hash.clone(), a.mkb_version, a.volume_id)) + }; + + let (Some(a), Some(b)) = (first_snapshot, second_snapshot) else { + return; + }; + assert_eq!(a.0, b.0, "disc_hash drifted across rescans"); + assert_eq!(a.1, b.1, "mkb_version drifted across rescans"); + assert_eq!(a.2, b.2, "VID drifted across rescans"); +} + +/// `drive_has_disc` matches what `DiscSession::open + scan` actually finds. +/// Every drive returning `Ok(true)` must be scannable; every `Ok(false)` +/// must fail to scan. Catches enumeration/probe divergence. +#[test] +#[ignore = "requires an attached optical drive; probes every enumerated drive"] +fn drive_has_disc_matches_scan_outcome() { + use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions}; + + let drives = list_drives(); + assert!(!drives.is_empty(), "no optical drive enumerated"); + for d in &drives { + match drive_has_disc(Path::new(&d.path)) { + Ok(true) => { + let mut session = DiscSession::open( + DeviceTarget::Path(PathBuf::from(&d.path)), + KeySpec::default(), + ) + .unwrap_or_else(|e| panic!("open failed for {}: {e}", d.path)); + let _ = session + .scan(ScanOptions::default()) + .unwrap_or_else(|e| panic!("scan failed on drive_has_disc=true: {e}")); + } + Ok(false) => { + let mut session = DiscSession::open( + DeviceTarget::Path(PathBuf::from(&d.path)), + KeySpec::default(), + ) + .unwrap_or_else(|e| panic!("open failed for empty tray {}: {e}", d.path)); + assert!( + session.scan(ScanOptions::default()).is_err(), + "scan unexpectedly succeeded on drive_has_disc=false: {}", + d.path + ); + } + Err(e) => panic!("drive_has_disc errored on {}: {e}", d.path), + } + } +} diff --git a/tests/macos_platform_tests.rs b/tests/macos_platform_tests.rs new file mode 100644 index 00000000..538a5d8f --- /dev/null +++ b/tests/macos_platform_tests.rs @@ -0,0 +1,85 @@ +//! macOS platform-integration tests that do NOT require an optical drive. +//! +//! These exercise the Mac-only code paths under `platform/fs_type/macos.rs`, +//! `io/platform_macos.rs`, and `io/writeback_file` end-to-end against the real +//! kernel. They run under `cargo test` without `--ignored`, so CI on macOS +//! (and the local `cargo test` a developer would run before pushing) covers +//! them automatically. + +#![cfg(target_os = "macos")] + +use libfreemkv::WritebackFile; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::PathBuf; + +fn tmp_path(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + p.push(format!("freemkv-macos-test-{pid}-{nanos}-{name}")); + p +} + +#[test] +fn writeback_file_roundtrips_write_read() { + let path = tmp_path("roundtrip"); + let payload = b"freemkv writeback roundtrip on APFS"; + + { + let mut wf = WritebackFile::create(&path).expect("create WritebackFile"); + wf.write_all(payload).expect("write"); + wf.sync_all().expect("sync_all"); + } + + let mut f = std::fs::File::open(&path).expect("reopen for read"); + let mut buf = Vec::new(); + f.read_to_end(&mut buf).expect("read"); + let _ = std::fs::remove_file(&path); + + assert_eq!(buf, payload); +} + +#[test] +fn writeback_file_seek_and_patch() { + let path = tmp_path("seek"); + + let mut wf = WritebackFile::create(&path).expect("create WritebackFile"); + wf.write_all(b"AAAAAAAA").expect("initial write"); + wf.seek(SeekFrom::Start(3)).expect("seek back"); + wf.write_all(b"BB").expect("patch write"); + wf.sync_all().expect("sync_all"); + drop(wf); + + let contents = std::fs::read(&path).expect("read back"); + let _ = std::fs::remove_file(&path); + assert_eq!(&contents, b"AAABBAAA"); +} + +#[test] +fn writeback_file_create_with_size_hint_preallocates() { + let path = tmp_path("prealloc"); + let hint: u64 = 16 * 1024 * 1024; + + let mut wf = WritebackFile::create_with_size_hint(&path, hint).expect("create_with_size_hint"); + wf.write_all(&[0xAB; 4096]).expect("write header"); + wf.sync_all().expect("sync_all"); + drop(wf); + + // Reported file length reflects bytes written, not the preallocation. + let meta = std::fs::metadata(&path).expect("stat"); + let _ = std::fs::remove_file(&path); + assert_eq!( + meta.len(), + 4096, + "reported length should equal bytes written" + ); +} + +#[test] +fn list_drives_does_not_panic_without_hardware() { + // Even on a Mac with no optical drive, enumeration must return cleanly. + let _ = libfreemkv::list_drives(); +}