use std::{
collections::hash_map::DefaultHasher,
ffi::OsString,
hash::{Hash, Hasher},
io,
os::windows::ffi::OsStringExt,
path::{Path, PathBuf},
sync::{
Mutex, OnceLock,
mpsc::{self, SyncSender},
},
thread::JoinHandle,
time::{Duration, Instant},
};
use ::dokan::{
CreateFileInfo, DiskSpaceInfo, FileInfo, FileSystemHandler, FileSystemMounter,
FileTimeOperation, FillDataError, FillDataResult, FindData, IO_SECURITY_CONTEXT, MountFlags,
MountOptions, OperationInfo, OperationResult, VolumeInfo, map_win32_error_to_ntstatus,
};
use dokan_sys::win32::{
FILE_CREATE, FILE_DIRECTORY_FILE, FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_IF,
FILE_OVERWRITE, FILE_OVERWRITE_IF, FILE_SUPERSEDE,
};
use widestring::{U16CStr, U16CString};
use winapi::{
shared::{
ntdef::NTSTATUS,
ntstatus::{
STATUS_ACCESS_DENIED, STATUS_DIRECTORY_NOT_EMPTY, STATUS_DISK_FULL,
STATUS_FILE_IS_A_DIRECTORY, STATUS_FILE_TOO_LARGE, STATUS_INVALID_PARAMETER,
STATUS_MEDIA_WRITE_PROTECTED, STATUS_NOT_A_DIRECTORY, STATUS_NOT_IMPLEMENTED,
STATUS_NOT_SAME_DEVICE, STATUS_OBJECT_NAME_COLLISION, STATUS_OBJECT_NAME_INVALID,
STATUS_OBJECT_NAME_NOT_FOUND, STATUS_UNEXPECTED_IO_ERROR, STATUS_UNSUCCESSFUL,
},
},
um::{
fileapi::GetVolumeInformationW,
winnt::{
ACCESS_MASK, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL,
FILE_CASE_PRESERVED_NAMES, FILE_CASE_SENSITIVE_SEARCH, FILE_UNICODE_ON_DISK,
},
},
};
use crate::fs::{
DirentKind, FileSystem, Mount,
filesystem::posix::{BLOCK_SIZE, NAME_MAX, TOTAL_BLOCKS, attr_for},
};
const VOLUME_NAME: &str = "virtx";
const FS_NAME: &str = "NTFS";
const OPERATION_TIMEOUT: Duration = Duration::from_secs(60);
const MOUNT_TIMEOUT: Duration = Duration::from_secs(30);
const ANSWER_POLL: Duration = Duration::from_millis(5);
pub struct DokanMount {
mountpoint: PathBuf,
wide: U16CString,
serving: Option<JoinHandle<()>>,
}
impl DokanMount {
pub fn try_new<T: FileSystem + 'static>(fs: T, mountpoint: &Path) -> io::Result<Self> {
Self::try_new_with(fs, mountpoint, MountFlags::empty())
}
pub fn try_new_with<T: FileSystem + 'static>(
fs: T,
mountpoint: &Path,
flags: MountFlags,
) -> io::Result<Self> {
crate::fs::mount_support()?;
let wide = U16CString::from_os_str(mountpoint).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidFilename,
"mount point contains an interior nul",
)
})?;
let (ready, mounted) = mpsc::sync_channel(1);
let serving = {
let wide = wide.clone();
std::thread::Builder::new()
.name("virtx-dokan".into())
.spawn(move || serve(fs, wide, flags, ready))?
};
let deadline = Instant::now() + MOUNT_TIMEOUT;
match mounted.recv_timeout(MOUNT_TIMEOUT) {
Ok(Ok(())) => {
let mount = DokanMount {
mountpoint: mountpoint.to_path_buf(),
wide,
serving: Some(serving),
};
loop {
match answers(&mount.wide) {
Ok(()) => return Ok(mount),
Err(last) if Instant::now() >= deadline => {
drop(mount);
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"dokan reported the mount live, but the mount point did \
not answer as the volume within {MOUNT_TIMEOUT:?}; its \
last answer: {last}"
),
));
}
Err(_) => std::thread::sleep(ANSWER_POLL),
}
}
}
Ok(Err(err)) => {
let _ = serving.join();
Err(err)
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
let _ = serving.join();
Err(io::Error::other("virtx dokan serving thread ended"))
}
Err(mpsc::RecvTimeoutError::Timeout) => {
let _ = ::dokan::unmount(&wide);
let _ = serving.join();
Err(io::Error::new(
io::ErrorKind::TimedOut,
"dokan did not report the mount live",
))
}
}
}
pub fn join(mut self) -> io::Result<()> {
match self.serving.take() {
Some(serving) => serving
.join()
.map_err(|_| io::Error::other("virtx dokan serving thread panicked")),
None => Ok(()),
}
}
}
impl Mount for DokanMount {
fn mountpoint(&self) -> &Path {
&self.mountpoint
}
fn url(&self) -> Option<String> {
let path = self
.mountpoint
.to_str()
.filter(|_| self.mountpoint.is_absolute())?;
Some(format!("file:///{}", path.replace('\\', "/")))
}
}
impl Drop for DokanMount {
fn drop(&mut self) {
let Some(serving) = self.serving.take() else {
return;
};
if !::dokan::unmount(&self.wide) {
eprintln!(
"virtx: unmounting {} failed; the volume is left for dokanctl to clear",
self.mountpoint.display()
);
return;
}
let _ = serving.join();
self.reclaim_mountpoint();
}
}
impl DokanMount {
fn reclaim_mountpoint(&self) {
use std::os::windows::fs::MetadataExt as _;
const REPARSE_POINT: u32 = 0x0000_0400;
let Ok(meta) = std::fs::symlink_metadata(&self.mountpoint) else {
return;
};
if meta.file_attributes() & REPARSE_POINT == 0 {
return;
}
if std::fs::remove_dir(&self.mountpoint).is_ok() {
let _ = std::fs::create_dir(&self.mountpoint);
}
}
}
fn answers(mountpoint: &U16CStr) -> io::Result<()> {
const BACKSLASH: u16 = b'\\' as u16;
let mut root = mountpoint.as_slice().to_vec();
if root.last() != Some(&BACKSLASH) {
root.push(BACKSLASH);
}
root.push(0);
let mut label = [0u16; 64];
let mut serial = 0u32;
let answered = unsafe {
GetVolumeInformationW(
root.as_ptr(),
label.as_mut_ptr(),
label.len() as u32,
&mut serial,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
0,
)
};
if answered == 0 {
return Err(io::Error::last_os_error());
}
let len = label
.iter()
.position(|&unit| unit == 0)
.unwrap_or(label.len());
let label = String::from_utf16_lossy(&label[..len]);
if serial == 0 && label == VOLUME_NAME {
Ok(())
} else {
Err(io::Error::other(format!(
"a volume labelled {label:?} with serial {serial:#010x}, not this one"
)))
}
}
fn library() {
static INIT: OnceLock<()> = OnceLock::new();
INIT.get_or_init(::dokan::init);
}
fn serve<T: FileSystem>(
fs: T,
wide: U16CString,
flags: MountFlags,
ready: SyncSender<io::Result<()>>,
) {
library();
let handler = Handler {
store: fs,
ready: Mutex::new(Some(ready)),
};
let options = MountOptions {
flags: flags | MountFlags::CASE_SENSITIVE,
timeout: OPERATION_TIMEOUT,
allocation_unit_size: BLOCK_SIZE as u32,
sector_size: BLOCK_SIZE as u32,
..Default::default()
};
let mut mounter = FileSystemMounter::new(&handler, &wide, &options);
match mounter.mount() {
Ok(fs) => drop(fs),
Err(err) => handler.report(Err(io::Error::other(format!("dokan mount failed: {err}")))),
}
}
struct Handler<T: FileSystem> {
store: T,
ready: Mutex<Option<SyncSender<io::Result<()>>>>,
}
impl<T: FileSystem> Handler<T> {
fn report(&self, outcome: io::Result<()>) {
if let Some(ready) = crate::lock::lock(&self.ready).take() {
let _ = ready.send(outcome);
}
}
}
impl<'c, 'h: 'c, T: FileSystem + 'h> FileSystemHandler<'c, 'h> for Handler<T> {
type Context = ();
#[allow(clippy::too_many_arguments)]
fn create_file(
&'h self,
file_name: &U16CStr,
_security_context: &IO_SECURITY_CONTEXT,
_desired_access: ACCESS_MASK,
_file_attributes: u32,
_share_access: u32,
create_disposition: u32,
create_options: u32,
_info: &mut OperationInfo<'c, 'h, Self>,
) -> OperationResult<CreateFileInfo<Self::Context>> {
let path = store_path(file_name)?;
let want_dir = create_options & FILE_DIRECTORY_FILE != 0;
let want_file = create_options & FILE_NON_DIRECTORY_FILE != 0;
let (may_create, may_truncate) = match create_disposition {
FILE_CREATE => (true, false),
FILE_OPEN => (false, false),
FILE_OPEN_IF => (true, false),
FILE_OVERWRITE => (false, true),
FILE_OVERWRITE_IF | FILE_SUPERSEDE => (true, true),
_ => return Err(STATUS_INVALID_PARAMETER),
};
let exclusive = create_disposition == FILE_CREATE;
let created = if may_create {
let made = if want_dir {
block_on(self.store.mkdir(&path))
} else {
block_on(self.store.create(&path))
};
match made {
Ok(stat) => Some(stat),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists && !exclusive => None,
Err(err) => return Err(nt_status(&err)),
}
} else {
None
};
let new_file_created = created.is_some();
let stat = match created {
Some(stat) => stat,
None => {
let stat = block_on(self.store.stat(&path)).map_err(|err| nt_status(&err))?;
if may_truncate && stat.kind == DirentKind::File {
block_on(self.store.truncate(&path, 0)).map_err(|err| nt_status(&err))?;
}
stat
}
};
let is_dir = stat.kind == DirentKind::Dir;
if is_dir && want_file {
return Err(STATUS_FILE_IS_A_DIRECTORY);
}
if !is_dir && want_dir {
return Err(STATUS_NOT_A_DIRECTORY);
}
Ok(CreateFileInfo {
context: (),
is_dir,
new_file_created,
})
}
fn cleanup(
&'h self,
file_name: &U16CStr,
info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) {
if !info.delete_on_close() {
return;
}
let Ok(path) = store_path(file_name) else {
return;
};
let removed = if info.is_dir() {
block_on(self.store.rmdir(&path))
} else {
block_on(self.store.unlink(&path))
};
if let Err(err) = removed {
eprintln!("virtx: removing {} failed: {err}", path.display());
}
}
fn read_file(
&'h self,
file_name: &U16CStr,
offset: i64,
buffer: &mut [u8],
_info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<u32> {
let path = store_path(file_name)?;
let offset = u64::try_from(offset).map_err(|_| STATUS_INVALID_PARAMETER)?;
let read =
block_on(self.store.read_at(&path, buffer, offset)).map_err(|e| nt_status(&e))?;
Ok(read as u32)
}
fn write_file(
&'h self,
file_name: &U16CStr,
offset: i64,
buffer: &[u8],
info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<u32> {
let path = store_path(file_name)?;
let offset = if info.write_to_eof() {
block_on(self.store.stat(&path))
.map_err(|e| nt_status(&e))?
.size
} else {
u64::try_from(offset).map_err(|_| STATUS_INVALID_PARAMETER)?
};
let mut written = 0usize;
while written < buffer.len() {
let n = block_on(self.store.write_at(
&path,
&buffer[written..],
offset + written as u64,
))
.map_err(|e| nt_status(&e))?;
if n == 0 {
return Err(STATUS_UNEXPECTED_IO_ERROR);
}
written += n;
}
Ok(written as u32)
}
fn flush_file_buffers(
&'h self,
file_name: &U16CStr,
_info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<()> {
let path = store_path(file_name)?;
block_on(self.store.flush(&path)).map_err(|e| nt_status(&e))
}
fn get_file_information(
&'h self,
file_name: &U16CStr,
_info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<FileInfo> {
let path = store_path(file_name)?;
let stat = block_on(self.store.stat(&path)).map_err(|e| nt_status(&e))?;
let attr = attr_for(&stat);
Ok(FileInfo {
attributes: file_attributes(stat.kind),
creation_time: attr.crtime,
last_access_time: attr.atime,
last_write_time: attr.mtime,
file_size: attr.size,
number_of_links: 1,
file_index: file_index(&path),
})
}
fn find_files(
&'h self,
file_name: &U16CStr,
mut fill_find_data: impl FnMut(&FindData) -> FillDataResult,
_info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<()> {
let path = store_path(file_name)?;
let children = block_on(self.store.list(&path)).map_err(|e| nt_status(&e))?;
for child in children {
let stat = match child.stat() {
Some(stat) => stat.clone(),
None => match block_on(self.store.stat(&path.join(&child.name))) {
Ok(stat) => stat,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(nt_status(&err)),
},
};
let Ok(name) = U16CString::from_str(&child.name) else {
continue;
};
let attr = attr_for(&stat);
let filled = fill_find_data(&FindData {
attributes: file_attributes(stat.kind),
creation_time: attr.crtime,
last_access_time: attr.atime,
last_write_time: attr.mtime,
file_size: attr.size,
file_name: name,
});
match filled {
Ok(()) => {}
Err(FillDataError::NameTooLong) => continue,
Err(err) => return Err(err.into()),
}
}
Ok(())
}
fn set_file_attributes(
&'h self,
_file_name: &U16CStr,
_file_attributes: u32,
_info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<()> {
Ok(())
}
fn set_file_time(
&'h self,
_file_name: &U16CStr,
_creation_time: FileTimeOperation,
_last_access_time: FileTimeOperation,
_last_write_time: FileTimeOperation,
_info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<()> {
Ok(())
}
fn delete_file(
&'h self,
file_name: &U16CStr,
info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<()> {
if !info.delete_on_close() {
return Ok(());
}
let path = store_path(file_name)?;
let stat = block_on(self.store.stat(&path)).map_err(|e| nt_status(&e))?;
match stat.kind {
DirentKind::File => Ok(()),
DirentKind::Dir => Err(STATUS_FILE_IS_A_DIRECTORY),
}
}
fn delete_directory(
&'h self,
file_name: &U16CStr,
info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<()> {
if !info.delete_on_close() {
return Ok(());
}
let path = store_path(file_name)?;
let children = block_on(self.store.list(&path)).map_err(|e| nt_status(&e))?;
if children.is_empty() {
Ok(())
} else {
Err(STATUS_DIRECTORY_NOT_EMPTY)
}
}
fn move_file(
&'h self,
file_name: &U16CStr,
new_file_name: &U16CStr,
replace_if_existing: bool,
_info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<()> {
let from = store_path(file_name)?;
let to = store_path(new_file_name)?;
if !replace_if_existing && from != to && block_on(self.store.stat(&to)).is_ok() {
return Err(STATUS_OBJECT_NAME_COLLISION);
}
block_on(self.store.rename(&from, &to)).map_err(|e| nt_status(&e))
}
fn set_end_of_file(
&'h self,
file_name: &U16CStr,
offset: i64,
_info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<()> {
let path = store_path(file_name)?;
let size = u64::try_from(offset).map_err(|_| STATUS_INVALID_PARAMETER)?;
block_on(self.store.truncate(&path, size)).map_err(|e| nt_status(&e))
}
fn set_allocation_size(
&'h self,
file_name: &U16CStr,
alloc_size: i64,
_info: &OperationInfo<'c, 'h, Self>,
_context: &'c Self::Context,
) -> OperationResult<()> {
let path = store_path(file_name)?;
let size = u64::try_from(alloc_size).map_err(|_| STATUS_INVALID_PARAMETER)?;
let stat = block_on(self.store.stat(&path)).map_err(|e| nt_status(&e))?;
if stat.size > size {
block_on(self.store.truncate(&path, size)).map_err(|e| nt_status(&e))?;
}
Ok(())
}
fn get_disk_free_space(
&'h self,
_info: &OperationInfo<'c, 'h, Self>,
) -> OperationResult<DiskSpaceInfo> {
let bytes = TOTAL_BLOCKS * BLOCK_SIZE;
Ok(DiskSpaceInfo {
byte_count: bytes,
free_byte_count: bytes,
available_byte_count: bytes,
})
}
fn get_volume_information(
&'h self,
_info: &OperationInfo<'c, 'h, Self>,
) -> OperationResult<VolumeInfo> {
Ok(VolumeInfo {
name: U16CString::from_str(VOLUME_NAME).expect("volume name has no interior nul"),
serial_number: 0,
max_component_length: NAME_MAX,
fs_flags: FILE_CASE_PRESERVED_NAMES | FILE_CASE_SENSITIVE_SEARCH | FILE_UNICODE_ON_DISK,
fs_name: U16CString::from_str(FS_NAME).expect("fs name has no interior nul"),
})
}
fn mounted(
&'h self,
_mount_point: &U16CStr,
_info: &OperationInfo<'c, 'h, Self>,
) -> OperationResult<()> {
self.report(Ok(()));
Ok(())
}
fn unmounted(&'h self, _info: &OperationInfo<'c, 'h, Self>) -> OperationResult<()> {
Ok(())
}
}
fn block_on<F: std::future::Future>(fut: F) -> F::Output {
super::block_on(fut)
}
fn store_path(name: &U16CStr) -> Result<PathBuf, NTSTATUS> {
const SEPARATOR: u16 = b'\\' as u16;
const COLON: u16 = b':' as u16;
let mut path = PathBuf::from("/");
for part in name.as_slice().split(|&unit| unit == SEPARATOR) {
if part.is_empty() {
continue;
}
if part.contains(&COLON) {
return Err(STATUS_OBJECT_NAME_INVALID);
}
let component = OsString::from_wide(part);
if component == "." || component == ".." {
return Err(STATUS_OBJECT_NAME_INVALID);
}
path.push(component);
}
Ok(path)
}
fn nt_status(err: &io::Error) -> NTSTATUS {
if let Some(code) = err.raw_os_error() {
return map_win32_error_to_ntstatus(code as u32);
}
match err.kind() {
io::ErrorKind::NotFound => STATUS_OBJECT_NAME_NOT_FOUND,
io::ErrorKind::NotADirectory => STATUS_NOT_A_DIRECTORY,
io::ErrorKind::IsADirectory => STATUS_FILE_IS_A_DIRECTORY,
io::ErrorKind::AlreadyExists => STATUS_OBJECT_NAME_COLLISION,
io::ErrorKind::DirectoryNotEmpty => STATUS_DIRECTORY_NOT_EMPTY,
io::ErrorKind::InvalidFilename => STATUS_OBJECT_NAME_INVALID,
io::ErrorKind::InvalidInput => STATUS_INVALID_PARAMETER,
io::ErrorKind::FileTooLarge => STATUS_FILE_TOO_LARGE,
io::ErrorKind::PermissionDenied => STATUS_ACCESS_DENIED,
io::ErrorKind::StorageFull => STATUS_DISK_FULL,
io::ErrorKind::ReadOnlyFilesystem => STATUS_MEDIA_WRITE_PROTECTED,
io::ErrorKind::CrossesDevices => STATUS_NOT_SAME_DEVICE,
io::ErrorKind::Unsupported => STATUS_NOT_IMPLEMENTED,
io::ErrorKind::WriteZero => STATUS_UNEXPECTED_IO_ERROR,
_ => STATUS_UNSUCCESSFUL,
}
}
fn file_attributes(kind: DirentKind) -> u32 {
match kind {
DirentKind::Dir => FILE_ATTRIBUTE_DIRECTORY,
DirentKind::File => FILE_ATTRIBUTE_NORMAL,
}
}
fn file_index(path: &Path) -> u64 {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
fn wide(s: &str) -> U16CString {
U16CString::from_str(s).unwrap()
}
#[test]
fn the_volume_root_is_the_store_root() {
assert_eq!(store_path(&wide("\\")).unwrap(), PathBuf::from("/"));
}
#[test]
fn a_path_keeps_its_components_and_changes_its_separator() {
assert_eq!(
store_path(&wide("\\src\\main.rs")).unwrap(),
PathBuf::from("/").join("src").join("main.rs")
);
}
#[test]
fn a_relative_name_is_rooted_like_every_other() {
assert_eq!(
store_path(&wide("notes.md")).unwrap(),
PathBuf::from("/").join("notes.md")
);
}
#[test]
fn dot_components_are_refused_rather_than_resolved() {
assert_eq!(
store_path(&wide("\\src\\..\\etc")),
Err(STATUS_OBJECT_NAME_INVALID)
);
assert_eq!(store_path(&wide("\\.")), Err(STATUS_OBJECT_NAME_INVALID));
}
#[test]
fn an_alternate_stream_is_not_a_file_this_mount_has() {
assert_eq!(
store_path(&wide("\\notes.md:hidden")),
Err(STATUS_OBJECT_NAME_INVALID)
);
}
#[test]
fn a_kind_reports_one_attribute_and_not_a_pair() {
assert_eq!(file_attributes(DirentKind::Dir), FILE_ATTRIBUTE_DIRECTORY);
assert_eq!(file_attributes(DirentKind::File), FILE_ATTRIBUTE_NORMAL);
}
#[test]
fn an_index_is_the_same_every_time_it_is_asked_for() {
let path = PathBuf::from("/src/main.rs");
assert_eq!(file_index(&path), file_index(&path));
assert_ne!(file_index(&path), file_index(Path::new("/src/lib.rs")));
}
#[test]
fn a_kind_with_no_raw_number_still_lands_on_a_status() {
assert_eq!(
nt_status(&io::ErrorKind::NotFound.into()),
STATUS_OBJECT_NAME_NOT_FOUND
);
assert_eq!(
nt_status(&io::ErrorKind::DirectoryNotEmpty.into()),
STATUS_DIRECTORY_NOT_EMPTY
);
}
}