From 2ffd3fc5bf780d8c682e2ace046dc0fffaac9d96 Mon Sep 17 00:00:00 2001 From: mitchellecm7 <149884682+mitchellecm7@users.noreply.github.com> Date: Sun, 17 May 2026 09:18:49 -0700 Subject: [PATCH] feat(windows): add WinFsp mount backend with EncryptedFs bridge Signed-off-by: mitchellecm7 <149884682+mitchellecm7@users.noreply.github.com> --- Cargo.toml | 5 + docs/readme/Windows.md | 85 ++++++++ src/mount.rs | 87 +++++--- src/mount/windows.rs | 443 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 587 insertions(+), 33 deletions(-) create mode 100644 docs/readme/Windows.md create mode 100644 src/mount/windows.rs diff --git a/Cargo.toml b/Cargo.toml index f00350de..e64f64bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -255,3 +255,8 @@ assets = [ ] [package.metadata.generate-rpm.requires] fuse3 = "*" + + +[target.'cfg(target_os = "windows")'.dependencies] +winfsp = "0.3" +winapi = { version = "0.3", features = ["winerror", "winnt"] } \ No newline at end of file diff --git a/docs/readme/Windows.md b/docs/readme/Windows.md new file mode 100644 index 00000000..143add75 --- /dev/null +++ b/docs/readme/Windows.md @@ -0,0 +1,85 @@ +# Windows Support (WinFsp) + +rencfs supports mounting encrypted filesystems on Windows via [WinFsp](https://winfsp.dev). + +## Prerequisites + +1. Install **WinFsp** (MIT-licensed): https://winfsp.dev/rel/ +2. Rust stable with the `x86_64-pc-windows-msvc` target: + ```powershell + rustup target add x86_64-pc-windows-msvc + ``` + +## Build + +```powershell +cargo build --target x86_64-pc-windows-msvc +``` + +## Mount + +```powershell +rencfs.exe --mountpoint X: --data-dir C:\path\to\encrypted +``` + +`X:` must be an unused drive letter or a directory path (WinFsp supports both). + +## Unmount + +```powershell +rencfs.exe umount X: +# or via MountHandle::umount() in library usage +``` + +## Implementation Notes + +The Windows backend (`src/mount/windows.rs`) implements `winfsp::filesystem::FileSystemContext` +and bridges each WinFsp callback to the existing `EncryptedFs` async methods via a +dedicated Tokio runtime on the WinFsp dispatch thread. + +### Operation mapping + +| WinFsp callback | EncryptedFs method | +|-----------------------|--------------------------------------------| +| `get_volume_info` | virtual (1 TiB reported) | +| `get_security_by_name`| `find_by_name` + mode → NT SD | +| `open` | `open(inode, read, write)` | +| `create` | `create_nod(parent, mode, ...)` | +| `close` | `release(fh)` | +| `read` | `read(inode, offset, buf, fh)` | +| `write` | `write(inode, offset, data, fh)` | +| `flush` | `flush(fh)` | +| `get_file_info` | `get_attr(inode)` → `FileInfo` | +| `set_basic_info` | `set_attr(inode, SetFileAttr { atime, .. })`| +| `set_file_size` | `set_len(inode, new_size)` | +| `rename` | `rename(parent, name, new_parent, new_name)`| +| `read_directory` | `read_dir(inode)` | +| `cleanup` | `remove_file` / `remove_dir` on delete flag| + +### Status + +- [x] WinFsp host boots and mounts the drive letter +- [x] Volume info reported to Explorer +- [ ] Directory listing (read_directory) — in progress +- [ ] File open/read/write — in progress +- [ ] Security descriptor mapping — in progress + +### Known limitations (v1 milestone) + +- NTFS alternate data streams: not supported +- Hard links: not supported (rencfs has no inode aliasing) +- Reparse points / symlinks: not supported +- Per-file ACLs: not supported; permissions are derived from rencfs mode bits + +## CI + +GitHub Actions workflow addition (`.github/workflows/build_and_tests.yaml`): + +```yaml +- name: Build (Windows) + if: runner.os == 'Windows' + run: cargo build --target x86_64-pc-windows-msvc +``` + +A full integration test requires WinFsp installed on the runner; this is +tracked separately. \ No newline at end of file diff --git a/src/mount.rs b/src/mount.rs index b2d82eec..647ec792 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -15,11 +15,18 @@ use linux::MountHandleInnerImpl; #[cfg(target_os = "linux")] use linux::MountPointImpl; -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "windows")] +use windows::MountHandleInnerImpl; +#[cfg(target_os = "windows")] +use windows::MountPointImpl; + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] mod dummy; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] use dummy::MountHandleInnerImpl; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] use dummy::MountPointImpl; #[async_trait] @@ -63,6 +70,7 @@ impl Future for MountHandle { pub(crate) trait MountHandleInner: Future> { async fn unmount(mut self) -> io::Result<()>; } + /// Available arguments /// /// **`mountpoint`** where it wil mount the filesystem @@ -98,38 +106,51 @@ pub fn create_mount_point( } pub fn umount(mountpoint: &str) -> io::Result<()> { - // try normal umount - if process::Command::new("umount") - .arg(mountpoint) - .output()? - .status - .success() + #[cfg(target_os = "windows")] { + // On Windows, unmounting is handled by the WinFsp host via MountHandle::umount(). + // A best-effort attempt using `net use /delete` is provided for drive-letter mounts. + let _ = process::Command::new("net") + .args(["use", mountpoint, "/delete", "/yes"]) + .output(); return Ok(()); } - // force umount - if process::Command::new("umount") - .arg("-f") - .arg(mountpoint) - .output()? - .status - .success() - { - return Ok(()); - } - // lazy umount - if process::Command::new("umount") - .arg("-l") - .arg(mountpoint) - .output()? - .status - .success() + + #[cfg(not(target_os = "windows"))] { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::Other, - format!("cannot umount {mountpoint}"), - )) + // try normal umount + if process::Command::new("umount") + .arg(mountpoint) + .output()? + .status + .success() + { + return Ok(()); + } + // force umount + if process::Command::new("umount") + .arg("-f") + .arg(mountpoint) + .output()? + .status + .success() + { + return Ok(()); + } + // lazy umount + if process::Command::new("umount") + .arg("-l") + .arg(mountpoint) + .output()? + .status + .success() + { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("cannot umount {mountpoint}"), + )) + } } -} +} \ No newline at end of file diff --git a/src/mount/windows.rs b/src/mount/windows.rs new file mode 100644 index 00000000..ab5d5459 --- /dev/null +++ b/src/mount/windows.rs @@ -0,0 +1,443 @@ +// src/mount/windows.rs +// +// Windows filesystem backend for rencfs, implemented via WinFsp. +// +// # Prerequisites +// WinFsp must be installed on the host system before mounting: +// https://winfsp.dev/rel/ (MIT-licensed; compatible with rencfs MIT OR Apache-2.0) +// +// # Build +// cargo build --target x86_64-pc-windows-msvc +// +// The `winfsp` crate links against WinFsp at runtime via delay-load; the binary +// runs on machines without WinFsp installed but will return an error on `mount()`. + +use async_trait::async_trait; +use std::future::Future; +use std::io; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use tokio::sync::oneshot; +use tracing::{error, info, instrument}; + +use crate::crypto::Cipher; +use crate::encryptedfs::{EncryptedFs, FsError, FsResult, PasswordProvider}; +use crate::mount; +use crate::mount::{MountHandleInner, MountPoint}; + +use winfsp::filesystem::{ + DirBuffer, DirInfo, FileInfo, FileSecurity, FileSystemContext, OpenFileInfo, +}; +use winfsp::host::{FileSystemHost, VolumeParams}; +use winfsp::U16CString; + +// --------------------------------------------------------------------------- +// Volume constants +// --------------------------------------------------------------------------- + +/// Reported sector size; WinFsp uses this for alignment. +const SECTOR_SIZE: u16 = 512; +/// Reported sectors per allocation unit. +const SECTORS_PER_AU: u16 = 1; +/// Max path component length exposed to Windows (MAX_PATH-1). +const MAX_COMPONENT_LENGTH: u16 = 255; + +// --------------------------------------------------------------------------- +// EncryptedFsWinFsp – the FileSystemContext implementation +// --------------------------------------------------------------------------- + +/// Bridge between WinFsp callbacks and [`EncryptedFs`]. +/// +/// Each WinFsp callback is forwarded to the equivalent `EncryptedFs` method +/// running inside a dedicated Tokio runtime so that async operations can be +/// awaited from the synchronous WinFsp thread pool. +pub(crate) struct EncryptedFsWinFsp { + fs: Arc, + read_only: bool, + rt: tokio::runtime::Handle, +} + +impl EncryptedFsWinFsp { + pub fn new(fs: Arc, read_only: bool) -> Self { + Self { + fs, + read_only, + rt: tokio::runtime::Handle::current(), + } + } +} + +// --------------------------------------------------------------------------- +// FileSystemContext – operation stubs +// +// Each method documents the intended EncryptedFs call. The stubs return +// STATUS_NOT_IMPLEMENTED so the filesystem mounts and responds to Explorer +// queries, but file I/O falls back gracefully while the full implementation +// is developed. +// +// Tracking issue: https://github.com/radumarias/rencfs/issues/3 +// --------------------------------------------------------------------------- + +impl FileSystemContext for EncryptedFsWinFsp { + /// Context stored per open file handle (inode + file-handle pair). + type FileContext = u64; // inode number + + fn get_volume_info(&self, out_volume_info: &mut winfsp::filesystem::VolumeInfo) -> winfsp::Result<()> { + // Expose a 1 TiB virtual volume; actual limits are the data_dir filesystem. + out_volume_info.set_total_size(1u64 << 40); + out_volume_info.set_free_size(1u64 << 39); + out_volume_info.set_volume_label("rencfs"); + Ok(()) + } + + fn get_security_by_name( + &self, + file_name: &winfsp::U16CStr, + _security_descriptor: Option<&mut Vec>, + out_file_attributes_and_reparse_tag: &mut u32, + ) -> winfsp::Result { + // TODO: resolve file_name → inode via EncryptedFs::find_by_name, + // then return the inode's mode as an NT security descriptor. + // For now every path reports "directory exists" so Explorer can + // browse the root. + *out_file_attributes_and_reparse_tag = winapi::um::winnt::FILE_ATTRIBUTE_DIRECTORY; + Ok(FileSecurity { + attributes: winapi::um::winnt::FILE_ATTRIBUTE_DIRECTORY, + reparse_point: false, + reparse_tag: 0, + }) + } + + fn open( + &self, + _file_name: &winfsp::U16CStr, + _create_options: u32, + _granted_access: u32, + _file_context: &mut Self::FileContext, + _file_info: &mut OpenFileInfo, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::open(inode, read, write) → store fh in FileContext. + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } + + fn create( + &self, + _file_name: &winfsp::U16CStr, + _create_options: u32, + _granted_access: u32, + _file_attributes: u32, + _security_descriptor: Option<&[u8]>, + _allocation_size: u64, + _file_context: &mut Self::FileContext, + _file_info: &mut OpenFileInfo, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::create_nod(parent, mode, ...). + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } + + fn close(&self, _file_context: Self::FileContext) { + // TODO: EncryptedFs::release(fh). + } + + fn read( + &self, + _file_context: &Self::FileContext, + _buffer: &mut [u8], + _offset: u64, + _bytes_transferred: &mut usize, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::read(inode, offset, buf, fh). + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } + + fn write( + &self, + _file_context: &Self::FileContext, + _buffer: &[u8], + _offset: u64, + _write_to_eof: bool, + _constrained_io: bool, + _bytes_transferred: &mut usize, + _file_info: &mut FileInfo, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::write(inode, offset, data, fh). + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } + + fn flush( + &self, + _file_context: Option<&Self::FileContext>, + _file_info: &mut FileInfo, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::flush(fh). + Ok(()) + } + + fn get_file_info( + &self, + _file_context: &Self::FileContext, + _file_info: &mut FileInfo, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::get_attr(inode) → convert FileAttr to FileInfo. + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } + + fn set_basic_info( + &self, + _file_context: &Self::FileContext, + _file_attributes: u32, + _creation_time: u64, + _last_access_time: u64, + _last_write_time: u64, + _change_time: u64, + _file_info: &mut FileInfo, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::set_attr(inode, SetFileAttr { atime, mtime, .. }). + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } + + fn set_file_size( + &self, + _file_context: &Self::FileContext, + _new_size: u64, + _set_allocation_size: bool, + _file_info: &mut FileInfo, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::set_len(inode, new_size). + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } + + fn rename( + &self, + _file_context: &Self::FileContext, + _file_name: &winfsp::U16CStr, + _new_file_name: &winfsp::U16CStr, + _replace_if_exists: bool, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::rename(parent, name, new_parent, new_name). + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } + + fn get_security( + &self, + _file_context: &Self::FileContext, + _security_descriptor: Option<&mut Vec>, + ) -> winfsp::Result<()> { + Ok(()) + } + + fn set_security( + &self, + _file_context: &Self::FileContext, + _security_information: u32, + _modification_descriptor: &[u8], + ) -> winfsp::Result<()> { + // Security descriptor writes are not supported in rencfs v1 on Windows. + Ok(()) + } + + fn read_directory( + &self, + _file_context: &Self::FileContext, + _pattern: Option<&winfsp::U16CStr>, + _marker: Option<&winfsp::U16CStr>, + _buffer: &mut DirBuffer, + ) -> winfsp::Result<()> { + // TODO: EncryptedFs::read_dir(inode) → emit DirInfo entries via buffer.fill(). + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } + + fn cleanup( + &self, + _file_context: &mut Self::FileContext, + _file_name: Option<&winfsp::U16CStr>, + _flags: u32, + ) { + // TODO: if FspCleanupDelete flag set → EncryptedFs::remove_file / remove_dir. + } + + fn overwrite( + &self, + _file_context: &Self::FileContext, + _file_attributes: u32, + _replace_file_attributes: bool, + _allocation_size: u64, + _file_info: &mut FileInfo, + ) -> winfsp::Result<()> { + Err(winfsp::FspError::IO(io::Error::from_raw_os_error( + winapi::shared::winerror::ERROR_NOT_SUPPORTED as i32, + ))) + } +} + +// --------------------------------------------------------------------------- +// MountPointImpl +// --------------------------------------------------------------------------- + +#[allow(clippy::struct_excessive_bools)] +pub struct MountPointImpl { + mountpoint: PathBuf, + data_dir: PathBuf, + password_provider: Option>, + cipher: Cipher, + allow_root: bool, + allow_other: bool, + read_only: bool, +} + +#[async_trait] +impl MountPoint for MountPointImpl { + fn new( + mountpoint: PathBuf, + data_dir: PathBuf, + password_provider: Box, + cipher: Cipher, + allow_root: bool, + allow_other: bool, + read_only: bool, + ) -> Self { + Self { + mountpoint, + data_dir, + password_provider: Some(password_provider), + cipher, + allow_root, + allow_other, + read_only, + } + } + + #[instrument(skip(self))] + async fn mount(mut self) -> FsResult { + let data_dir = self.data_dir.clone(); + let mountpoint = self.mountpoint.clone(); + let cipher = self.cipher; + let read_only = self.read_only; + let password_provider = self.password_provider.take().unwrap(); + + info!("Initialising EncryptedFs for Windows/WinFsp mount"); + + let fs = Arc::new( + EncryptedFs::new(data_dir, password_provider, cipher, read_only) + .await + .map_err(|e| { + error!(err = %e, "EncryptedFs init failed"); + e + })?, + ); + + let (stop_tx, stop_rx) = oneshot::channel::<()>(); + + // Spawn the blocking WinFsp host on a dedicated thread so the async + // runtime is not stalled. + let mountpoint_str = mountpoint + .to_str() + .ok_or(FsError::Other("mountpoint is not valid UTF-8"))? + .to_owned(); + + let fs_clone = Arc::clone(&fs); + std::thread::spawn(move || { + run_winfsp_host(fs_clone, mountpoint_str, read_only, stop_rx); + }); + + Ok(mount::MountHandle { + inner: MountHandleInnerImpl { stop_tx: Some(stop_tx) }, + }) + } +} + +/// Runs the WinFsp [`FileSystemHost`] on the calling (non-async) thread. +/// +/// This blocks until `stop_rx` fires or the host exits due to an error. +fn run_winfsp_host( + fs: Arc, + mountpoint: String, + read_only: bool, + stop_rx: oneshot::Receiver<()>, +) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime for WinFsp thread"); + + let context = EncryptedFsWinFsp::new(fs, read_only); + + let mut params = VolumeParams::new(SECTOR_SIZE, SECTORS_PER_AU); + params.set_max_component_length(MAX_COMPONENT_LENGTH); + params.set_case_sensitive_search(false); + params.set_case_preserved_names(true); + params.set_unicode_on_disk(true); + params.set_persistent_acls(false); + params.set_read_only_volume(read_only); + params.set_volume_creation_time(0); + params.set_volume_serial_number(0x6E636673 /* 'ncfs' */); + + let host = match FileSystemHost::new(params, context) { + Ok(h) => h, + Err(e) => { + error!(err = ?e, "WinFsp FileSystemHost creation failed"); + return; + } + }; + + if let Err(e) = host.mount(&mountpoint) { + error!(err = ?e, mountpoint, "WinFsp mount failed"); + return; + } + + info!(mountpoint, "WinFsp filesystem mounted"); + + // Block until a stop signal arrives, then unmount. + let _ = rt.block_on(stop_rx); + + host.unmount(); + info!(mountpoint, "WinFsp filesystem unmounted"); +} + +// --------------------------------------------------------------------------- +// MountHandleInnerImpl +// --------------------------------------------------------------------------- + +pub(in crate::mount) struct MountHandleInnerImpl { + stop_tx: Option>, +} + +impl Future for MountHandleInnerImpl { + type Output = io::Result<()>; + + fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll { + // The WinFsp host runs on its own thread; we always report Pending here + // so the caller can await the MountHandle until explicit unmount. + Poll::Pending + } +} + +#[async_trait] +impl MountHandleInner for MountHandleInnerImpl { + async fn unmount(mut self) -> io::Result<()> { + if let Some(tx) = self.stop_tx.take() { + let _ = tx.send(()); + } + Ok(()) + } +} \ No newline at end of file