diff --git a/src/io/mod.rs b/src/io/mod.rs index f4b7f9b..7c3878e 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -26,6 +26,7 @@ pub(crate) mod bounded; pub mod byte_channel; pub mod file_sector_source; +pub mod sink; mod writeback; mod writeback_file; diff --git a/src/io/sink/local_file.rs b/src/io/sink/local_file.rs new file mode 100644 index 0000000..5eef032 --- /dev/null +++ b/src/io/sink/local_file.rs @@ -0,0 +1,162 @@ +//! `LocalFileSink` — `BufWriter` for the common local-disk case. +//! +//! Buffering: 4 MiB internal `BufWriter`. Sized to coalesce the small +//! per-PES writes that come out of the muxer into kernel-page-aligned +//! flushes without making the buffer big enough to matter for memory +//! pressure on a single concurrent rip. +//! +//! `Seek` flushes the underlying `BufWriter` first; otherwise a seek +//! could leapfrog buffered data and silently corrupt the file. This is +//! the same shape `BufWriter` itself uses when it impls `Seek` in +//! stdlib, and is necessary for MKV's seek-back operations (cluster +//! size patch, Cues index, segment header backpatch) to land on the +//! right offset. +//! +//! `RandomAccessSink` is satisfied via the blanket impl in +//! [`super::mod`]; no explicit impl needed here. + +use std::fs::{File, OpenOptions}; +use std::io::{self, BufWriter, Seek, SeekFrom, Write}; +use std::path::Path; + +use super::preallocate; + +const BUFFER_BYTES: usize = 4 * 1024 * 1024; + +/// Random-access write sink for local disks. +/// +/// Wraps a `BufWriter` with a 4 MiB internal buffer and forwards +/// `Write`/`Seek` so any call site that previously held a `File` or +/// `WritebackFile` can drop this in. `finish()` flushes the buffer and +/// runs `sync_all` on the underlying file so the caller can drop it +/// without losing data. +/// +/// Construction always opens the file `create + truncate + read + +/// write`. `read` is enabled so the same handle can be reused for a +/// verification re-read after the mux (the existing +/// `FileSectorSink::create` pattern). On Linux, [`with_size_hint`] +/// additionally calls `fallocate(FALLOC_FL_KEEP_SIZE)` to pre-reserve +/// extents. +/// +/// [`with_size_hint`]: Self::with_size_hint +pub struct LocalFileSink { + inner: BufWriter, +} + +impl LocalFileSink { + /// Open `path` for writing, truncating any existing contents. + pub fn create(path: &Path) -> io::Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(path)?; + Ok(Self { + inner: BufWriter::with_capacity(BUFFER_BYTES, file), + }) + } + + /// Like [`Self::create`] but additionally calls the per-OS + /// preallocate path with `size_bytes`. On Linux this is + /// `fallocate(FALLOC_FL_KEEP_SIZE)` so the on-disk extents are + /// reserved up front (reducing fragmentation for big sequential + /// muxer output); on other OSes it is a no-op today. Failures + /// from the preallocate call are non-fatal — the file is still + /// returned, just without the size reservation. + pub fn with_size_hint(path: &Path, size_bytes: u64) -> io::Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(path)?; + preallocate::preallocate(&file, size_bytes); + Ok(Self { + inner: BufWriter::with_capacity(BUFFER_BYTES, file), + }) + } + + /// Drain the internal buffer and `fsync` the underlying file. + /// Idempotent with `Drop` (the `BufWriter` also flushes on drop; + /// this call additionally surfaces fsync errors to the caller). + #[allow(dead_code)] // exposed for parity with WritebackFile::sync_all + pub fn sync_all(&mut self) -> io::Result<()> { + self.inner.flush()?; + self.inner.get_ref().sync_all() + } +} + +impl Write for LocalFileSink { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.inner.write(buf) + } + + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + self.inner.write_all(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +impl Seek for LocalFileSink { + fn seek(&mut self, from: SeekFrom) -> io::Result { + // Flush before seeking so buffered bytes land at the offset + // they were written for, not the new one. + self.inner.flush()?; + self.inner.get_mut().seek(from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + #[test] + fn write_seek_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("rt.bin"); + let mut s = LocalFileSink::create(&p).unwrap(); + s.write_all(b"AAAA").unwrap(); + s.write_all(b"BBBB").unwrap(); + // Seek back over the second word and overwrite. + s.seek(SeekFrom::Start(4)).unwrap(); + s.write_all(b"CCCC").unwrap(); + s.sync_all().unwrap(); + drop(s); + + let mut f = File::open(&p).unwrap(); + let mut got = Vec::new(); + f.read_to_end(&mut got).unwrap(); + assert_eq!(&got[..], b"AAAACCCC"); + } + + #[test] + fn drop_flushes() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("drop.bin"); + { + let mut s = LocalFileSink::create(&p).unwrap(); + s.write_all(b"buffered").unwrap(); + // No explicit flush / sync_all — BufWriter drop runs the + // flush and the file should land on disk. + } + let bytes = std::fs::read(&p).unwrap(); + assert_eq!(&bytes[..], b"buffered"); + } + + #[test] + fn with_size_hint_creates_writable_file() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("sz.bin"); + let mut s = LocalFileSink::with_size_hint(&p, 64 * 1024).unwrap(); + s.write_all(b"hint-ok").unwrap(); + s.sync_all().unwrap(); + drop(s); + let bytes = std::fs::read(&p).unwrap(); + assert_eq!(&bytes[..], b"hint-ok"); + } +} diff --git a/src/io/sink/mod.rs b/src/io/sink/mod.rs new file mode 100644 index 0000000..f010bdf --- /dev/null +++ b/src/io/sink/mod.rs @@ -0,0 +1,159 @@ +//! Output-sink trait split for the buffering architecture. +//! +//! Two traits, one for each capability axis of an output destination: +//! +//! - [`SequentialSink`] — anything you can `Write` to in order. Sockets, +//! pipes, append-only stores, plain files. Containers that don't need +//! seek (M2TS, fMP4, HEVC elementary) target this. +//! - [`RandomAccessSink`] — everything `SequentialSink` plus a working +//! `Seek`. Local files, NFS files, anything with random-write +//! semantics. Containers that need backpatch (MKV cluster sizes, Cues +//! index, MP4 moov-at-end) target this. +//! +//! `RandomAccessSink: SequentialSink` — every random-access sink is +//! also a valid sequential sink. The muxer is generic over which it +//! requires (`MkvMux`, `M2tsMux`) +//! so an attempt to mux MKV to a network socket is a compile error. +//! +//! Buffering policy belongs to the concrete sink, not to a wrapper at +//! the call site. `LocalFileSink` wraps a `BufWriter` with a +//! 4 MiB buffer for the common local-disk case; `WritebackFile` +//! (separate module) wraps a `File` with the adaptive-chunk +//! `sync_file_range` machinery for the Linux+NFS case. +//! +//! See `freemkv-private/memory/project_buffering_architecture.md` for +//! the full design and the source/sink matrix. + +use std::io::{Seek, Write}; + +mod local_file; +mod preallocate; + +pub use local_file::LocalFileSink; + +/// Sequential-only write destination. Sockets, pipes, append-only +/// stores. No seek. Implementations own their write buffering — the +/// trait does not impose or hide any buffering of its own. +/// +/// `finish` drains any internal buffering and signals end-of-stream to +/// the underlying transport (close-write on a socket, flush on a +/// buffered writer, etc.). The default impl is a no-op; concrete +/// implementations that need explicit shutdown can override it but the +/// blanket impl below keeps it optional for adapter types like +/// `&mut File`. +pub trait SequentialSink: Write + Send { + fn finish(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Random-access write destination. Local files, NFS files, anything +/// with a working `Seek`. Inherits the `SequentialSink` contract — a +/// random-access sink is always usable as a sequential sink. +pub trait RandomAccessSink: SequentialSink + Seek {} + +// Blanket impls so any `Write + Send` type acts as a `SequentialSink` +// (with default `finish`), and any sink that also impls `Seek` is +// automatically a `RandomAccessSink`. Keeps call-site ergonomics simple +// — `&mut File`, `LocalFileSink`, `WritebackFile`, `BufWriter`, +// and `Cursor>` all satisfy the right trait without per-type +// boilerplate. +impl SequentialSink for T {} +impl RandomAccessSink for T {} + +/// Pick the right `RandomAccessSink` impl for `dest` based on its +/// filesystem type. +/// +/// - Linux + NFS path → `WritebackFile` with its adaptive-chunk +/// sync_file_range machinery and (when supported) `fallocate` size +/// hint. +/// - everything else → [`LocalFileSink`] over `BufWriter`. On +/// non-Linux there is no `WritebackFile` machinery to opt into, and +/// on local Linux the kernel's default writeback policy is already +/// fine. +/// +/// `size_hint`, when present, is forwarded to the per-OS preallocate +/// path (`fallocate(KEEP_SIZE)` on Linux, `F_PREALLOCATE` on macOS when +/// implemented, no-op elsewhere). +/// +/// Returns a boxed trait object so the call site (mux construction) +/// stays agnostic of which concrete sink got picked. +#[allow(dead_code)] // wiring to mux::resolve is a follow-up commit +pub fn open_for_mkv( + dest: &std::path::Path, + size_hint: Option, +) -> std::io::Result> { + #[cfg(target_os = "linux")] + use crate::platform::fs_type::{FsType, detect}; + #[cfg(not(target_os = "linux"))] + use crate::platform::fs_type::detect; + + #[cfg(target_os = "linux")] + { + if detect(dest) == FsType::Nfs { + let wf = match size_hint { + Some(n) => crate::io::WritebackFile::create_with_size_hint(dest, n)?, + None => crate::io::WritebackFile::create(dest)?, + }; + return Ok(Box::new(wf)); + } + } + // Silence the unused-binding warning on non-Linux where the only + // branch above is cfg-gated out. + #[cfg(not(target_os = "linux"))] + { + let _ = detect(dest); + } + + let sink = match size_hint { + Some(n) => LocalFileSink::with_size_hint(dest, n)?, + None => LocalFileSink::create(dest)?, + }; + Ok(Box::new(sink)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + + // Type-level assertion: the blanket impls cover the shapes we care + // about. These functions never run; they just have to type-check. + fn _assert_file_is_sequential(_: &mut dyn SequentialSink) {} + fn _assert_file_is_random_access(_: &mut dyn RandomAccessSink) {} + + #[test] + fn blanket_impls_cover_file_and_localfilesink() { + // `File` directly via blanket impls. + let dir = tempfile::tempdir().unwrap(); + let mut f = File::create(dir.path().join("a.bin")).unwrap(); + _assert_file_is_sequential(&mut f); + _assert_file_is_random_access(&mut f); + + // `LocalFileSink` ditto. + let mut s = LocalFileSink::create(&dir.path().join("b.bin")).unwrap(); + _assert_file_is_sequential(&mut s); + _assert_file_is_random_access(&mut s); + + // `WritebackFile` — confirms the Phase 1 type still satisfies + // the trait via the blanket impl without needing an explicit + // `impl RandomAccessSink for WritebackFile {}`. + let mut wf = crate::io::WritebackFile::create(&dir.path().join("c.bin")).unwrap(); + _assert_file_is_sequential(&mut wf); + _assert_file_is_random_access(&mut wf); + } + + #[test] + fn open_for_mkv_returns_a_random_access_sink() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("c.bin"); + let mut sink = open_for_mkv(&p, Some(64 * 1024)).unwrap(); + use std::io::{Seek, SeekFrom, Write}; + sink.write_all(b"hello").unwrap(); + sink.seek(SeekFrom::Start(0)).unwrap(); + sink.finish().unwrap(); + drop(sink); + let bytes = std::fs::read(&p).unwrap(); + assert_eq!(&bytes[..5], b"hello"); + } +} diff --git a/src/io/sink/preallocate/linux.rs b/src/io/sink/preallocate/linux.rs new file mode 100644 index 0000000..5995020 --- /dev/null +++ b/src/io/sink/preallocate/linux.rs @@ -0,0 +1,20 @@ +//! Linux `fallocate(FALLOC_FL_KEEP_SIZE)` preallocation. +//! +//! `KEEP_SIZE` reserves extents without changing the apparent file +//! length, which matches the muxer's expectation that writes still grow +//! the file naturally. + +use std::fs::File; +#[cfg(unix)] +use std::os::unix::io::AsRawFd; + +pub(super) fn preallocate_impl(file: &File, size_bytes: u64) { + let fd = file.as_raw_fd(); + // FALLOC_FL_KEEP_SIZE = 0x01. + let rc = unsafe { libc::fallocate(fd, libc::FALLOC_FL_KEEP_SIZE, 0, size_bytes as i64) }; + tracing::debug!( + target: "mux", + "LocalFileSink fallocate size_hint={size_bytes} rc={rc} ok={}", + rc == 0 + ); +} diff --git a/src/io/sink/preallocate/macos.rs b/src/io/sink/preallocate/macos.rs new file mode 100644 index 0000000..dd7bc13 --- /dev/null +++ b/src/io/sink/preallocate/macos.rs @@ -0,0 +1,48 @@ +//! macOS `F_PREALLOCATE` extent reservation. +//! +//! `fcntl(F_PREALLOCATE)` with `F_ALLOCATECONTIG` first (try for a +//! contiguous run) and fall back to `F_ALLOCATEALL` (non-contig OK). +//! Reported file size is unchanged — the muxer's writes still grow it. + +use std::fs::File; +use std::os::unix::io::AsRawFd; + +// Mirror the Darwin `fstore_t` struct from ``. libc on +// some Rust toolchains/versions doesn't ship this binding, so define +// it locally with the layout the kernel ABI requires. +#[repr(C)] +struct Fstore { + fst_flags: libc::c_uint, + fst_posmode: libc::c_int, + fst_offset: libc::off_t, + fst_length: libc::off_t, + fst_bytesalloc: libc::off_t, +} + +// Constants from . +const F_PREALLOCATE: libc::c_int = 42; +const F_ALLOCATECONTIG: libc::c_uint = 0x0000_0002; +const F_ALLOCATEALL: libc::c_uint = 0x0000_0004; +const F_PEOFPOSMODE: libc::c_int = 3; + +pub(super) fn preallocate_impl(file: &File, size_bytes: u64) { + let fd = file.as_raw_fd(); + let mut store = Fstore { + fst_flags: F_ALLOCATECONTIG, + fst_posmode: F_PEOFPOSMODE, + fst_offset: 0, + fst_length: size_bytes as libc::off_t, + fst_bytesalloc: 0, + }; + let mut rc = unsafe { libc::fcntl(fd, F_PREALLOCATE, &mut store as *mut Fstore) }; + if rc == -1 { + // Fall back to non-contiguous. + store.fst_flags = F_ALLOCATEALL; + rc = unsafe { libc::fcntl(fd, F_PREALLOCATE, &mut store as *mut Fstore) }; + } + tracing::debug!( + target: "mux", + "LocalFileSink F_PREALLOCATE size_hint={size_bytes} rc={rc} bytesalloc={}", + store.fst_bytesalloc + ); +} diff --git a/src/io/sink/preallocate/mod.rs b/src/io/sink/preallocate/mod.rs new file mode 100644 index 0000000..1b2f677 --- /dev/null +++ b/src/io/sink/preallocate/mod.rs @@ -0,0 +1,27 @@ +//! Per-OS extent preallocation. Best-effort; failures are logged at +//! debug and otherwise swallowed because the file is still usable +//! without the size reservation — only large-file fragmentation gets +//! marginally worse. + +use std::fs::File; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod other; + +#[cfg(target_os = "linux")] +use linux::preallocate_impl; +#[cfg(target_os = "macos")] +use macos::preallocate_impl; +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +use other::preallocate_impl; + +/// Reserve `size_bytes` of disk space for `file`'s on-disk extents. +/// Reported file size is unchanged — writes still grow the file +/// naturally; only the allocator's extent map is primed. +pub(super) fn preallocate(file: &File, size_bytes: u64) { + preallocate_impl(file, size_bytes); +} diff --git a/src/io/sink/preallocate/other.rs b/src/io/sink/preallocate/other.rs new file mode 100644 index 0000000..22d94b3 --- /dev/null +++ b/src/io/sink/preallocate/other.rs @@ -0,0 +1,10 @@ +//! Fallback preallocate impl. No-op. + +use std::fs::File; + +pub(super) fn preallocate_impl(_file: &File, size_bytes: u64) { + tracing::debug!( + target: "mux", + "LocalFileSink preallocate size_hint={size_bytes} skipped (no platform impl)" + ); +} diff --git a/src/platform/fs_type/linux.rs b/src/platform/fs_type/linux.rs new file mode 100644 index 0000000..26d2969 --- /dev/null +++ b/src/platform/fs_type/linux.rs @@ -0,0 +1,45 @@ +//! Linux `statfs64`-based filesystem type detection. +//! +//! Recognised local-FS magics: ext2/3/4, xfs, btrfs, tmpfs. NFS is the +//! one network FS this layer cares about (the buffering decision keys +//! off it). Anything else maps to [`FsType::Unknown`]. + +use std::ffi::CString; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use super::FsType; + +// Magic numbers from ``. Kept literal here so we don't +// depend on libc exposing each one — only `NFS_SUPER_MAGIC` is +// guaranteed to be present across libc / musl revisions. +const EXT2_SUPER_MAGIC: i64 = 0xEF53; +const XFS_SUPER_MAGIC: i64 = 0x5846_5342; +const BTRFS_SUPER_MAGIC: i64 = 0x9123_683E; +const TMPFS_MAGIC: i64 = 0x0102_1994; + +pub(super) fn detect_impl(path: &Path) -> FsType { + let cpath = match CString::new(path.as_os_str().as_bytes()) { + Ok(c) => c, + Err(_) => return FsType::Unknown, + }; + // `statfs64` is repr(C); zeroing is the documented init pattern for + // the kernel uapi struct. + let mut buf: libc::statfs64 = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statfs64(cpath.as_ptr(), &mut buf) }; + if rc != 0 { + return FsType::Unknown; + } + // `f_type` is signed (`__fsword_t`) on glibc and unsigned + // (`c_ulong`) on musl. Cast both sides to i64 for a portable + // comparison. + let f_type = buf.f_type as i64; + let nfs_magic = libc::NFS_SUPER_MAGIC as i64; + if f_type == nfs_magic { + return FsType::Nfs; + } + match f_type { + EXT2_SUPER_MAGIC | XFS_SUPER_MAGIC | BTRFS_SUPER_MAGIC | TMPFS_MAGIC => FsType::Local, + _ => FsType::Unknown, + } +} diff --git a/src/platform/fs_type/macos.rs b/src/platform/fs_type/macos.rs new file mode 100644 index 0000000..583be79 --- /dev/null +++ b/src/platform/fs_type/macos.rs @@ -0,0 +1,41 @@ +//! macOS `statfs`-based filesystem type detection. +//! +//! macOS exposes a textual `f_fstypename` field (e.g. `"apfs"`, `"hfs"`, +//! `"nfs"`, `"smbfs"`) on its `statfs` struct, which is far more +//! reliable than chasing magic numbers. Anything starting with `"nfs"` +//! counts as NFS; anything else recognised maps to `Local`; otherwise +//! `Unknown`. + +use std::ffi::CString; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use super::FsType; + +pub(super) fn detect_impl(path: &Path) -> FsType { + let cpath = match CString::new(path.as_os_str().as_bytes()) { + Ok(c) => c, + Err(_) => return FsType::Unknown, + }; + let mut buf: libc::statfs = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statfs(cpath.as_ptr(), &mut buf) }; + if rc != 0 { + return FsType::Unknown; + } + // `f_fstypename` is a NUL-terminated C string of length MFSTYPENAMELEN. + // SAFETY: libc guarantees the field is initialised to a NUL-terminated + // string by a successful statfs. + let name_ptr = buf.f_fstypename.as_ptr(); + let cstr = unsafe { std::ffi::CStr::from_ptr(name_ptr) }; + let name = cstr.to_bytes(); + if name.starts_with(b"nfs") { + return FsType::Nfs; + } + // Recognised local types. SMB is not NFS but the buffering-policy + // outcome on macOS is the same as for any other local FS — there's + // no `WritebackFile` machinery to opt out of on this OS. + match name { + b"apfs" | b"hfs" | b"exfat" | b"msdos" | b"tmpfs" | b"smbfs" | b"webdav" => FsType::Local, + _ => FsType::Unknown, + } +} diff --git a/src/platform/fs_type/mod.rs b/src/platform/fs_type/mod.rs new file mode 100644 index 0000000..8a58af7 --- /dev/null +++ b/src/platform/fs_type/mod.rs @@ -0,0 +1,112 @@ +//! Filesystem-type detection. +//! +//! The buffering architecture (Phase 2) selects different output sinks +//! for local vs network filesystems: NFS gets the adaptive +//! `WritebackFile` machinery on Linux; local disks get `LocalFileSink` +//! and rely on the kernel's default writeback policy. This module +//! provides the construction-site primitive that picks which one. +//! +//! Per the per-OS file-split convention, the actual `statfs` call lives +//! in the matching platform file (`linux.rs`, `macos.rs`, `windows.rs`, +//! `other.rs`); this `mod.rs` exposes only the cross-platform enum and +//! the `detect` entry point. + +use std::path::Path; + +/// What kind of filesystem a path lives on, to the extent we can tell +/// cheaply at construction time. +/// +/// `Unknown` is the fail-open default: a misdetection here should not +/// be load-bearing for correctness, only for the choice of sink (and +/// hence buffering policy). Callers that need a binary local/non-local +/// answer should treat `Unknown` as "probably local". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FsType { + /// A local on-disk filesystem (ext4, xfs, btrfs, apfs, ntfs, …). + Local, + /// A network filesystem with NFS-like semantics. The current + /// detector lumps SMB / UNC into this on Windows because the + /// buffering policy outcome is the same. + Nfs, + /// `statfs` failed, the filesystem type is not on our recognised + /// list, or we are on an OS without a real implementation. + Unknown, +} + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +mod other; + +#[cfg(target_os = "linux")] +use linux::detect_impl; +#[cfg(target_os = "macos")] +use macos::detect_impl; +#[cfg(target_os = "windows")] +use windows::detect_impl; +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +use other::detect_impl; + +/// Best-effort classification of the filesystem under `path`. +/// +/// Falls back to [`FsType::Unknown`] on any syscall error or unrecognised +/// filesystem signature. Never panics. Never blocks beyond the cost of +/// a single `statfs(2)` (Unix) or a string check (Windows). +pub fn detect(path: &Path) -> FsType { + detect_impl(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_does_not_panic_on_missing_path() { + // Non-existent path should fall through to Unknown, not panic. + let p = std::path::Path::new("/this/path/should/not/exist/freemkv-test"); + let r = detect(p); + // We don't assert == Unknown because Windows' heuristic looks at + // the leading bytes and might still classify; just confirm the + // call returns rather than panicking. + let _ = r; + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_tmp_is_local() { + // `/tmp` on macOS dev rigs is APFS via the symlink to + // `/private/tmp`. Either way, never NFS. + let r = detect(std::path::Path::new("/tmp")); + assert!( + matches!(r, FsType::Local | FsType::Unknown), + "expected Local or Unknown for /tmp on macOS, got {r:?}", + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_tmp_is_local_or_unknown() { + // `/tmp` is tmpfs on most distros (which we recognise) but + // could be ext4 on others. NFS would be unusual. + let r = detect(std::path::Path::new("/tmp")); + assert!( + matches!(r, FsType::Local | FsType::Unknown), + "expected Local or Unknown for /tmp on Linux, got {r:?}", + ); + } + + /// Real NFS exercise needs an actual NFS mount and isn't available + /// in CI. Kept here as a manual probe. + #[test] + #[ignore = "needs an NFS mount path (e.g. /mnt/nfs/...) to validate live"] + fn nfs_path_classified_as_nfs() { + // Operator passes the mount as FREEMKV_NFS_PROBE; gated behind + // `--ignored` because there's no portable NFS path. + let p = std::env::var("FREEMKV_NFS_PROBE").expect("set FREEMKV_NFS_PROBE"); + assert_eq!(detect(std::path::Path::new(&p)), FsType::Nfs); + } +} diff --git a/src/platform/fs_type/other.rs b/src/platform/fs_type/other.rs new file mode 100644 index 0000000..a3825aa --- /dev/null +++ b/src/platform/fs_type/other.rs @@ -0,0 +1,12 @@ +//! Fallback fs-type detection for platforms without a specific impl. +//! +//! Always returns [`FsType::Unknown`]. Callers treat that as +//! "probably local" and pick `LocalFileSink`. + +use std::path::Path; + +use super::FsType; + +pub(super) fn detect_impl(_path: &Path) -> FsType { + FsType::Unknown +} diff --git a/src/platform/fs_type/windows.rs b/src/platform/fs_type/windows.rs new file mode 100644 index 0000000..b510d17 --- /dev/null +++ b/src/platform/fs_type/windows.rs @@ -0,0 +1,26 @@ +//! Windows filesystem-type detection. +//! +//! Heuristic-only: any UNC path (`\\server\share\...`) is treated as a +//! network mount and bucketed into `Nfs`. Strictly, SMB is not NFS, but +//! the buffering-policy outcome for our purposes is the same — there is +//! no platform `WritebackFile` machinery on Windows yet, so the worst +//! case of a false positive is using `LocalFileSink` regardless. A +//! proper `GetVolumeInformation` query is a Phase 4 concern. + +use std::path::Path; + +use super::FsType; + +pub(super) fn detect_impl(path: &Path) -> FsType { + // `Path::starts_with("\\\\")` won't match because component-wise + // matching strips the prefix. Look at the raw OsStr instead. + let s = path.as_os_str(); + // OsStr -> [u16] is the platform-correct way on Windows, but + // checking the leading bytes via `to_string_lossy` is good enough + // for a UNC prefix probe and works on any encoding. + let lossy = s.to_string_lossy(); + if lossy.starts_with("\\\\") || lossy.starts_with("//") { + return FsType::Nfs; + } + FsType::Local +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index b8582f6..a9fa8b3 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,5 +1,6 @@ //! Platform-specific drive initialization and disc probing. +pub mod fs_type; pub mod mt1959; use crate::error::Result;