use std::collections::{BTreeSet, HashMap}; use std::ffi::{OsStr, OsString}; use std::fs::File; use std::fs::OpenOptions; use std::io; use std::os::fd::{AsFd, AsRawFd}; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::fs::FileExt; use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use fuser::{ BsdFileFlags, Config, CopyFileRangeFlags, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, Generation, INodeNo, InitFlags, KernelConfig, LockFlags, LockOwner, MountOption, OpenFlags, RenameFlags as FuseRenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyLock, ReplyLseek, ReplyOpen, ReplyStatfs, ReplyWrite, ReplyXattr, Request, TimeOrNow, WriteFlags, }; use nix::fcntl::{FcntlArg, fcntl}; use rustix::fs::{ FallocateFlags, FlockOperation, Mode, OFlags, RenameFlags, SeekFrom, Timestamps, UTIME_NOW, UTIME_OMIT, XattrFlags, copy_file_range, fallocate, fchmod, fchown, fgetxattr, flistxattr, flock as bsd_flock, fremovexattr, fsetxattr, fstat, fstatvfs, ftruncate, futimens, mknodat, seek, }; use rustix::process::{Gid, Uid}; use rustix::time::Timespec; use crate::backing::{BackingRoot, validate_name, validate_relative}; use crate::classifier::tracked_paths_from_index; use crate::classifier::{Classification, ClassificationSnapshot, GitClassifier}; const TTL: Duration = Duration::ZERO; #[derive(Clone, Debug)] pub struct MountConfiguration { pub workspace: PathBuf, pub isolated_state: PathBuf, pub mountpoint: PathBuf, pub global_excludes: Option, pub threads: usize, } pub(crate) struct RouterConfiguration { pub workspace_label: PathBuf, pub workspace: File, pub isolated_state: File, pub mountpoint: PathBuf, pub global_excludes: Option, pub isolate_ignored: bool, pub threads: usize, } pub struct IgnoredWorkspace { host: BackingRoot, isolated: BackingRoot, classifier: GitClassifier, isolate_ignored: bool, nodes: Mutex, files: Mutex>>, directories: Mutex>, posix_lock_files: Mutex>>, next_handle: AtomicU64, } fn mount_config(threads: usize) -> Config { let mut config = Config::default(); config.mount_options.extend([ MountOption::FSName("lazaret-ignored-workspace".to_owned()), MountOption::RW, MountOption::Exec, MountOption::NoSuid, MountOption::NoDev, MountOption::DefaultPermissions, ]); config.n_threads = Some(threads.max(1)); config.clone_fd = true; config } impl IgnoredWorkspace { /// # Errors /// Refuses roots that are not distinct, descriptor-openable directories in a Git workspace. pub fn open( workspace: &Path, isolated_state: &Path, global_excludes: Option, ) -> io::Result { let workspace = workspace.canonicalize()?; let isolated_state = isolated_state.canonicalize()?; if workspace == isolated_state { return Err(io::Error::other("workspace and isolated roots must differ")); } let host = BackingRoot::open(&workspace)?; let isolated = BackingRoot::open(&isolated_state)?; Self::from_roots(workspace, host, isolated, global_excludes) } /// # Errors /// Refuses aliased or non-directory authorities and unreadable Git policy inputs. pub fn from_authorities( workspace_label: PathBuf, workspace: File, isolated_state: File, global_excludes: Option, ) -> io::Result { let host = BackingRoot::from_file(workspace)?; let isolated = BackingRoot::from_file(isolated_state)?; Self::from_roots(workspace_label, host, isolated, global_excludes) } /// # Errors /// Refuses aliased or non-directory authorities and unreadable Git policy inputs. pub fn from_authorities_with_policy( workspace_label: PathBuf, workspace: File, isolated_state: File, global_excludes: Option, isolate_ignored: bool, ) -> io::Result { let host = BackingRoot::from_file(workspace)?; let isolated = BackingRoot::from_file(isolated_state)?; Self::from_roots_with_policy( workspace_label, host, isolated, global_excludes, isolate_ignored, ) } fn from_roots( workspace_label: PathBuf, host: BackingRoot, isolated: BackingRoot, global_excludes: Option, ) -> io::Result { Self::from_roots_with_policy(workspace_label, host, isolated, global_excludes, true) } fn from_roots_with_policy( workspace_label: PathBuf, host: BackingRoot, isolated: BackingRoot, global_excludes: Option, isolate_ignored: bool, ) -> io::Result { if host.identity()? == isolated.identity()? { return Err(io::Error::other("workspace and isolated authorities alias")); } let classifier_root = BackingRoot::from_file(host.duplicate()?)?; let classifier = GitClassifier::from_root(workspace_label, classifier_root, global_excludes)?; Ok(Self { host, isolated, classifier, isolate_ignored, nodes: Mutex::new(NodeTable::new()), files: Mutex::new(HashMap::new()), directories: Mutex::new(HashMap::new()), posix_lock_files: Mutex::new(HashMap::new()), next_handle: AtomicU64::new(1), }) } /// # Errors /// Returns mount or daemon I/O failures. pub fn mount(configuration: MountConfiguration) -> io::Result<()> { let filesystem = Self::open( &configuration.workspace, &configuration.isolated_state, configuration.global_excludes, )?; let config = mount_config(configuration.threads); fuser::mount2(filesystem, configuration.mountpoint, &config) } /// # Errors /// Returns mount, readiness, control-channel, unmount, or daemon I/O failures. pub(crate) fn serve_from_authorities( configuration: RouterConfiguration, ready: impl FnOnce() -> io::Result<()>, ) -> io::Result<()> { let filesystem = Self::from_authorities_with_policy( configuration.workspace_label, configuration.workspace, configuration.isolated_state, configuration.global_excludes, configuration.isolate_ignored, )?; let session = fuser::spawn_mount2( filesystem, configuration.mountpoint, &mount_config(configuration.threads), )?; if let Err(error) = ready() { let _ = session.umount_and_join(); return Err(error); } std::io::copy(&mut std::io::stdin().lock(), &mut std::io::sink())?; session.umount_and_join() } fn root(&self, backing: Classification) -> &BackingRoot { match backing { Classification::Host => &self.host, Classification::Isolated => &self.isolated, } } fn path_for(&self, inode: INodeNo) -> io::Result { self.nodes .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .path(inode) .ok_or_else(|| io::Error::from_raw_os_error(libc::ENOENT)) } fn child_path(&self, parent: INodeNo, name: &OsStr) -> io::Result { validate_name(name)?; let path = self.path_for(parent)?.join(name); validate_relative(&path)?; Ok(path) } fn inode_for(&self, entry: &ResolvedEntry) -> INodeNo { if entry.path.as_os_str().is_empty() { return INodeNo::ROOT; } self.nodes .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .inode(entry) } fn resolve(&self, snapshot: &ClassificationSnapshot, path: &Path) -> io::Result { let host = self.host.stat(path).ok(); let isolated = self.isolated.stat(path).ok(); let directory = isolated .as_ref() .or(host.as_ref()) .is_some_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR); let backing = self.classify(snapshot, path, directory); if snapshot.is_tracked(path) { if let (Some(host), Some(isolated)) = (&host, &isolated) && host.st_mode & libc::S_IFMT != isolated.st_mode & libc::S_IFMT { return Err(io::Error::from_raw_os_error(libc::EIO)); } if backing == Classification::Host && host.is_none() && isolated.is_some() { return Err(io::Error::from_raw_os_error(libc::EIO)); } } let stat = match backing { Classification::Host => host, Classification::Isolated => isolated, } .ok_or_else(|| io::Error::from_raw_os_error(libc::ENOENT))?; Ok(ResolvedEntry { path: path.to_path_buf(), backing, stat, }) } fn create_backing( &self, snapshot: &ClassificationSnapshot, path: &Path, directory: bool, ) -> io::Result { let backing = self.classify(snapshot, path, directory); if backing == Classification::Isolated { self.isolated.ensure_parent_directories(path)?; } Ok(backing) } fn classify( &self, snapshot: &ClassificationSnapshot, path: &Path, directory: bool, ) -> Classification { if self.isolate_ignored { snapshot.classify(path, directory) } else { Classification::Host } } fn open_file(&self, inode: INodeNo, flags: i32) -> io::Result { let path = self.path_for(inode)?; let snapshot = self.classifier.snapshot_for(&path); let entry = self.resolve(&snapshot, &path)?; let file = self .root(entry.backing) .open_entry(&path, open_flags(flags), Mode::empty())?; let handle = self.next_handle.fetch_add(1, Ordering::Relaxed); self.files .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .insert( handle, Arc::new(OpenFile { file, backing: entry.backing, access_mode: flags & libc::O_ACCMODE, }), ); Ok(handle) } fn visible_entries( &self, directory: &Path, snapshot: &ClassificationSnapshot, ) -> io::Result> { let mut names = BTreeSet::new(); for root in [&self.host, &self.isolated] { match root.entries(directory) { Ok(entries) => names.extend(entries), Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error), } } let mut entries = Vec::new(); for name in names { let path = directory.join(&name); if let Ok(entry) = self.resolve(snapshot, &path) { entries.push(DirectoryEntry { inode: self.inode_for(&entry), kind: file_type(entry.stat.st_mode)?, name, }); } } Ok(entries) } fn reply_entry(&self, path: &Path, reply: ReplyEntry) { let snapshot = self.classifier.snapshot_for(path); match self .resolve(&snapshot, path) .and_then(|entry| self.attributes(&entry)) { Ok(attributes) => reply.entry(&TTL, &attributes, Generation(0)), Err(error) => reply.error(errno(&error)), } } fn attributes(&self, entry: &ResolvedEntry) -> io::Result { let stat = entry.stat; Ok(FileAttr { ino: self.inode_for(entry), size: u64::try_from(stat.st_size) .map_err(|_| io::Error::other("negative file size"))?, blocks: u64::try_from(stat.st_blocks) .map_err(|_| io::Error::other("negative block count"))?, atime: system_time(stat.st_atime, stat.st_atime_nsec), mtime: system_time(stat.st_mtime, stat.st_mtime_nsec), ctime: system_time(stat.st_ctime, stat.st_ctime_nsec), crtime: UNIX_EPOCH, kind: file_type(stat.st_mode)?, perm: (stat.st_mode & 0o7777) as u16, nlink: u32::try_from(stat.st_nlink).unwrap_or(u32::MAX), uid: stat.st_uid, gid: stat.st_gid, rdev: u32::try_from(stat.st_rdev).unwrap_or(u32::MAX), blksize: u32::try_from(stat.st_blksize).unwrap_or(u32::MAX), flags: 0, }) } fn with_file( &self, handle: FileHandle, operation: impl FnOnce(&OpenFile) -> io::Result, ) -> io::Result { let open = self .files .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&handle.0) .cloned() .ok_or_else(|| io::Error::from_raw_os_error(libc::EBADF))?; operation(&open) } fn posix_lock_file(&self, open: &OpenFile, owner: LockOwner) -> io::Result> { let stat = fstat(open.file.as_fd())?; let key = PosixLockKey { backing: open.backing, device: stat.st_dev, inode: stat.st_ino, owner: owner.0, }; let mut files = self .posix_lock_files .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(file) = files.get(&key) { return Ok(Arc::clone(file)); } let mut options = OpenOptions::new(); match open.access_mode { libc::O_RDONLY => { options.read(true); } libc::O_WRONLY => { options.write(true); } libc::O_RDWR => { options.read(true).write(true); } _ => return Err(io::Error::from_raw_os_error(libc::EINVAL)), } let file = Arc::new( options .custom_flags(libc::O_CLOEXEC) .open(format!("/proc/self/fd/{}", open.file.as_raw_fd()))?, ); let reopened = fstat(file.as_fd())?; if (reopened.st_dev, reopened.st_ino) != (stat.st_dev, stat.st_ino) { return Err(io::Error::other("lock descriptor identity changed")); } files.insert(key, Arc::clone(&file)); Ok(file) } fn release_posix_locks(&self, open: &OpenFile, owner: LockOwner) -> io::Result<()> { let stat = fstat(open.file.as_fd())?; self.posix_lock_files .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .remove(&PosixLockKey { backing: open.backing, device: stat.st_dev, inode: stat.st_ino, owner: owner.0, }); Ok(()) } } impl Filesystem for IgnoredWorkspace { fn init(&mut self, _request: &Request, config: &mut KernelConfig) -> io::Result<()> { config .add_capabilities( InitFlags::FUSE_AUTO_INVAL_DATA | InitFlags::FUSE_POSIX_ACL | InitFlags::FUSE_POSIX_LOCKS | InitFlags::FUSE_FLOCK_LOCKS, ) .map_err(|unsupported| { io::Error::other(format!( "required FUSE capabilities are unavailable: {unsupported:?}" )) }) } fn lookup(&self, _request: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { match self.child_path(parent, name) { Ok(path) => self.reply_entry(&path, reply), Err(error) => reply.error(errno(&error)), } } fn getattr( &self, _request: &Request, inode: INodeNo, handle: Option, reply: ReplyAttr, ) { let result = if let Some(handle) = handle { self.with_file(handle, |open| { let stat = fstat(open.file.as_fd())?; let path = self.path_for(inode)?; self.attributes(&ResolvedEntry { path, backing: open.backing, stat, }) }) } else { self.path_for(inode).and_then(|path| { let snapshot = self.classifier.snapshot_for(&path); self.resolve(&snapshot, &path) .and_then(|entry| self.attributes(&entry)) }) }; match result { Ok(attributes) => reply.attr(&TTL, &attributes), Err(error) => reply.error(errno(&error)), } } fn setattr( &self, _request: &Request, inode: INodeNo, mode: Option, uid: Option, gid: Option, size: Option, atime: Option, mtime: Option, _ctime: Option, handle: Option, _crtime: Option, _chgtime: Option, _bkuptime: Option, _flags: Option, reply: ReplyAttr, ) { let operation = |open: &OpenFile| -> io::Result { if let Some(mode) = mode { fchmod(open.file.as_fd(), Mode::from_raw_mode(mode & 0o7777))?; } if uid.is_some() || gid.is_some() { fchown( open.file.as_fd(), uid.map(Uid::from_raw), gid.map(Gid::from_raw), )?; } if let Some(size) = size { ftruncate(open.file.as_fd(), size)?; } if atime.is_some() || mtime.is_some() { futimens( open.file.as_fd(), &Timestamps { last_access: timestamp(atime), last_modification: timestamp(mtime), }, )?; } let path = self.path_for(inode)?; self.attributes(&ResolvedEntry { path, backing: open.backing, stat: fstat(open.file.as_fd())?, }) }; let result = if let Some(handle) = handle { self.with_file(handle, operation) } else { let path = match self.path_for(inode) { Ok(path) => path, Err(error) => { reply.error(errno(&error)); return; } }; let snapshot = self.classifier.snapshot_for(&path); let entry = match self.resolve(&snapshot, &path) { Ok(entry) => entry, Err(error) => { reply.error(errno(&error)); return; } }; if entry.stat.st_mode & libc::S_IFMT == libc::S_IFLNK { if mode.is_some() || uid.is_some() || gid.is_some() || size.is_some() { Err(io::Error::from_raw_os_error(libc::EOPNOTSUPP)) } else { self.root(entry.backing) .set_symlink_times( &path, &Timestamps { last_access: timestamp(atime), last_modification: timestamp(mtime), }, ) .and_then(|()| { self.root(entry.backing).stat(&path).and_then(|stat| { self.attributes(&ResolvedEntry { path, backing: entry.backing, stat, }) }) }) } } else { let flags = if size.is_some() { libc::O_RDWR } else { libc::O_RDONLY }; self.open_file(inode, flags).and_then(|handle| { let result = self.with_file(FileHandle(handle), operation); self.files .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .remove(&handle); result }) } }; match result { Ok(attributes) => reply.attr(&TTL, &attributes), Err(error) => reply.error(errno(&error)), } } fn readlink(&self, _request: &Request, inode: INodeNo, reply: ReplyData) { let result = self.path_for(inode).and_then(|path| { let snapshot = self.classifier.snapshot_for(&path); let entry = self.resolve(&snapshot, &path)?; self.root(entry.backing).readlink(&path) }); match result { Ok(bytes) => reply.data(&bytes), Err(error) => reply.error(errno(&error)), } } fn mknod( &self, _request: &Request, parent: INodeNo, name: &OsStr, mode: u32, umask: u32, rdev: u32, reply: ReplyEntry, ) { if matches!(mode & libc::S_IFMT, libc::S_IFCHR | libc::S_IFBLK) { reply.error(Errno::EPERM); return; } let result = (|| { let path = self.child_path(parent, name)?; let snapshot = self.classifier.snapshot_for(&path); let backing = self.create_backing(&snapshot, &path, false)?; let root = self.root(backing); let parent_path = path.parent().unwrap_or_else(|| Path::new("")); let directory = root.open_directory(parent_path)?; mknodat( directory.as_fd(), name, rustix::fs::FileType::from_raw_mode(mode), Mode::from_raw_mode(mode & !umask), rustix::fs::makedev(rdev >> 8, rdev & 0xff), )?; Ok(path) })(); match result { Ok(path) => self.reply_entry(&path, reply), Err(error) => reply.error(errno(&error)), } } fn mkdir( &self, _request: &Request, parent: INodeNo, name: &OsStr, mode: u32, umask: u32, reply: ReplyEntry, ) { let result = (|| { let path = self.child_path(parent, name)?; let snapshot = self.classifier.snapshot_for(&path); let backing = self.create_backing(&snapshot, &path, true)?; self.root(backing) .mkdir(&path, Mode::from_raw_mode((mode & !umask) & 0o7777))?; Ok(path) })(); match result { Ok(path) => self.reply_entry(&path, reply), Err(error) => reply.error(errno(&error)), } } fn unlink(&self, _request: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { self.remove(parent, name, false, reply); } fn rmdir(&self, _request: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { self.remove(parent, name, true, reply); } fn symlink( &self, _request: &Request, parent: INodeNo, name: &OsStr, target: &Path, reply: ReplyEntry, ) { let result = (|| { let path = self.child_path(parent, name)?; let snapshot = self.classifier.snapshot_for(&path); let backing = self.create_backing(&snapshot, &path, false)?; self.root(backing).symlink(target, &path)?; Ok(path) })(); match result { Ok(path) => self.reply_entry(&path, reply), Err(error) => reply.error(errno(&error)), } } fn rename( &self, _request: &Request, parent: INodeNo, name: &OsStr, new_parent: INodeNo, new_name: &OsStr, flags: FuseRenameFlags, reply: ReplyEmpty, ) { if flags.contains(FuseRenameFlags::RENAME_WHITEOUT) { reply.error(Errno::EOPNOTSUPP); return; } let result = (|| { let source = self.child_path(parent, name)?; let destination = self.child_path(new_parent, new_name)?; let snapshot = self.classifier.snapshot_for_paths(&[&source, &destination]); let source_entry = self.resolve(&snapshot, &source)?; let destination_backing = self.classify( &snapshot, &destination, source_entry.stat.st_mode & libc::S_IFMT == libc::S_IFDIR, ); if source_entry.backing != destination_backing { return Err(io::Error::from_raw_os_error(libc::EXDEV)); } if destination == Path::new(".git/index") { self.require_index_publication_safe(source_entry.backing, &source, &snapshot)?; } let flags = RenameFlags::from_bits_retain(flags.bits()); self.root(source_entry.backing) .rename(&source, &destination, flags) })(); reply_result(result, reply); } fn link( &self, _request: &Request, inode: INodeNo, new_parent: INodeNo, new_name: &OsStr, reply: ReplyEntry, ) { let result = (|| { let source = self.path_for(inode)?; let destination = self.child_path(new_parent, new_name)?; let snapshot = self.classifier.snapshot_for_paths(&[&source, &destination]); let source_entry = self.resolve(&snapshot, &source)?; let destination_backing = self.classify(&snapshot, &destination, false); if source_entry.backing != destination_backing { return Err(io::Error::from_raw_os_error(libc::EXDEV)); } self.root(source_entry.backing) .link(&source, &destination)?; Ok(destination) })(); match result { Ok(path) => self.reply_entry(&path, reply), Err(error) => reply.error(errno(&error)), } } fn open(&self, _request: &Request, inode: INodeNo, flags: OpenFlags, reply: ReplyOpen) { match self.open_file(inode, flags.0) { Ok(handle) => reply.opened(FileHandle(handle), FopenFlags::empty()), Err(error) => reply.error(errno(&error)), } } fn read( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, offset: u64, size: u32, _flags: OpenFlags, _lock_owner: Option, reply: ReplyData, ) { let result = self.with_file(handle, |open| { let mut bytes = vec![0; size as usize]; let read = open.file.read_at(&mut bytes, offset)?; bytes.truncate(read); Ok(bytes) }); match result { Ok(bytes) => reply.data(&bytes), Err(error) => reply.error(errno(&error)), } } fn write( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, offset: u64, data: &[u8], _write_flags: WriteFlags, flags: OpenFlags, _lock_owner: Option, reply: ReplyWrite, ) { let result = self.with_file(handle, |open| { let offset = if flags.0 & libc::O_APPEND != 0 { open.file.metadata()?.len() } else { offset }; open.file.write_at(data, offset) }); match result { Ok(written) => reply.written(u32::try_from(written).unwrap_or(u32::MAX)), Err(error) => reply.error(errno(&error)), } } fn flush( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, owner: LockOwner, reply: ReplyEmpty, ) { let result = self.with_file(handle, |open| { self.release_posix_locks(open, owner)?; open.file.sync_data() }); reply_result(result, reply); } fn release( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, _flags: OpenFlags, owner: Option, _flush: bool, reply: ReplyEmpty, ) { let open = self .files .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .remove(&handle.0); let result = match (open, owner) { (Some(open), Some(owner)) => self.release_posix_locks(&open, owner), (Some(_), None) => Ok(()), (None, _) => Err(io::Error::from_raw_os_error(libc::EBADF)), }; reply_result(result, reply); } fn fsync( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, datasync: bool, reply: ReplyEmpty, ) { let result = self.with_file(handle, |open| { if datasync { open.file.sync_data() } else { open.file.sync_all() } }); reply_result(result, reply); } fn getlk( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, owner: LockOwner, start: u64, end: u64, typ: i32, _pid: u32, flags: LockFlags, reply: ReplyLock, ) { if unsupported_lock_flags(flags) { reply.error(Errno::EOPNOTSUPP); return; } if flags.contains(LockFlags::FUSE_LK_FLOCK) { reply.locked(0, u64::MAX, libc::F_UNLCK, 0); return; } let result = self.with_file(handle, |open| { let file = self.posix_lock_file(open, owner)?; let mut lock = record_lock(start, end, typ)?; fcntl(file.as_fd(), FcntlArg::F_OFD_GETLK(&mut lock))?; fuse_lock(&lock) }); match result { Ok((start, end, typ, pid)) => reply.locked(start, end, typ, pid), Err(error) => reply.error(errno(&error)), } } fn setlk( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, owner: LockOwner, start: u64, end: u64, typ: i32, _pid: u32, sleep: bool, flags: LockFlags, reply: ReplyEmpty, ) { if unsupported_lock_flags(flags) { reply.error(Errno::EOPNOTSUPP); return; } let result = self.with_file(handle, |open| { if flags.contains(LockFlags::FUSE_LK_FLOCK) { if (start, end) != (0, i64::MAX as u64) { return Err(io::Error::from_raw_os_error(libc::EINVAL)); } return Ok(bsd_flock( open.file.as_fd(), bsd_lock_operation(typ, sleep)?, )?); } let file = self.posix_lock_file(open, owner)?; let lock = record_lock(start, end, typ)?; let command = if sleep { FcntlArg::F_OFD_SETLKW(&lock) } else { FcntlArg::F_OFD_SETLK(&lock) }; fcntl(file.as_fd(), command)?; Ok(()) }); reply_result(result, reply); } fn opendir(&self, _request: &Request, inode: INodeNo, _flags: OpenFlags, reply: ReplyOpen) { let result = (|| { let path = self.path_for(inode)?; let snapshot = self.classifier.snapshot_for(&path); let entry = self.resolve(&snapshot, &path)?; let directory = self.root(entry.backing).open_directory(&path)?; let mut entries = vec![ DirectoryEntry { inode, kind: FileType::Directory, name: OsString::from("."), }, DirectoryEntry { inode: if path.as_os_str().is_empty() { INodeNo::ROOT } else { let parent = path.parent().unwrap_or_else(|| Path::new("")); let parent_entry = self.resolve(&snapshot, parent)?; self.inode_for(&parent_entry) }, kind: FileType::Directory, name: OsString::from(".."), }, ]; entries.extend(self.visible_entries(&path, &snapshot)?); let handle = self.next_handle.fetch_add(1, Ordering::Relaxed); self.directories .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .insert( handle, DirectoryStream { directory, entries, _snapshot: snapshot, }, ); Ok(handle) })(); match result { Ok(handle) => reply.opened(FileHandle(handle), FopenFlags::empty()), Err(error) => reply.error(errno(&error)), } } fn readdir( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, offset: u64, mut reply: ReplyDirectory, ) { let directories = self .directories .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let Some(directory) = directories.get(&handle.0) else { reply.error(Errno::EBADF); return; }; for (index, entry) in directory .entries .iter() .enumerate() .skip(usize::try_from(offset).unwrap_or(usize::MAX)) { if reply.add(entry.inode, (index + 1) as u64, entry.kind, &entry.name) { break; } } reply.ok(); } fn releasedir( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, _flags: OpenFlags, reply: ReplyEmpty, ) { self.directories .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .remove(&handle.0); reply.ok(); } fn fsyncdir( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, datasync: bool, reply: ReplyEmpty, ) { let directories = self .directories .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let result = directories .get(&handle.0) .ok_or_else(|| io::Error::from_raw_os_error(libc::EBADF)) .and_then(|stream| { if datasync { stream.directory.sync_data() } else { stream.directory.sync_all() } }); reply_result(result, reply); } fn statfs(&self, _request: &Request, _inode: INodeNo, reply: ReplyStatfs) { match self .host .duplicate() .and_then(|file| Ok(fstatvfs(file.as_fd())?)) { Ok(stat) => reply.statfs( stat.f_blocks, stat.f_bfree, stat.f_bavail, stat.f_files, stat.f_ffree, u32::try_from(stat.f_bsize).unwrap_or(u32::MAX), u32::try_from(stat.f_namemax).unwrap_or(u32::MAX), u32::try_from(stat.f_frsize).unwrap_or(u32::MAX), ), Err(error) => reply.error(errno(&error)), } } fn setxattr( &self, _request: &Request, inode: INodeNo, name: &OsStr, value: &[u8], flags: i32, position: u32, reply: ReplyEmpty, ) { if position != 0 || !writable_xattr(name) { reply.error(Errno::ENOTSUP); return; } let result = self.with_temporary_file(inode, |file| { Ok(fsetxattr( file.as_fd(), name, value, XattrFlags::from_bits_retain(u32::from_ne_bytes(flags.to_ne_bytes())), )?) }); reply_result(result, reply); } fn getxattr( &self, _request: &Request, inode: INodeNo, name: &OsStr, size: u32, reply: ReplyXattr, ) { let result = self.with_temporary_file(inode, |file| { let mut bytes = vec![0; 64 * 1024]; let length = fgetxattr(file.as_fd(), name, &mut bytes)?; bytes.truncate(length); Ok(bytes) }); reply_xattr(result, size, reply); } fn listxattr(&self, _request: &Request, inode: INodeNo, size: u32, reply: ReplyXattr) { let result = self.with_temporary_file(inode, |file| { let mut bytes = vec![0; 64 * 1024]; let length = flistxattr(file.as_fd(), &mut bytes)?; bytes.truncate(length); Ok(bytes) }); reply_xattr(result, size, reply); } fn removexattr(&self, _request: &Request, inode: INodeNo, name: &OsStr, reply: ReplyEmpty) { if !writable_xattr(name) { reply.error(Errno::ENOTSUP); return; } let result = self.with_temporary_file(inode, |file| Ok(fremovexattr(file.as_fd(), name)?)); reply_result(result, reply); } fn create( &self, _request: &Request, parent: INodeNo, name: &OsStr, mode: u32, umask: u32, flags: i32, reply: ReplyCreate, ) { let result = (|| { let path = self.child_path(parent, name)?; let snapshot = self.classifier.snapshot_for(&path); let backing = self.create_backing(&snapshot, &path, false)?; let file = self.root(backing).open_entry( &path, open_flags(flags) | OFlags::CREATE, Mode::from_raw_mode((mode & !umask) & 0o7777), )?; let stat = fstat(file.as_fd())?; let attributes = self.attributes(&ResolvedEntry { path, backing, stat, })?; let handle = self.next_handle.fetch_add(1, Ordering::Relaxed); self.files .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .insert( handle, Arc::new(OpenFile { file, backing, access_mode: flags & libc::O_ACCMODE, }), ); Ok((attributes, handle)) })(); match result { Ok((attributes, handle)) => reply.created( &TTL, &attributes, Generation(0), FileHandle(handle), FopenFlags::empty(), ), Err(error) => reply.error(errno(&error)), } } fn fallocate( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, offset: u64, length: u64, mode: i32, reply: ReplyEmpty, ) { let result = self.with_file(handle, |open| { Ok(fallocate( open.file.as_fd(), FallocateFlags::from_bits_retain(u32::from_ne_bytes(mode.to_ne_bytes())), offset, length, )?) }); reply_result(result, reply); } fn lseek( &self, _request: &Request, _inode: INodeNo, handle: FileHandle, offset: i64, whence: i32, reply: ReplyLseek, ) { let position = match whence { libc::SEEK_SET => u64::try_from(offset).ok().map(SeekFrom::Start), libc::SEEK_CUR => Some(SeekFrom::Current(offset)), libc::SEEK_END => Some(SeekFrom::End(offset)), libc::SEEK_DATA => u64::try_from(offset).ok().map(SeekFrom::Data), libc::SEEK_HOLE => u64::try_from(offset).ok().map(SeekFrom::Hole), _ => None, }; let result = position .ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL)) .and_then(|position| { self.with_file(handle, |open| Ok(seek(open.file.as_fd(), position)?)) }); match result { Ok(position) => match i64::try_from(position) { Ok(position) => reply.offset(position), Err(_) => reply.error(Errno::EOVERFLOW), }, Err(error) => reply.error(errno(&error)), } } fn copy_file_range( &self, _request: &Request, _inode_in: INodeNo, handle_in: FileHandle, offset_in: u64, _inode_out: INodeNo, handle_out: FileHandle, offset_out: u64, length: u64, flags: CopyFileRangeFlags, reply: ReplyWrite, ) { if !flags.is_empty() { reply.error(Errno::EINVAL); return; } let files = self .files .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let result = (|| { let input = files .get(&handle_in.0) .ok_or_else(|| io::Error::from_raw_os_error(libc::EBADF))?; let output = files .get(&handle_out.0) .ok_or_else(|| io::Error::from_raw_os_error(libc::EBADF))?; if input.backing != output.backing { return Err(io::Error::from_raw_os_error(libc::EXDEV)); } let mut input_offset = offset_in; let mut output_offset = offset_out; copy_file_range( input.file.as_fd(), Some(&mut input_offset), output.file.as_fd(), Some(&mut output_offset), usize::try_from(length).unwrap_or(usize::MAX), ) .map_err(io::Error::from) })(); match result { Ok(written) => reply.written(u32::try_from(written).unwrap_or(u32::MAX)), Err(error) => reply.error(errno(&error)), } } } impl IgnoredWorkspace { fn remove(&self, parent: INodeNo, name: &OsStr, directory: bool, reply: ReplyEmpty) { let result = (|| { let path = self.child_path(parent, name)?; let snapshot = self.classifier.snapshot_for(&path); let entry = self.resolve(&snapshot, &path)?; self.root(entry.backing).unlink(&path, directory) })(); reply_result(result, reply); } fn with_temporary_file( &self, inode: INodeNo, operation: impl FnOnce(&File) -> io::Result, ) -> io::Result { let path = self.path_for(inode)?; let snapshot = self.classifier.snapshot_for(&path); let entry = self.resolve(&snapshot, &path)?; let file = self .root(entry.backing) .open_entry(&path, OFlags::RDONLY, Mode::empty())?; operation(&file) } fn require_index_publication_safe( &self, backing: Classification, source: &Path, snapshot: &ClassificationSnapshot, ) -> io::Result<()> { let bytes = self.root(backing).read_file(source, 512 * 1024 * 1024)?; for path in tracked_paths_from_index(&bytes)? { let path = PathBuf::from(OsString::from_vec(path)); if snapshot.is_tracked(&path) { continue; } let host = self.host.stat(&path).ok(); let isolated = self.isolated.stat(&path).ok(); if let Some(isolated) = isolated { let compatible = host.is_some_and(|host| { host.st_mode & libc::S_IFMT == isolated.st_mode & libc::S_IFMT }); if !compatible { return Err(io::Error::from_raw_os_error(libc::EBUSY)); } } } Ok(()) } } struct ResolvedEntry { path: PathBuf, backing: Classification, stat: rustix::fs::Stat, } struct OpenFile { file: File, backing: Classification, access_mode: i32, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] struct PosixLockKey { backing: Classification, device: u64, inode: u64, owner: u64, } struct DirectoryStream { directory: File, entries: Vec, _snapshot: ClassificationSnapshot, } struct DirectoryEntry { inode: INodeNo, kind: FileType, name: OsString, } struct NodeTable { paths: HashMap, inodes: HashMap, next_inode: u64, } impl NodeTable { fn new() -> Self { Self { paths: HashMap::from([(INodeNo::ROOT.0, PathBuf::new())]), inodes: HashMap::new(), next_inode: 2, } } fn path(&self, inode: INodeNo) -> Option { self.paths.get(&inode.0).cloned() } fn inode(&mut self, entry: &ResolvedEntry) -> INodeNo { let key = NodeKey { path: entry.path.clone(), backing: entry.backing, device: entry.stat.st_dev, inode: entry.stat.st_ino, }; if let Some(inode) = self.inodes.get(&key) { return INodeNo(*inode); } let inode = self.next_inode; self.next_inode = self.next_inode.saturating_add(1); self.paths.insert(inode, entry.path.clone()); self.inodes.insert(key, inode); INodeNo(inode) } } #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct NodeKey { path: PathBuf, backing: Classification, device: u64, inode: u64, } fn open_flags(flags: i32) -> OFlags { let mut result = OFlags::from_bits_retain(u32::from_ne_bytes(flags.to_ne_bytes())); result.remove(OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC); result } fn file_type(mode: u32) -> io::Result { match mode & libc::S_IFMT { libc::S_IFREG => Ok(FileType::RegularFile), libc::S_IFDIR => Ok(FileType::Directory), libc::S_IFLNK => Ok(FileType::Symlink), libc::S_IFIFO => Ok(FileType::NamedPipe), libc::S_IFSOCK => Ok(FileType::Socket), libc::S_IFCHR => Ok(FileType::CharDevice), libc::S_IFBLK => Ok(FileType::BlockDevice), _ => Err(io::Error::from_raw_os_error(libc::EOPNOTSUPP)), } } fn system_time(seconds: i64, nanoseconds: u64) -> SystemTime { if seconds >= 0 { UNIX_EPOCH + Duration::new( u64::try_from(seconds).unwrap_or(0), u32::try_from(nanoseconds).unwrap_or(999_999_999), ) } else { UNIX_EPOCH } } fn timestamp(value: Option) -> Timespec { match value { Some(TimeOrNow::Now) => Timespec { tv_sec: 0, tv_nsec: UTIME_NOW, }, Some(TimeOrNow::SpecificTime(value)) => { let duration = value.duration_since(UNIX_EPOCH).unwrap_or_default(); Timespec { tv_sec: i64::try_from(duration.as_secs()).unwrap_or(i64::MAX), tv_nsec: i64::from(duration.subsec_nanos()), } } None => Timespec { tv_sec: 0, tv_nsec: UTIME_OMIT, }, } } fn writable_xattr(name: &OsStr) -> bool { matches!( name.as_bytes(), b"system.posix_acl_access" | b"system.posix_acl_default" ) || name.as_bytes().starts_with(b"user.") } fn unsupported_lock_flags(flags: LockFlags) -> bool { flags.bits() & !LockFlags::FUSE_LK_FLOCK.bits() != 0 } fn bsd_lock_operation(typ: i32, sleep: bool) -> io::Result { match (typ, sleep) { (libc::F_RDLCK, true) => Ok(FlockOperation::LockShared), (libc::F_RDLCK, false) => Ok(FlockOperation::NonBlockingLockShared), (libc::F_WRLCK, true) => Ok(FlockOperation::LockExclusive), (libc::F_WRLCK, false) => Ok(FlockOperation::NonBlockingLockExclusive), (libc::F_UNLCK, true) => Ok(FlockOperation::Unlock), (libc::F_UNLCK, false) => Ok(FlockOperation::NonBlockingUnlock), _ => Err(io::Error::from_raw_os_error(libc::EINVAL)), } } fn record_lock(start: u64, end: u64, typ: i32) -> io::Result { if start > end { return Err(io::Error::from_raw_os_error(libc::EINVAL)); } let start_u64 = start; let start = i64::try_from(start_u64).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; let length = if end == u64::MAX { 0 } else { let length = end .checked_sub(start_u64) .and_then(|length| length.checked_add(1)) .ok_or_else(|| io::Error::from_raw_os_error(libc::EOVERFLOW))?; i64::try_from(length).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))? }; let typ = i16::try_from(typ).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; let whence = i16::try_from(libc::SEEK_SET).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; if !matches!( i32::from(typ), libc::F_RDLCK | libc::F_WRLCK | libc::F_UNLCK ) { return Err(io::Error::from_raw_os_error(libc::EINVAL)); } Ok(libc::flock { l_type: typ, l_whence: whence, l_start: start, l_len: length, l_pid: 0, }) } fn fuse_lock(lock: &libc::flock) -> io::Result<(u64, u64, i32, u32)> { let start = u64::try_from(lock.l_start).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; let end = if lock.l_len == 0 { u64::MAX } else { let length = u64::try_from(lock.l_len).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; start .checked_add(length.saturating_sub(1)) .ok_or_else(|| io::Error::from_raw_os_error(libc::EOVERFLOW))? }; Ok(( start, end, i32::from(lock.l_type), u32::from_ne_bytes(lock.l_pid.to_ne_bytes()), )) } fn errno(error: &io::Error) -> Errno { Errno::from_i32(error.raw_os_error().unwrap_or(libc::EIO)) } fn reply_result(result: io::Result<()>, reply: ReplyEmpty) { match result { Ok(()) => reply.ok(), Err(error) => reply.error(errno(&error)), } } fn reply_xattr(result: io::Result>, size: u32, reply: ReplyXattr) { match result { Ok(bytes) if size == 0 => reply.size(u32::try_from(bytes.len()).unwrap_or(u32::MAX)), Ok(bytes) if bytes.len() <= size as usize => reply.data(&bytes), Ok(_) => reply.error(Errno::ERANGE), Err(error) => reply.error(errno(&error)), } } #[cfg(test)] mod tests { use std::fs::{self, OpenOptions}; use std::io::{Read, Seek, Write}; use std::os::fd::{AsFd, AsRawFd}; use std::sync::atomic::{AtomicU64, Ordering}; use fuser::{Config, LockOwner, MountOption}; use nix::fcntl::{FcntlArg, fcntl}; use super::{Classification, IgnoredWorkspace, OpenFile, fuse_lock, record_lock}; static SEQUENCE: AtomicU64 = AtomicU64::new(0); #[test] fn posix_lock_owners_use_distinct_backing_lock_domains() { let fixture = Fixture::new(); repository(&fixture.workspace); let path = fixture.workspace.join("ordinary"); fs::write(&path, b"lock").unwrap(); let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap(); let open = OpenFile { file: OpenOptions::new() .read(true) .write(true) .open(path) .unwrap(), backing: Classification::Host, access_mode: libc::O_RDWR, }; let first_owner = LockOwner(101); let second_owner = LockOwner(202); let first = filesystem.posix_lock_file(&open, first_owner).unwrap(); let second = filesystem.posix_lock_file(&open, second_owner).unwrap(); assert_ne!(first.as_raw_fd(), second.as_raw_fd()); let lock = record_lock(0, 0, libc::F_WRLCK).unwrap(); fcntl(first.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap(); let error = fcntl(second.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap_err(); assert_eq!(error, nix::errno::Errno::EAGAIN); drop(first); filesystem.release_posix_locks(&open, first_owner).unwrap(); fcntl(second.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap(); } #[test] fn record_lock_ranges_round_trip() { for (start, end) in [(0, 0), (7, 19), (11, u64::MAX)] { let lock = record_lock(start, end, libc::F_RDLCK).unwrap(); assert_eq!(fuse_lock(&lock).unwrap(), (start, end, libc::F_RDLCK, 0)); } } #[test] fn disabled_isolation_routes_ignored_paths_to_the_host_backing() { let fixture = Fixture::new(); repository(&fixture.workspace); fs::write(fixture.workspace.join(".gitignore"), b"ignored/\n").unwrap(); let filesystem = IgnoredWorkspace::from_authorities_with_policy( fixture.workspace.clone(), fs::File::open(&fixture.workspace).unwrap(), fs::File::open(&fixture.isolated).unwrap(), None, false, ) .unwrap(); let path = std::path::Path::new("ignored/item"); let snapshot = filesystem.classifier.snapshot_for(path); assert_eq!( filesystem.classify(&snapshot, path, false), Classification::Host ); } #[test] #[ignore = "requires a usable host FUSE device"] fn physical_router_preserves_lock_domains_across_transition() { let fixture = Fixture::new(); repository(&fixture.workspace); fs::write(fixture.workspace.join(".gitignore"), b"ignored/\n").unwrap(); fs::create_dir(fixture.workspace.join("ignored")).unwrap(); fs::write(fixture.workspace.join("ignored/locked"), b"host").unwrap(); fs::create_dir(fixture.isolated.join("ignored")).unwrap(); fs::write(fixture.isolated.join("ignored/locked"), b"isolated").unwrap(); let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap(); let mut config = Config::default(); config.mount_options.extend([ MountOption::RW, MountOption::DefaultPermissions, MountOption::NoDev, MountOption::NoSuid, ]); let session = fuser::spawn_mount2(filesystem, &fixture.mountpoint, &config).unwrap(); let mounted_path = fixture.mountpoint.join("ignored/locked"); let old = OpenOptions::new() .read(true) .write(true) .open(&mounted_path) .unwrap(); let lock = record_lock(0, 0, libc::F_WRLCK).unwrap(); fcntl(old.as_fd(), FcntlArg::F_SETLK(&lock)).unwrap(); fs::write(fixture.workspace.join(".gitignore"), b"different/\n").unwrap(); let new = OpenOptions::new() .read(true) .write(true) .open(&mounted_path) .unwrap(); assert_eq!(fs::read(&mounted_path).unwrap(), b"host"); let isolated = OpenOptions::new() .read(true) .write(true) .open(fixture.isolated.join("ignored/locked")) .unwrap(); assert_eq!( fcntl(isolated.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap_err(), nix::errno::Errno::EAGAIN ); let host = OpenOptions::new() .read(true) .write(true) .open(fixture.workspace.join("ignored/locked")) .unwrap(); fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap(); let unlock = record_lock(0, 0, libc::F_UNLCK).unwrap(); fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&unlock)).unwrap(); fcntl(new.as_fd(), FcntlArg::F_SETLK(&lock)).unwrap(); assert_eq!( fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap_err(), nix::errno::Errno::EAGAIN ); drop(old); fcntl(isolated.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap(); drop(new); fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap(); session.umount_and_join().unwrap(); } #[test] #[ignore = "requires a usable host FUSE device"] fn physical_router_preserves_routes_transitions_and_cross_route_atomicity() { let fixture = Fixture::new(); repository(&fixture.workspace); fs::write(fixture.workspace.join(".gitignore"), b"ignored/\n").unwrap(); fs::create_dir(fixture.workspace.join("ignored")).unwrap(); fs::write(fixture.workspace.join("ignored/sentinel"), b"host").unwrap(); fs::create_dir(fixture.isolated.join("ignored")).unwrap(); fs::write(fixture.isolated.join("ignored/sentinel"), b"isolated").unwrap(); let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap(); let mut config = Config::default(); config.mount_options.extend([ MountOption::RW, MountOption::DefaultPermissions, MountOption::NoDev, MountOption::NoSuid, ]); let session = fuser::spawn_mount2(filesystem, &fixture.mountpoint, &config).unwrap(); assert_eq!( fs::read(fixture.mountpoint.join("ignored/sentinel")).unwrap(), b"isolated" ); fs::write(fixture.mountpoint.join("ordinary"), b"bidirectional").unwrap(); assert_eq!( fs::read(fixture.workspace.join("ordinary")).unwrap(), b"bidirectional" ); let mut pinned = OpenOptions::new() .read(true) .write(true) .open(fixture.mountpoint.join("ignored/sentinel")) .unwrap(); fs::write(fixture.workspace.join(".gitignore"), b"different/\n").unwrap(); assert_eq!( fs::read(fixture.mountpoint.join("ignored/sentinel")).unwrap(), b"host" ); pinned.write_all(b"-pinned").unwrap(); pinned.flush().unwrap(); let mut pinned_bytes = Vec::new(); pinned.rewind().unwrap(); pinned.read_to_end(&mut pinned_bytes).unwrap(); assert_eq!(pinned_bytes, b"-pinnedd"); assert_eq!( fs::read(fixture.workspace.join("ignored/sentinel")).unwrap(), b"host" ); fs::write(fixture.workspace.join("host-source"), b"source").unwrap(); fs::write(fixture.workspace.join(".gitignore"), b"destination\n").unwrap(); let error = fs::rename( fixture.mountpoint.join("host-source"), fixture.mountpoint.join("destination"), ) .unwrap_err(); assert_eq!(error.raw_os_error(), Some(libc::EXDEV)); assert_eq!( fs::read(fixture.workspace.join("host-source")).unwrap(), b"source" ); assert!(!fixture.isolated.join("destination").exists()); drop(pinned); session.umount_and_join().unwrap(); } fn repository(root: &std::path::Path) { fs::create_dir_all(root.join(".git/info")).unwrap(); fs::create_dir_all(root.join(".git/hooks")).unwrap(); fs::write(root.join(".git/config"), b"[core]\nignoreCase = false\n").unwrap(); fs::write(root.join(".git/info/exclude"), b"").unwrap(); let mut index = b"DIRC".to_vec(); index.extend_from_slice(&2u32.to_be_bytes()); index.extend_from_slice(&0u32.to_be_bytes()); index.extend_from_slice(&[0; 20]); fs::write(root.join(".git/index"), index).unwrap(); } struct Fixture { root: std::path::PathBuf, workspace: std::path::PathBuf, isolated: std::path::PathBuf, mountpoint: std::path::PathBuf, } impl Fixture { fn new() -> Self { let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); let root = std::env::current_dir() .unwrap() .join("target/ignored-workspace-tests") .join(format!("physical-{}-{sequence}", std::process::id())); let workspace = root.join("workspace"); let isolated = root.join("isolated"); let mountpoint = root.join("mountpoint"); fs::create_dir_all(&workspace).unwrap(); fs::create_dir(&isolated).unwrap(); fs::create_dir(&mountpoint).unwrap(); Self { root, workspace, isolated, mountpoint, } } } impl Drop for Fixture { fn drop(&mut self) { fs::remove_dir_all(&self.root).unwrap(); } } }