use crate::block::{BlockDevice, BlockRead};
use crate::error::{Error, Result};
use std::fs::{File, OpenOptions};
use std::io::{Seek, SeekFrom, Write};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::RwLock;
pub struct FileDevice {
file: File,
io_lock: RwLock<()>,
size: AtomicU64,
writable: bool,
growable: bool,
#[cfg(test)]
arrivals: LockArrivals,
}
#[cfg(test)]
#[derive(Default)]
struct LockArrivals {
waiting: std::sync::atomic::AtomicUsize,
}
#[cfg(test)]
struct Arrival<'a>(&'a LockArrivals);
#[cfg(test)]
impl Drop for Arrival<'_> {
fn drop(&mut self) {
self.0
.waiting
.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
}
impl FileDevice {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let file = File::open(path)?;
let size = measure_size(&file)?;
Ok(Self {
file,
io_lock: RwLock::new(()),
size: AtomicU64::new(size),
writable: false,
growable: false,
#[cfg(test)]
arrivals: LockArrivals::default(),
})
}
pub fn open_rw<P: AsRef<Path>>(path: P) -> Result<Self> {
let file = OpenOptions::new().read(true).write(true).open(path)?;
let size = measure_size(&file)?;
let growable = is_regular_file(&file);
Ok(Self {
file,
io_lock: RwLock::new(()),
size: AtomicU64::new(size),
writable: true,
growable,
#[cfg(test)]
arrivals: LockArrivals::default(),
})
}
pub fn open_best_effort<P: AsRef<Path>>(path: P) -> Result<Self> {
let p = path.as_ref();
match Self::open_rw(p) {
Ok(d) => Ok(d),
Err(_) => Self::open(p),
}
}
}
#[cfg(unix)]
fn measure_size(file: &File) -> Result<u64> {
use std::os::unix::fs::FileTypeExt;
let meta = file.metadata()?;
let ft = meta.file_type();
if !ft.is_block_device() && !ft.is_char_device() {
return Ok(meta.len());
}
device_size_bytes(file)
}
#[cfg(not(unix))]
fn measure_size(file: &File) -> Result<u64> {
Ok(file.metadata()?.len())
}
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "linux"))]
unsafe extern "C" {
fn ioctl(fd: std::os::raw::c_int, request: std::os::raw::c_ulong, ...) -> std::os::raw::c_int;
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
fn device_size_bytes(file: &File) -> Result<u64> {
use std::io;
use std::os::fd::AsRawFd;
const DKIOCGETBLOCKSIZE: std::os::raw::c_ulong = 0x4004_6418;
const DKIOCGETBLOCKCOUNT: std::os::raw::c_ulong = 0x4008_6419;
let fd = file.as_raw_fd();
let mut block_size: u32 = 0;
let mut block_count: u64 = 0;
unsafe {
if ioctl(fd, DKIOCGETBLOCKSIZE, &raw mut block_size) < 0 {
return Err(io::Error::last_os_error().into());
}
if ioctl(fd, DKIOCGETBLOCKCOUNT, &raw mut block_count) < 0 {
return Err(io::Error::last_os_error().into());
}
}
block_count
.checked_mul(u64::from(block_size))
.ok_or_else(|| {
Error::Io(io::Error::other(format!(
"device reports {block_count} blocks of {block_size} bytes, \
whose product does not fit in u64"
)))
})
}
#[cfg(any(target_os = "linux", test))]
const fn blkgetsize64_for(pointer_width: usize) -> std::os::raw::c_ulong {
const READ: std::os::raw::c_ulong = 0x8000_0000;
const TYPE: std::os::raw::c_ulong = 0x12;
const NR: std::os::raw::c_ulong = 114;
READ | ((pointer_width as std::os::raw::c_ulong) << 16) | (TYPE << 8) | NR
}
#[cfg(target_os = "linux")]
fn device_size_bytes(file: &File) -> Result<u64> {
use std::io;
use std::os::fd::AsRawFd;
const BLKGETSIZE64: std::os::raw::c_ulong = blkgetsize64_for(std::mem::size_of::<usize>());
let mut size: u64 = 0;
let rc = unsafe { ioctl(file.as_raw_fd(), BLKGETSIZE64, &raw mut size) };
if rc < 0 {
return Err(io::Error::last_os_error().into());
}
Ok(size)
}
#[cfg(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "linux"))
))]
fn device_size_bytes(_file: &File) -> Result<u64> {
use std::io;
Err(Error::Io(io::Error::other(
"no measured way to read a device node's size on this platform; \
open the backing image file rather than the device node",
)))
}
impl FileDevice {
#[cfg(test)]
fn arriving(&self) -> Arrival<'_> {
self.arrivals
.waiting
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Arrival(&self.arrivals)
}
#[cfg(unix)]
fn shared_guard(&self) -> std::sync::RwLockReadGuard<'_, ()> {
#[cfg(test)]
let _arrival = self.arriving();
self.io_lock.read().unwrap()
}
fn exclusive_guard(&self) -> std::sync::RwLockWriteGuard<'_, ()> {
#[cfg(test)]
let _arrival = self.arriving();
self.io_lock.write().unwrap()
}
#[cfg(unix)]
fn read_guard(&self) -> std::sync::RwLockReadGuard<'_, ()> {
self.shared_guard()
}
#[cfg(windows)]
fn read_guard(&self) -> std::sync::RwLockWriteGuard<'_, ()> {
self.exclusive_guard()
}
#[cfg(unix)]
fn read_once(&self, offset: u64, buf: &mut [u8]) -> Result<usize> {
use std::os::unix::fs::FileExt;
Ok(self.file.read_at(buf, offset)?)
}
#[cfg(windows)]
fn read_once(&self, offset: u64, buf: &mut [u8]) -> Result<usize> {
use std::os::windows::fs::FileExt;
Ok(self.file.seek_read(buf, offset)?)
}
}
impl BlockRead for FileDevice {
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
let _guard = self.read_guard();
let mut total = 0usize;
while total < buf.len() {
let n = self.read_once(offset + total as u64, &mut buf[total..])?;
if n == 0 {
return Err(Error::ShortRead {
offset,
want: buf.len(),
got: total,
});
}
total += n;
}
Ok(())
}
fn size_bytes(&self) -> u64 {
self.size.load(Ordering::Acquire)
}
}
impl BlockDevice for FileDevice {
fn write_at(&self, offset: u64, buf: &[u8]) -> Result<()> {
if !self.writable {
return Err(Error::ReadOnly);
}
let size = self.size_bytes();
let end = offset.checked_add(buf.len() as u64);
if end.is_none_or(|end| end > size) {
return Err(Error::OutOfBounds {
offset,
len: buf.len() as u64,
size,
});
}
let _guard = self.exclusive_guard();
let mut f = &self.file;
f.seek(SeekFrom::Start(offset))?;
f.write_all(buf)?;
Ok(())
}
fn flush(&self) -> Result<()> {
if !self.writable {
return Ok(());
}
let _guard = self.exclusive_guard();
let mut f = &self.file;
f.flush()?;
self.file.sync_data()?;
Ok(())
}
fn is_writable(&self) -> bool {
self.writable
}
fn set_len(&self, new_len: u64) -> Result<()> {
if !self.writable {
return Err(Error::ReadOnly);
}
if !self.growable {
return Err(Error::Custom(
"this FileDevice is open on something that is not a regular file \
-- a device node's length is the kernel's, not ours, and cannot \
be set through this handle"
.to_string(),
));
}
let _guard = self.exclusive_guard();
let old = self.size.load(Ordering::Acquire);
if new_len < old {
self.size.store(new_len, Ordering::Release);
}
if let Err(e) = self.file.set_len(new_len) {
if let Ok(actual) = measure_size(&self.file) {
self.size.store(actual, Ordering::Release);
}
return Err(e.into());
}
self.size.store(new_len, Ordering::Release);
Ok(())
}
fn can_grow(&self) -> bool {
self.growable
}
}
fn is_regular_file(file: &File) -> bool {
file.metadata().is_ok_and(|m| m.file_type().is_file())
}
#[cfg(test)]
mod tests {
#[test]
fn blkgetsize64_is_encoded_for_the_pointer_width() {
const KNOWN: &[(usize, u64)] = &[(4, 0x8004_1272), (8, 0x8008_1272)];
for (width, want) in KNOWN {
assert_eq!(
super::blkgetsize64_for(*width) as u64,
*want,
"_IOR(0x12, 114, size_t) with a {width}-byte size_t is {want:#010x}"
);
}
}
#[test]
fn this_target_issues_its_own_encoding() {
let width = std::mem::size_of::<usize>();
let expected = if width == 8 {
0x8008_1272u64
} else {
0x8004_1272u64
};
assert_eq!(super::blkgetsize64_for(width) as u64, expected);
}
use super::*;
#[test]
fn many_threads_reading_different_offsets_each_get_their_own_bytes() {
let path = temp_path("parallel_reads");
let _c = Cleanup(path.clone());
let mut bytes = Vec::with_capacity(64 * 256);
for page in 0..64u8 {
bytes.extend(std::iter::repeat_n(page, 256));
}
std::fs::write(&path, &bytes).expect("write the image");
let dev = std::sync::Arc::new(FileDevice::open(&path).expect("open"));
let mut handles = Vec::new();
for page in 0..64u8 {
let dev = dev.clone();
handles.push(std::thread::spawn(move || {
for _ in 0..50 {
let mut buf = [0u8; 256];
dev.read_at(u64::from(page) * 256, &mut buf).expect("read");
assert!(
buf.iter().all(|b| *b == page),
"page {page} came back holding another page's bytes"
);
}
}));
}
for h in handles {
h.join().expect("a reader panicked");
}
}
use std::sync::atomic::{AtomicU64, Ordering};
fn temp_path(tag: &str) -> std::path::PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("fs_core_{tag}_{pid}_{n}.bin"))
}
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[test]
fn open_rw_round_trips_write_then_read() {
let path = temp_path("rw");
let _g = Cleanup(path.clone());
std::fs::write(&path, vec![0u8; 32]).unwrap();
let dev = FileDevice::open_rw(&path).unwrap();
assert!(dev.is_writable());
assert_eq!(dev.size_bytes(), 32);
dev.write_at(8, &[0xAA, 0xBB, 0xCC, 0xDD]).unwrap();
dev.flush().unwrap();
let mut buf = [0u8; 4];
dev.read_at(8, &mut buf).unwrap();
assert_eq!(buf, [0xAA, 0xBB, 0xCC, 0xDD]);
}
#[test]
fn open_rw_errors_on_missing_path() {
let path = temp_path("missing");
assert!(FileDevice::open_rw(&path).is_err());
}
#[test]
fn open_best_effort_uses_rw_when_writable() {
let path = temp_path("best_rw");
let _g = Cleanup(path.clone());
std::fs::write(&path, vec![0u8; 16]).unwrap();
let dev = FileDevice::open_best_effort(&path).unwrap();
assert!(dev.is_writable());
dev.write_at(0, &[0x11; 4]).unwrap();
}
#[test]
#[cfg(unix)]
fn open_best_effort_falls_back_to_read_only() {
use std::os::unix::fs::PermissionsExt;
let path = temp_path("best_ro");
let _g = Cleanup(path.clone());
std::fs::write(&path, vec![0xEFu8; 16]).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
let dev = FileDevice::open_best_effort(&path).unwrap();
assert!(!dev.is_writable());
assert!(matches!(dev.write_at(0, &[0u8; 4]), Err(Error::ReadOnly)));
let mut buf = [0u8; 4];
dev.read_at(0, &mut buf).unwrap();
assert_eq!(buf, [0xEF; 4]);
dev.flush().unwrap();
}
use std::sync::mpsc;
use std::time::Duration;
const UNBLOCKED_WITHIN: Duration = Duration::from_secs(10);
fn rw_image(tag: &str, fill: u8) -> (std::sync::Arc<FileDevice>, Cleanup) {
let path = temp_path(tag);
let cleanup = Cleanup(path.clone());
std::fs::write(&path, vec![fill; 4096]).expect("write the image");
let dev = std::sync::Arc::new(FileDevice::open_rw(&path).expect("open rw"));
(dev, cleanup)
}
fn waiting_at_the_lock(dev: &FileDevice) -> usize {
dev.arrivals.waiting.load(Ordering::SeqCst)
}
fn await_arrival(dev: &FileDevice, operation: &str) {
let deadline = std::time::Instant::now() + UNBLOCKED_WITHIN;
while waiting_at_the_lock(dev) == 0 {
assert!(
std::time::Instant::now() < deadline,
"{operation} never reached io_lock. It either never ran, or it \
does not take the lock at all — which is the exclusion this \
test exists to assert"
);
std::thread::sleep(Duration::from_micros(200));
}
}
struct ReleaseThenJoin<G> {
held: Option<G>,
worker: Option<std::thread::JoinHandle<()>>,
}
impl<G> ReleaseThenJoin<G> {
fn release(&mut self) {
self.held = None;
}
}
impl<G> Drop for ReleaseThenJoin<G> {
fn drop(&mut self) {
self.held = None;
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
#[test]
fn a_panicking_assertion_joins_the_worker_before_the_cleanup_runs() {
use std::panic::AssertUnwindSafe;
use std::sync::{Arc, Mutex};
struct Recorder(Arc<Mutex<Vec<&'static str>>>);
impl Drop for Recorder {
fn drop(&mut self) {
self.0.lock().unwrap().push("cleanup");
}
}
let order: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
let (dev, _c) = rw_image("panic_joins", 0x99);
let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| {
let _recorder = Recorder(Arc::clone(&order));
let held = dev.io_lock.write().unwrap();
let worker = {
let dev = Arc::clone(&dev);
let order = Arc::clone(&order);
std::thread::spawn(move || {
struct Ended(Arc<Mutex<Vec<&'static str>>>);
impl Drop for Ended {
fn drop(&mut self) {
self.0.lock().unwrap().push("worker");
}
}
let _ended = Ended(order);
let mut buf = [0u8; 4096];
let _ = dev.read_at(0, &mut buf);
})
};
let _lock = ReleaseThenJoin {
held: Some(held),
worker: Some(worker),
};
await_arrival(&dev, "read_at");
panic!("the assertion an exclusion test exists to make, failing");
}));
let payload = outcome.expect_err("the deliberate panic must have unwound");
let message = payload
.downcast_ref::<&str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("<panic payload was not a string>");
assert!(
message.contains("an exclusion test exists to make"),
"the test unwound for the wrong reason, so the ordering below is \
about some other failure: {message}"
);
assert_eq!(
*order.lock().unwrap(),
vec!["worker", "cleanup"],
"the worker was still inside read_at when cleanup ran: an unwinding \
test must release the guard, join the worker, and only then let the \
temp file be removed"
);
}
#[test]
fn an_uncontended_operation_leaves_no_thread_waiting_at_the_lock() {
let (dev, _c) = rw_image("arrivals_settle", 0x0F);
assert_eq!(waiting_at_the_lock(&dev), 0, "nothing has run yet");
let mut buf = [0u8; 16];
dev.read_at(0, &mut buf).expect("read");
assert_eq!(
waiting_at_the_lock(&dev),
0,
"read_at left an arrival behind"
);
dev.write_at(0, &[0x10u8; 16]).expect("write");
assert_eq!(
waiting_at_the_lock(&dev),
0,
"write_at left an arrival behind"
);
dev.flush().expect("flush");
assert_eq!(waiting_at_the_lock(&dev), 0, "flush left an arrival behind");
}
#[test]
fn a_read_cannot_proceed_while_a_write_holds_the_lock() {
let (dev, _c) = rw_image("read_excluded_by_write", 0x5A);
let held = dev.io_lock.write().unwrap();
assert_eq!(
waiting_at_the_lock(&dev),
0,
"the holder must not count as a waiter, or the wait below proves nothing"
);
let (done_tx, done_rx) = mpsc::channel();
let worker = {
let dev = std::sync::Arc::clone(&dev);
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
let outcome = dev.read_at(0, &mut buf);
let _ = done_tx.send(outcome.map(|()| buf[0]));
})
};
let mut lock = ReleaseThenJoin {
held: Some(held),
worker: Some(worker),
};
await_arrival(&dev, "read_at");
assert!(
matches!(done_rx.try_recv(), Err(mpsc::TryRecvError::Empty)),
"a read completed while the exclusive write guard was held. Reads and \
writes are not mutually excluded, so a read overlapping a write_at \
can observe a partially written region"
);
lock.release();
let first = done_rx
.recv_timeout(UNBLOCKED_WITHIN)
.expect("the read must proceed once the write guard is released")
.expect("and must succeed");
assert_eq!(first, 0x5A, "the read returned the wrong bytes");
}
#[test]
fn a_write_cannot_proceed_while_a_read_holds_the_lock() {
let (dev, _c) = rw_image("write_excluded_by_read", 0x11);
let held = dev.io_lock.read().unwrap();
assert_eq!(waiting_at_the_lock(&dev), 0, "the holder is not a waiter");
let (done_tx, done_rx) = mpsc::channel();
let worker = {
let dev = std::sync::Arc::clone(&dev);
std::thread::spawn(move || {
let _ = done_tx.send(dev.write_at(0, &[0x22u8; 4096]));
})
};
let mut lock = ReleaseThenJoin {
held: Some(held),
worker: Some(worker),
};
await_arrival(&dev, "write_at");
assert!(
matches!(done_rx.try_recv(), Err(mpsc::TryRecvError::Empty)),
"a write completed while a read guard was held; a write_at may not \
run through a read that is already in progress"
);
lock.release();
done_rx
.recv_timeout(UNBLOCKED_WITHIN)
.expect("the write must proceed once the read releases")
.expect("and must succeed");
let mut buf = [0u8; 4];
dev.read_at(0, &mut buf).expect("read back");
assert_eq!(buf, [0x22; 4], "the write did not land");
}
#[test]
fn a_flush_cannot_proceed_while_a_read_holds_the_lock() {
let (dev, _c) = rw_image("flush_excluded_by_read", 0x33);
let held = dev.io_lock.read().unwrap();
assert_eq!(waiting_at_the_lock(&dev), 0, "the holder is not a waiter");
let (done_tx, done_rx) = mpsc::channel();
let worker = {
let dev = std::sync::Arc::clone(&dev);
std::thread::spawn(move || {
let _ = done_tx.send(dev.flush());
})
};
let mut lock = ReleaseThenJoin {
held: Some(held),
worker: Some(worker),
};
await_arrival(&dev, "flush");
assert!(
matches!(done_rx.try_recv(), Err(mpsc::TryRecvError::Empty)),
"a flush completed while a read guard was held; flush takes the lock \
exclusively for the same reason write_at does"
);
lock.release();
done_rx
.recv_timeout(UNBLOCKED_WITHIN)
.expect("the flush must proceed once the read releases")
.expect("and must succeed");
}
#[test]
#[cfg(unix)]
fn a_read_does_not_exclude_another_read() {
let (dev, _c) = rw_image("reads_overlap", 0x77);
let held = dev.io_lock.read().unwrap();
let (done_tx, done_rx) = mpsc::channel();
let worker = {
let dev = std::sync::Arc::clone(&dev);
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
let outcome = dev.read_at(0, &mut buf);
let _ = done_tx.send(outcome.map(|()| buf[0]));
})
};
let lock = ReleaseThenJoin {
held: Some(held),
worker: Some(worker),
};
let first = done_rx
.recv_timeout(UNBLOCKED_WITHIN)
.expect(
"a read blocked behind another read. On Unix the guard must be \
shared -- positioned reads need no cursor, and serialising them \
undoes the parallelism the read path was rewritten for",
)
.expect("and the read must succeed");
assert_eq!(first, 0x77);
drop(lock);
}
}