mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
10
Commits
v1.7.5
...
v1.7.6-rc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
584cc6030b | ||
|
|
7257559153 | ||
|
|
8949b81dfc | ||
|
|
7bcfdeda32 | ||
|
|
89be65a3d4 | ||
|
|
f21ab37920 | ||
|
|
0c28e569a8 | ||
|
|
0d9565eac9 | ||
|
|
b52e3c8292 | ||
|
|
d4510392f6 |
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, ®istry_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, ®istry_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, ®istry_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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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