mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
tests(macos): hardware drive checks (INQUIRY, reopen, UHD AACS2 state, rescan stability, probe/scan agreement) + non-hardware WritebackFile/preallocate tests
This commit is contained in:
+180
-9
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user