io+platform: phase 2 — sink trait split + fs_type detection

Introduces the SequentialSink / RandomAccessSink trait pair under
io::sink and an open_for_mkv dispatch helper that picks WritebackFile
on Linux+NFS and LocalFileSink everywhere else. LocalFileSink wraps
BufWriter<File> with a 4 MiB buffer and exposes a per-OS preallocate
path (fallocate on Linux, F_PREALLOCATE on macOS, no-op fallback).

Adds platform::fs_type::detect with a per-OS split (statfs on Linux /
macOS, UNC heuristic on Windows, Unknown elsewhere) so construction-
site dispatch has a single primitive to call.

Blanket impls cover the common shapes: any Write+Send is a
SequentialSink, and any SequentialSink+Seek is a RandomAccessSink.
WritebackFile satisfies the random-access trait via the blanket impl
without needing an explicit per-type impl. No callers wired yet — the
mux::resolve construction sites stay on WritebackFile pending Phase 3.

Tests: 5 new sink/preallocate tests + 3 fs_type tests (1 ignored,
needs a real NFS mount). cargo +1.86 fmt + clippy + tests all green.
This commit is contained in:
2026-05-13 19:58:54 -07:00
parent f5af9e0cb5
commit 5380edd737
13 changed files with 664 additions and 0 deletions
+1
View File
@@ -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;
+162
View File
@@ -0,0 +1,162 @@
//! `LocalFileSink` — `BufWriter<File>` 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<File>` 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<File>,
}
impl LocalFileSink {
/// Open `path` for writing, truncating any existing contents.
pub fn create(path: &Path) -> io::Result<Self> {
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<Self> {
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<usize> {
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<u64> {
// 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");
}
}
+159
View File
@@ -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<S: RandomAccessSink>`, `M2tsMux<S: SequentialSink>`)
//! 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<File>` 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<File>`,
// and `Cursor<Vec<u8>>` all satisfy the right trait without per-type
// boilerplate.
impl<T: Write + Send + ?Sized> SequentialSink for T {}
impl<T: SequentialSink + Seek + ?Sized> 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<File>`. 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<u64>,
) -> std::io::Result<Box<dyn RandomAccessSink>> {
#[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");
}
}
+20
View File
@@ -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
);
}
+48
View File
@@ -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 `<sys/fcntl.h>`. 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 <sys/fcntl.h>.
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
);
}
+27
View File
@@ -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);
}
+10
View File
@@ -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)"
);
}
+45
View File
@@ -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 `<linux/magic.h>`. 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,
}
}
+41
View File
@@ -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,
}
}
+112
View File
@@ -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);
}
}
+12
View File
@@ -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
}
+26
View File
@@ -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
}
+1
View File
@@ -1,5 +1,6 @@
//! Platform-specific drive initialization and disc probing.
pub mod fs_type;
pub mod mt1959;
use crate::error::Result;