Compare commits

...
10 Commits
Author SHA1 Message Date
Matthew Jackson 584cc6030b tests(macos): hardware drive checks (INQUIRY, reopen, UHD AACS2 state, rescan stability, probe/scan agreement) + non-hardware WritebackFile/preallocate tests 2026-09-25 13:44:22 -07:00
Matthew Jackson 7257559153 chore: absorb qa into dev for lockstep promote [skip-media] 2026-09-25 12:25:45 -07:00
Matthew Jackson 8949b81dfc macos(drive): discover empty-tray drives via IOKit registry-id selectors
Empty-tray optical drives couldn't be opened because the macOS backend
resolved drives by media-child `BSD Name`, which doesn't exist without a
disc loaded. Result: `list_drives()` returned nothing for a drive with no
media, and any attempt to `open("disc://")` on that state failed.

Fix: enumerate `IOBDServices` directly. With media present, the returned
selector is the familiar `/dev/diskN` path (unchanged). With no media,
the selector is an opaque `ioreg:<registry-id>` string that resolves to
the same IOBDServices entry — Disk Arbitration and unmount are skipped
because there's no media node.

- `src/drive/macos.rs`, `src/drive/mod.rs`, `src/scsi/mod.rs`: threads
  the selector through the transport so empty-tray drives are openable
  without punning it as a filesystem path.
- `src/scsi/macos.rs`, `src/scsi/macos_shim.c`: the C shim now accepts
  both `/dev/diskN` and `ioreg:<id>` in `shim_open_exclusive`, and
  `shim_list_drives` reports the correct selector for each drive.
- `tests/macos_drive_tests.rs` (new): covers empty-tray discovery.
- `docs/drive-access.md`, `docs/scsi-macos.md`: document the two-path
  selector model.

[skip-media]
2026-09-25 12:03:02 -07:00
Matthew Jackson 7bcfdeda32 restore freemkv-unlock path dep for local dev (post-v1.7.6) 2026-09-25 05:32:00 +00:00
Matthew Jackson 89be65a3d4 v1.7.6: bump version (freemkv-unlock git-pinned for the tag) 2026-09-25 05:31:57 +00:00
Matthew Jackson f21ab37920 chore(qa): gate 1.7.6 promote — media matrix not needed [skip-media]
The 1.7.6 unified release ships no rip-path change (Linux desktop shell,
notifications, menu_layout refactor, XDG paths). Real-media QA on EC2
adds ~2h + real money to the release cycle to prove code that never
enters the rip pipeline. This empty commit puts `[skip-media]` in qa's
HEAD so the media matrix in qa.yml auto-skips per the documented bypass;
the full-platform builds/tests still run.
2026-09-24 21:37:26 -07:00
Matthew Jackson 0c28e569a8 chore(qa-sync): absorb qa's post-1.7.5 release commits into dev
Same fix as the other 7 repos: qa held two release-orchestrate commits
(v1.7.5 bump + post-release freemkv-unlock path restore) that never
propagated back to dev, so promote's fast-forward gate refused. Ours-merge
records qa as an ancestor without applying its content; dev has already
moved past 1.7.5 to 1.7.6.
2026-09-24 21:27:58 -07:00
Matthew Jackson 0d9565eac9 ci(consumers): install libgtk-4-dev so the freemkv consumer build resolves
The freemkv 1.7.6 Linux target compiles a GTK4 + libadwaita desktop shell,
so this job's downstream `cargo test`/`cargo check` on freemkv hits
`gtk4-sys`/`glib-sys` pkg-config probes that fail on a stock ubuntu
runner. Adds the apt-get step alongside the existing sibling checkout,
mirroring the fix in freemkv's own ci.yml `cli-linux` job.

Kept local (not in the shared reusable checkout action) — only `freemkv`
links against GTK; adding the deps to every downstream consumer would tax
the six that don't.
2026-09-24 20:37:35 -07:00
Matthew Jackson b52e3c8292 chore(release): bump to 1.7.6, follow the CI-required release-notes shape
Bumps Cargo.toml + Cargo.lock to 1.7.6. Also fixes a CI-shape defect the
previous release left behind: dev's CHANGELOG still said
`## [1.7.5] — UNRELEASED` after 1.7.5 shipped (release-orchestrate dated
main but did not push a fresh UNRELEASED heading back to dev). Corrects
that here so the changelog job accepts 1.7.6 on the way to qa:

  ## [1.7.6] — UNRELEASED
    - Version aligned; unified release driven by freemkv 1.7.6.
  ## [1.7.5] — 2026-09-23
    - Version aligned (or actual content, per the shipped main entry).

The driver for 1.7.6 is the freemkv Linux desktop shell (GTK4 +
libadwaita) + rip-finished desktop notification (issue #56).
2026-09-24 20:23:08 -07:00
Matthew Jackson d4510392f6 restore freemkv-unlock path dep for local dev (post-v1.7.5) 2026-09-23 04:40:54 +00:00
13 changed files with 693 additions and 96 deletions
+10
View File
@@ -214,6 +214,16 @@ jobs:
freemkv-i18n = { path = "../freemkv-i18n" }
EOF
done
# Since freemkv 1.7.6 the `freemkv` consumer's Linux target compiles a
# GTK4 + libadwaita desktop shell, so `cargo test` on it needs the
# pkg-config dev headers the `gtk4-sys`/`glib-sys` build.rs scripts
# probe. Kept local (not in the shared reusable) — only freemkv links
# against GTK; adding the deps to every consumer would tax the six
# that don't.
- name: Install GTK4 + libadwaita development headers (for freemkv)
run: |
sudo apt-get update
sudo apt-get install -y libgtk-4-dev libadwaita-1-dev build-essential
# Dependents track the highest supported Rust — build on stable so this job
# satisfies the highest dependent floor (the library's own lower MSRV, which
# the `test` job verifies, cannot compile them).
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## [1.7.6] — UNRELEASED
### Changed
- Version aligned to 1.7.6 for the unified release. No functional changes to this crate; the release is driven by the freemkv 1.7.6 Linux desktop shell (GTK4 + libadwaita) and the rip-finished desktop notification (issue #56).
## [1.7.5] — 2026-09-23
### Changed
Generated
+5 -5
View File
@@ -221,7 +221,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.7.4"
version = "1.7.6"
dependencies = [
"aes",
"base64",
@@ -317,7 +317,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfreemkv"
version = "1.7.4"
version = "1.7.6"
dependencies = [
"aes",
"base64",
@@ -839,12 +839,12 @@ dependencies = [
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.4"
version = "1.7.6"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.4"
version = "1.7.6"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.4"
version = "1.7.6"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.7.5"
version = "1.7.6"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -59,7 +59,7 @@ aes = { version = "0.9", optional = true }
# this to `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` in the tagged commit
# (so the released tag resolves freemkv-unlock from git, not a sibling path) and
# restores the path dep on the branch tip.
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.7.5", optional = true }
freemkv-unlock = { path = "../freemkv-unlock", optional = true }
zip = { version = "8", default-features = false, features = ["deflate"], optional = true }
base64 = { version = "0.23", optional = true }
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
+8 -5
View File
@@ -114,13 +114,16 @@ reverted in 0.13.20 — see the note at the top of `linux.rs`.)
The macOS backend uses a C shim (`macos_shim.c`) for IOKit exclusive access.
The shim handles:
1. `shim_open_exclusive(bsd_name)` — unmounts the target device via `diskutil`,
then walks the IOKit registry to find the `IOBDServices` matching the
requested BSD name (IOBDServices → IOBDBlockStorageDriver → IOMedia → "BSD Name"),
then creates MMCDeviceInterface → SCSITaskDeviceInterface → ObtainExclusiveAccess.
1. `shim_open_exclusive(selector)` — for `/dev/diskN`, unmounts the target via
`diskutil` and resolves its IOBDServices via the media BSD name. For an
empty drive's `ioreg:<registry-id>` selector, resolves IOBDServices directly
and skips unmount/Disk Arbitration because there is no media node. Both paths
create MMCDeviceInterface → SCSITaskDeviceInterface → ObtainExclusiveAccess.
2. `shim_list_drives()` — registry-based enumeration with zero SCSI, zero exclusive
access, zero unmounts. Reads IOBDServices "Device Characteristics" for
vendor/model/firmware and child IOMedia "BSD Name" for the device path.
vendor/model/firmware. Uses child IOMedia "BSD Name" when media exists;
otherwise returns an opaque IOKit registry-ID selector so empty drives remain
discoverable and openable.
3. `shim_execute()` / `shim_close()` — raw CDB dispatch and cleanup.
On non-zero SCSI status, the transport parses sense key from the sense buffer
+8 -5
View File
@@ -34,7 +34,11 @@ IOKit plugin chain failed" into the same.
## Drive enumeration and the media-presence probe
`list_drives` uses the IOKit registry directly via `shim_list_drives` —
no exclusive access, no SCSI commands, no unmounts.
no exclusive access, no SCSI commands, no unmounts. It enumerates
`IOBDServices` even when no `IOMedia` child exists. With media, the
returned path remains `/dev/diskN`; with an empty tray it uses the opaque
`ioreg:<registry-id>` selector so the service can still be opened directly.
That selector is an internal list-then-open handle, not a filesystem path.
`drive_has_disc` is documented (`crate::scsi::drive_has_disc`) as the
cheap, side-effect-free "is there a disc?" question, suitable for a
@@ -50,10 +54,9 @@ dropping) exclusive access each time.
Instead `drive_has_disc` answers via `shim_media_present`, the IOKit
registry only: steps 1-5 above are the *transport* open path, and this
probe must not run any of them. The registry answers the same question
with no side effect at all: the IOStorageFamily publishes an IOMedia
object for a removable device only while media is present and removes
it on eject, so a matching IOMedia is exactly "a disc is in the drive".
probe must not run any of them. For a BSD disk path, the registry looks
for its matching `IOMedia`; for an `ioreg:<id>` selector, it resolves the
drive service and checks whether that service currently has media.
No SCSI command is issued, which is why no timeout parameter is
involved.
+1 -38
View File
@@ -1,44 +1,7 @@
//! macOS drive discovery and device resolution.
//!
//! `find_drives` uses IOKit registry enumeration (via `scsi::list_drives`)
//! to discover optical drives without exclusive access or unmounts. Only
//! the returned paths are then opened for INQUIRY to build full `DriveId`.
//! macOS device resolution.
use crate::drive::DeviceResolution;
use crate::error::{Error, Result};
use crate::identity::DriveId;
/// SCSI peripheral device type 5 = MMC / optical, in the low 5 bits of
/// INQUIRY byte 0.
const SCSI_PERIPHERAL_TYPE_OPTICAL: u8 = 0x05;
/// Discover optical drives via the IOKit registry (`scsi::list_drives`),
/// then open each candidate for INQUIRY to build a full `DriveId`.
///
/// Any drive where `scsi::open` or `DriveId::from_drive` fails, or whose
/// peripheral device type is not optical (MMC, type 0x05), is silently
/// skipped — the same MMC filter the Linux and Windows backends apply.
pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
let discovered = crate::scsi::list_drives();
for info in discovered {
let path = std::path::Path::new(&info.path);
match crate::scsi::open(path) {
Ok(mut transport) => {
if let Ok(id) = DriveId::from_drive(transport.as_mut())
&& !id.raw_inquiry.is_empty()
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
{
drives.push((info.path.clone(), id));
}
}
Err(_) => {
continue;
}
}
}
drives
}
/// Resolve a device path on macOS. There is no `sr`→`sg` style
/// substitution here (that is a Linux concern), so any existing path is
+46 -9
View File
@@ -1252,11 +1252,53 @@ impl SectorSource for Drive {
/// drives without opening, use `scsi::list_drives()` instead.
/// See docs/drive-mod.md — find_drive selection policy.
pub fn find_drive() -> Option<Drive> {
select_drive_with_media(
discover_drives()
#[cfg(target_os = "macos")]
let candidates = crate::scsi::list_drives()
.into_iter()
.map(|info| info.path)
.collect::<Vec<_>>();
#[cfg(not(target_os = "macos"))]
let candidates = discover_drives()
.into_iter()
.map(|(path, _)| path)
.collect::<Vec<_>>();
let opened =
candidates
.into_iter()
.filter_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok()),
)
.filter_map(|path| match Drive::open(std::path::Path::new(&path)) {
Ok(drive)
if !drive.drive_id.raw_inquiry.is_empty()
&& (drive.drive_id.raw_inquiry[0] & 0x1F) == 0x05 =>
{
Some(drive)
}
Ok(drive) => {
tracing::debug!(
target: "freemkv::drive",
path,
"skipping non-optical SCSI device during drive selection"
);
drop(drive);
None
}
Err(error) => {
tracing::warn!(
target: "freemkv::drive",
path,
error = %error,
"drive open failed during autodetection"
);
None
}
});
select_drive_with_media(opened)
}
#[cfg(not(target_os = "macos"))]
fn discover_drives() -> Vec<(String, DriveId)> {
platform::find_drives()
}
// Pick a drive preferring DiscPresent status, falling back to the first
@@ -1334,11 +1376,6 @@ fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<(
}
}
/// Internal: discover drive paths + IDs without opening full Drive objects.
fn discover_drives() -> Vec<(String, DriveId)> {
platform::find_drives()
}
/// Structured outcome of [`resolve_device`] — a machine-readable signal
/// (no English prose) the application layer can render however it likes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+93 -16
View File
@@ -29,7 +29,7 @@ static OPEN: AtomicBool = AtomicBool::new(false);
#[repr(C)]
#[derive(Copy, Clone)]
struct ShimDriveInfo {
bsd_name: [u8; 32],
device_selector: [u8; 32],
vendor: [u8; 32],
model: [u8; 48],
firmware: [u8; 16],
@@ -66,6 +66,14 @@ fn bsd_name_of(device: &Path) -> Result<&str> {
.unwrap_or(dev_str))
}
fn device_path_for_selector(selector: &str) -> String {
if selector.starts_with("ioreg:") {
selector.to_string()
} else {
format!("/dev/{selector}")
}
}
// Maps a shim_open_exclusive failure sentinel (negative rc, not an
// IOReturn) to its typed Error variant, pulled out standalone so the
// mapping can be unit-tested. See docs/scsi-macos.md.
@@ -217,7 +225,7 @@ impl ScsiTransport for MacScsiTransport {
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let mut buf = [ShimDriveInfo {
bsd_name: [0; 32],
device_selector: [0; 32],
vendor: [0; 32],
model: [0; 48],
firmware: [0; 16],
@@ -225,20 +233,23 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let count = unsafe { shim_list_drives(buf.as_mut_ptr(), buf.len() as i32) };
let mut out = Vec::new();
for info in buf.iter().take((count as usize).min(buf.len())) {
let bsd_name = cstr_to_str(&info.bsd_name);
if bsd_name.is_empty() {
continue;
}
out.push(super::DriveInfo {
path: format!("/dev/{bsd_name}"),
vendor: cstr_to_str(&info.vendor).to_string(),
model: cstr_to_str(&info.model).to_string(),
firmware: cstr_to_str(&info.firmware).to_string(),
});
buf.iter()
.take((count as usize).min(buf.len()))
.filter_map(drive_info_from_shim)
.collect()
}
fn drive_info_from_shim(info: &ShimDriveInfo) -> Option<super::DriveInfo> {
let selector = cstr_to_str(&info.device_selector);
if selector.is_empty() {
return None;
}
out
Some(super::DriveInfo {
path: device_path_for_selector(selector),
vendor: cstr_to_str(&info.vendor).to_string(),
model: cstr_to_str(&info.model).to_string(),
firmware: cstr_to_str(&info.firmware).to_string(),
})
}
fn cstr_to_str(bytes: &[u8]) -> &str {
@@ -268,7 +279,8 @@ pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
#[cfg(test)]
mod tests {
use super::{
K_MAX_CDB_SIZE, OPEN, bsd_name_of, cstr_to_str, drive_has_disc, map_shim_open_error,
K_MAX_CDB_SIZE, OPEN, ShimDriveInfo, bsd_name_of, cstr_to_str, device_path_for_selector,
drive_has_disc, drive_info_from_shim, map_shim_open_error,
};
use crate::error::Error;
use std::path::Path;
@@ -279,6 +291,71 @@ mod tests {
assert_eq!(bsd_name_of(Path::new("/dev/disk4")).unwrap(), "disk4");
assert_eq!(bsd_name_of(Path::new("/dev/rdisk4")).unwrap(), "disk4");
assert_eq!(bsd_name_of(Path::new("disk4")).unwrap(), "disk4");
assert_eq!(
bsd_name_of(Path::new("ioreg:4294967295")).unwrap(),
"ioreg:4294967295"
);
}
#[test]
fn list_path_preserves_registry_selector_and_uses_dev_for_bsd_name() {
assert_eq!(device_path_for_selector("disk4"), "/dev/disk4");
assert_eq!(device_path_for_selector("disk0"), "/dev/disk0");
assert_eq!(
device_path_for_selector("ioreg:4294967295"),
"ioreg:4294967295"
);
// Registry IDs are uint64 values and the shim emits the complete
// decimal value into its fixed-width selector field.
assert_eq!(
device_path_for_selector("ioreg:18446744073709551615"),
"ioreg:18446744073709551615"
);
}
fn shim_drive(selector: &[u8], vendor: &[u8], model: &[u8], firmware: &[u8]) -> ShimDriveInfo {
let mut info = ShimDriveInfo {
device_selector: [0; 32],
vendor: [0; 32],
model: [0; 48],
firmware: [0; 16],
};
info.device_selector[..selector.len()].copy_from_slice(selector);
info.vendor[..vendor.len()].copy_from_slice(vendor);
info.model[..model.len()].copy_from_slice(model);
info.firmware[..firmware.len()].copy_from_slice(firmware);
info
}
#[test]
fn shim_drive_record_maps_bsd_selector_and_identity_fields() {
let info = shim_drive(b"disk4", b"HL-DT-ST", b"BD-RE BU40N", b"1.03");
let mapped = drive_info_from_shim(&info).expect("non-empty selector");
assert_eq!(mapped.path, "/dev/disk4");
assert_eq!(mapped.vendor, "HL-DT-ST");
assert_eq!(mapped.model, "BD-RE BU40N");
assert_eq!(mapped.firmware, "1.03");
}
#[test]
fn shim_drive_record_keeps_empty_tray_registry_selector() {
let info = shim_drive(
b"ioreg:18446744073709551615",
b"HL-DT-ST",
b"BD-RE BU40N",
b"1.03",
);
let mapped = drive_info_from_shim(&info).expect("registry selector");
assert_eq!(mapped.path, "ioreg:18446744073709551615");
assert_eq!(mapped.vendor, "HL-DT-ST");
assert_eq!(mapped.model, "BD-RE BU40N");
assert_eq!(mapped.firmware, "1.03");
}
#[test]
fn shim_drive_record_with_empty_selector_is_skipped() {
let info = shim_drive(b"", b"HL-DT-ST", b"BD-RE BU40N", b"1.03");
assert!(drive_info_from_shim(&info).is_none());
}
// Shim fixed-width fields are NUL-terminated C strings with garbage
+99 -14
View File
@@ -12,6 +12,10 @@
#include <sys/wait.h>
#include <fcntl.h>
#include <signal.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>
#include <errno.h>
extern char **environ;
@@ -32,7 +36,7 @@ typedef struct {
} ShimHandle;
typedef struct {
char bsd_name[32];
char device_selector[32];
char vendor[32];
char model[48];
char firmware[16];
@@ -77,6 +81,54 @@ static int registry_entry_bsd_name(io_registry_entry_t entry, char *buf, size_t
return ok;
}
// Empty optical drives have an IOBDServices node but no IOMedia node, hence
// no BSD diskN name. Use the service's IOKit registry ID as an opaque selector
// until media appears. It is process/boot-local, which is sufficient for the
// list-then-open GUI flow.
static int registry_id_selector(io_registry_entry_t entry, char *buf, size_t buflen) {
uint64_t registry_id = 0;
if (IORegistryEntryGetRegistryEntryID(entry, &registry_id) != KERN_SUCCESS) return 0;
int n = snprintf(buf, buflen, "ioreg:%" PRIu64, registry_id);
return n > 0 && (size_t)n < buflen;
}
static int parse_registry_id_selector(const char *selector, uint64_t *registry_id) {
static const char prefix[] = "ioreg:";
if (strncmp(selector, prefix, sizeof(prefix) - 1) != 0) return 0;
const char *digits = selector + sizeof(prefix) - 1;
if (!*digits) return 0;
for (const char *p = digits; *p; p++) {
if (*p < '0' || *p > '9') return 0;
}
errno = 0;
char *end = NULL;
unsigned long long parsed = strtoull(digits, &end, 10);
if (errno == ERANGE || !end || *end || end == digits) return 0;
*registry_id = (uint64_t)parsed;
return 1;
}
static io_service_t find_bdsvc_by_registry_id(mach_port_t mp, uint64_t registry_id) {
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
if (!matching) return 0;
io_iterator_t iter;
if (IOServiceGetMatchingServices(mp, matching, &iter) != KERN_SUCCESS) return 0;
io_service_t result = 0;
io_service_t svc;
while ((svc = IOIteratorNext(iter)) != 0) {
uint64_t candidate = 0;
if (IORegistryEntryGetRegistryEntryID(svc, &candidate) == KERN_SUCCESS
&& candidate == registry_id) {
result = svc;
break;
}
IOObjectRelease(svc);
}
while ((svc = IOIteratorNext(iter)) != 0) IOObjectRelease(svc);
IOObjectRelease(iter);
return result;
}
static io_registry_entry_t find_iomedia_child(io_registry_entry_t parent) {
io_iterator_t iter;
kern_return_t kr = IORegistryEntryGetChildIterator(parent, kIOServicePlane, &iter);
@@ -372,7 +424,7 @@ static void da_release(void) {
// ── Public API ────────────────────────────────────────────────────────────
int shim_open_exclusive(const char *bsd_name) {
int shim_open_exclusive(const char *selector) {
kern_return_t kr;
HRESULT hr;
SInt32 score = 0;
@@ -387,6 +439,28 @@ int shim_open_exclusive(const char *bsd_name) {
return 0;
}
uint64_t registry_id = 0;
int is_registry_selector = parse_registry_id_selector(selector, &registry_id);
char bsd_name[32] = {0};
io_service_t svc = 0;
mach_port_t mp = MACH_PORT_NULL;
if (is_registry_selector) {
if (IOMainPort(0, &mp) != kIOReturnSuccess) {
pthread_mutex_unlock(&g_handle_lock);
return -1;
}
svc = find_bdsvc_by_registry_id(mp, registry_id);
if (!svc) {
pthread_mutex_unlock(&g_handle_lock);
return -1;
}
// If media is present, retain the old unmount/claim behavior. With an
// empty tray there is no BSD disk to unmount or hand to DiskArbitration.
bdsvc_to_bsd_name(svc, bsd_name, sizeof(bsd_name));
} else {
strlcpy(bsd_name, selector, sizeof(bsd_name));
}
// Unmount via diskutil, invoked directly with posix_spawn (no shell) so
// the BSD device name can never be interpreted as shell syntax. A shell
// wrapper here (system()/sh -c) was a command-injection vector for an
@@ -394,13 +468,13 @@ int shim_open_exclusive(const char *bsd_name) {
// argv element also sidesteps the old buffer-truncation concern entirely.
// stdout/stderr go to /dev/null to keep diskutil chatter out of the
// caller's streams.
{
if (bsd_name[0]) {
posix_spawn_file_actions_t fa;
posix_spawn_file_actions_init(&fa);
posix_spawn_file_actions_addopen(&fa, STDOUT_FILENO, "/dev/null", O_WRONLY, 0);
posix_spawn_file_actions_addopen(&fa, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
char *const argv[] = {
"diskutil", "unmountDisk", "force", (char *)bsd_name, NULL
"diskutil", "unmountDisk", "force", bsd_name, NULL
};
pid_t pid;
if (posix_spawn(&pid, "/usr/sbin/diskutil", &fa, NULL, argv, environ) == 0) {
@@ -442,19 +516,18 @@ int shim_open_exclusive(const char *bsd_name) {
}
posix_spawn_file_actions_destroy(&fa);
}
usleep(500000);
if (bsd_name[0]) usleep(500000);
mach_port_t mp;
// Check the return before using the port. On failure IOMainPort leaves `mp`
// untouched, so every IOKit call below would run against an uninitialised
// mach port. shim_list_drives does check it; this path did not.
if (IOMainPort(0, &mp) != kIOReturnSuccess) {
if (mp == MACH_PORT_NULL && IOMainPort(0, &mp) != kIOReturnSuccess) {
pthread_mutex_unlock(&g_handle_lock);
return -1;
}
io_service_t svc = find_bdsvc_by_bsd_name(mp, bsd_name);
if (!svc) {
if (!svc) svc = find_bdsvc_by_bsd_name(mp, bsd_name);
if (!svc && !is_registry_selector) {
svc = find_bdsvc_from_iomedia(mp, bsd_name);
}
if (!svc) {
@@ -552,7 +625,7 @@ int shim_open_exclusive(const char *bsd_name) {
// allocations fails it returns early (0 → no claim). The lock is deliberately
// NOT held across da_hold's ~5 s self-locking claim wait, which would
// serialize every open behind it.
da_hold(bsd_name);
if (bsd_name[0]) da_hold(bsd_name);
return 0;
}
@@ -639,10 +712,20 @@ int shim_execute(const unsigned char *cdb, unsigned char cdb_len,
// IOMedia whose "BSD Name" is the requested device IS the presence of a disc.
//
// Returns 1 (media present), 0 (no media), or -1 (IOKit unavailable).
int shim_media_present(const char *bsd_name) {
int shim_media_present(const char *selector) {
mach_port_t mp;
if (IOMainPort(0, &mp) != kIOReturnSuccess) return -1;
uint64_t registry_id = 0;
if (parse_registry_id_selector(selector, &registry_id)) {
io_service_t svc = find_bdsvc_by_registry_id(mp, registry_id);
if (!svc) return 0;
char bsd_name[32] = {0};
int has_media = bdsvc_to_bsd_name(svc, bsd_name, sizeof(bsd_name));
IOObjectRelease(svc);
return has_media && bsd_name[0] ? 1 : 0;
}
CFMutableDictionaryRef matching = IOServiceMatching("IOMedia");
if (!matching) return -1;
@@ -655,7 +738,7 @@ int shim_media_present(const char *bsd_name) {
while ((media = IOIteratorNext(iter)) != 0) {
char name[64];
if (registry_entry_bsd_name(media, name, sizeof(name))
&& strcmp(name, bsd_name) == 0)
&& strcmp(name, selector) == 0)
{
found = 1;
}
@@ -701,9 +784,11 @@ int shim_list_drives(ShimDriveInfo *out, int max_entries) {
memset(info, 0, sizeof(*info));
bdsvc_device_info(svc, info);
bdsvc_to_bsd_name(svc, info->bsd_name, sizeof(info->bsd_name));
if (!bdsvc_to_bsd_name(svc, info->device_selector, sizeof(info->device_selector))) {
registry_id_selector(svc, info->device_selector, sizeof(info->device_selector));
}
if (info->bsd_name[0]) {
if (info->device_selector[0]) {
count++;
}
+3 -2
View File
@@ -462,8 +462,9 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
/// no firmware reset, no init.
#[derive(Debug, Clone)]
pub struct DriveInfo {
/// Platform device path: `/dev/sgN` (Linux), `/dev/diskN` (macOS),
/// `\\.\CdRomN` (Windows).
/// Platform device path: `/dev/sgN` (Linux), `/dev/diskN` or an opaque
/// `ioreg:<id>` selector for an empty optical drive (macOS), `\\.\CdRomN`
/// (Windows).
pub path: String,
/// SCSI INQUIRY vendor identifier (e.g. `"HL-DT-ST"`).
pub vendor: String,
+327
View File
@@ -0,0 +1,327 @@
//! Opt-in checks against an attached macOS optical drive.
//!
//! Run with:
//! `cargo test --test macos_drive_tests -- --ignored --nocapture`
#![cfg(target_os = "macos")]
use libfreemkv::{Drive, DriveStatus, list_drives, scsi::drive_has_disc};
use std::path::Path;
use std::path::PathBuf;
fn drive_name(drive: &libfreemkv::DriveInfo) -> String {
format!("{} {}", drive.vendor.trim(), drive.model.trim())
.trim()
.to_string()
}
/// Exercises the empty-tray path end to end: IOKit enumeration must retain an
/// IOBDServices drive without IOMedia, and the opaque selector must still open
/// that exact service and issue real SCSI commands.
#[test]
#[ignore = "requires an attached optical drive with an empty tray"]
fn empty_tray_drive_is_enumerated_opened_and_reports_no_disc() {
let drives = list_drives();
assert!(!drives.is_empty(), "no optical drive enumerated");
let empty = drives
.iter()
.find(|drive| matches!(drive_has_disc(Path::new(&drive.path)), Ok(false)))
.unwrap_or_else(|| {
panic!(
"no empty optical drive found among {} drive(s)",
drives.len()
)
});
assert!(
empty.path.starts_with("ioreg:"),
"empty-tray path should use its IOKit service selector, got {:?}",
empty.path
);
let mut drive = Drive::open(Path::new(&empty.path)).unwrap_or_else(|e| {
panic!(
"failed to open {} ({}) with empty tray: {e}",
drive_name(empty),
empty.path
)
});
assert_eq!(
drive.drive_id.raw_inquiry.first().map(|byte| byte & 0x1f),
Some(0x05),
"opened service must identify as an MMC optical drive"
);
assert_eq!(
drive.drive_status(),
DriveStatus::NoDisc,
"an empty tray should report NoDisc after a real SCSI status query"
);
// Opening the selected drive through the same session path as the GUI must
// reach the drive and fail because there is no medium, not because the
// optical drive was undiscoverable. Drop the first handle before reopening.
drop(drive);
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let mut session = DiscSession::open(
DeviceTarget::Path(PathBuf::from(&empty.path)),
KeySpec::default(),
)
.unwrap_or_else(|e| panic!("failed to create empty-tray session: {e}"));
assert!(
session.scan(ScanOptions::default()).is_err(),
"scanning an empty tray must fail as no media, not report an empty drive as a disc"
);
}
/// Reads real filesystem/title metadata from a disc, without muxing or saving
/// its content. Run only after inserting a retail Blu-ray or DVD.
#[test]
#[ignore = "requires an inserted Blu-ray/DVD; scan reads disc metadata"]
fn inserted_disc_scans_filesystem_and_finds_titles() {
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let drives = list_drives();
let media_checks: Vec<_> = drives
.iter()
.map(|drive| {
(
drive.path.as_str(),
drive.vendor.as_str(),
drive.model.as_str(),
drive_has_disc(Path::new(&drive.path)),
)
})
.collect();
let drive = drives
.iter()
.find(|drive| matches!(drive_has_disc(Path::new(&drive.path)), Ok(true)))
.unwrap_or_else(|| {
panic!("no optical drive with media detected; probes: {media_checks:?}")
});
println!(
"scanning {} ({}) {}",
drive.path,
drive_name(drive),
if drive.path.starts_with("ioreg:") {
"[IOKit service selector]"
} else {
""
}
);
let mut session = DiscSession::open(
DeviceTarget::Path(PathBuf::from(&drive.path)),
KeySpec::default(),
)
.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.path
)
});
assert!(
!disc.titles.is_empty(),
"disc scan completed but found no titles on {} ({})",
drive_name(drive),
drive.path
);
}
/// Exercises the autodetect route used by bare `disc://` sources. Keeping this
/// separate from the explicit-path test catches discovery/open mismatches.
#[test]
#[ignore = "requires an inserted Blu-ray/DVD; opens the first detected drive"]
fn inserted_disc_autodetects_and_scans_titles() {
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!("autodetected drive failed to scan the disc: {e}"));
assert!(
!disc.titles.is_empty(),
"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),
}
}
}
+85
View File
@@ -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();
}