//! Lazy workspaces (`on get --lazy`, Linux): the workspace folder is a //! FUSE mount that shows the change's whole tree at once and fetches a file //! from the host the first time it's opened. What's written goes to a real //! folder, the workspace's backing (ONSET_HOME/lazy/), which the //! mount shows over the tree; a file deleted from the tree is remembered as //! gone. Saves read the workspace like any other: its index starts with //! every file in the tree, at the sizes and times the mount shows them, so //! only what changed is read. `on mount` starts the mount again (after a //! restart, say); any Onset command run in the folder does that too. use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use crate::app::App; use crate::db::Workspace; use crate::paths; use crate::ws; /// The time a file the mount hasn't fetched yet shows (2000-01-01), in ns. pub const MTIME_NS: i64 = 946_684_800_123_456_789; /// Where a lazy workspace's own files are. pub fn backing(workspace: &str) -> PathBuf { paths::home().join("lazy").join(workspace) } pub fn is_lazy(workspace: &str) -> bool { ws::is_workspace(&backing(workspace)) } /// The inode number a path starts with, the same each time the mount /// starts, as the index records it. pub fn ino(path: &str) -> u64 { if path.is_empty() { return 1; } let id = onset_core::id::sha256(&[b"vcs inode\n", path.as_bytes()]); u64::from_le_bytes(id.0[..8].try_into().unwrap_or_default()).max(2) } /// Whether something is mounted at `root`. pub fn mounted(root: &Path) -> bool { let Ok(info) = std::fs::read_to_string("/proc/self/mountinfo") else { return false }; let want = root.to_string_lossy(); // Field 5 is the mount point, with spaces and such as octal escapes. info.lines().any(|line| line.split(' ').nth(4).is_some_and(|m| unescape(m) == want)) } fn unescape(text: &str) -> String { let bytes = text.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'\\' && i + 3 < bytes.len() && let Ok(n) = u8::from_str_radix(&text[i + 1..i + 4], 8) { out.push(n); i += 4; } else { out.push(bytes[i]); i += 1; } } String::from_utf8_lossy(&out).into_owned() } /// The lazy workspace whose folder is `root` or holds it (this machine's). pub fn workspace_at(app: &App, root: &Path) -> Result> { Ok(app.db.all_workspaces()?.into_iter().filter(|w| root.starts_with(&w.path) && is_lazy(&w.id)).max_by_key(|w| w.path.as_os_str().len())) } /// Starts the mount for a lazy workspace, if it isn't running: `on mount /// --serve` in the background, and back once the folder shows the tree. pub fn mount(w: &Workspace) -> Result<()> { if mounted(&w.path) { return Ok(()); } std::fs::create_dir_all(&w.path)?; let log = backing(&w.id).join(ws::DIR).join("mount.log"); let out = std::fs::File::create(&log)?; let mut command = std::process::Command::new(std::env::current_exe()?); command.arg("mount").arg("--serve").arg(&w.path).stdin(std::process::Stdio::null()).stdout(out.try_clone()?).stderr(out); #[cfg(unix)] { use std::os::unix::process::CommandExt; command.process_group(0); } let mut child = command.spawn()?; for _ in 0..200 { if mounted(&w.path) { return Ok(()); } if let Ok(Some(status)) = child.try_wait() { let said = std::fs::read_to_string(&log).unwrap_or_default(); return Err(anyhow!("the mount at {} stopped ({status}): {}", w.path.display(), said.trim())); } std::thread::sleep(std::time::Duration::from_millis(50)); } Err(anyhow!("the mount at {} didn't start; see {}", w.path.display(), log.display())) } /// Stops a lazy workspace's mount (its backing keeps everything). pub fn unmount(root: &Path) -> Result<()> { if !mounted(root) { return Ok(()); } for tool in ["fusermount3", "fusermount"] { if std::process::Command::new(tool).arg("-u").arg(root).status().is_ok_and(|s| s.success()) { return Ok(()); } } Err(anyhow!("couldn't unmount {} (is something still using it?)", root.display())) } /// `on mount [folder] [--stop]`: a lazy workspace's mount, started again or stopped. pub fn run(app: &App, folder: Option, stop: bool, serve_now: bool) -> crate::out::Out { use crate::out::{Fail, Report}; let path = crate::sys::canonical(&app.cwd.join(folder.unwrap_or_else(|| PathBuf::from(".")))).map_err(|e| Fail::new("not_found", e.to_string()))?; if serve_now { serve(app, &path)?; return Ok(Report::new()); } let w = workspace_at(app, &path)?.ok_or_else(|| { Fail::new("not_found", format!("{} isn't a lazy workspace", path.display())).state("nothing changed").next("on get --lazy makes one") })?; let mut r = Report::new(); if stop { unmount(&w.path)?; r.line("stopped", format!("{} (its work is kept; on mount starts it again)", w.path.display())); } else { mount(&w)?; r.line("mounted", w.path.display().to_string()); } r.set("workspace", w.path.display().to_string()).set("mounted", !stop); Ok(r) } /// Whether this system has lazy workspaces. const LAZY: bool = cfg!(target_os = "linux"); /// What a file the mount hasn't fetched yet shows: its size, the fixed /// time, and the inode its path starts with. fn shown(path: &str, size: u64) -> ws::Stat { ws::Stat { size, mtime_ns: MTIME_NS, ino: ino(path) } } /// Makes workspace `workspace` a lazy one of `tree`: its folders and its /// files' sizes from the host, and the index the mount matches. Answers /// that index (the workspace's first). pub fn prepare(app: &App, project: &crate::db::Project, workspace: &str, tree: &onset_core::Id) -> Result { use onset_core::tree::{self, EntryKind}; if !LAZY { return Err(anyhow!("lazy workspaces need Linux for now (macOS comes with FSKit)")); } let remote = crate::remote::Remote::for_project(project); app.store.ingest(&remote.get_bytes(&format!("/trees/{tree}?folders=1"))?)?; #[derive(serde::Deserialize)] struct Sizes { sizes: std::collections::HashMap, } let sizes: Sizes = remote.get_json(&format!("/trees/{tree}/sizes"))?; // Sealed folders: ones this machine can't open are left out (a save keeps them as the change has them); // the rest come now, since the host's sizes are their sealed ones. let seals = crate::seal::Seals::load(&app.db, &project.id)?; let closed = seals.unreadable(); let all = tree::flatten(&app.store, tree)?; let sealed: Vec<&String> = all.keys().filter(|p| seals.folder_of(p).is_some() && !closed.iter().any(|f| tree::under(p, f))).collect(); if !sealed.is_empty() { app.store.ingest(&remote.post_for_bytes(&format!("/trees/{tree}/files"), &serde_json::json!({ "paths": sealed }))?)?; } let mut base = ws::Index::default(); for (path, e) in all { if closed.iter().any(|f| tree::under(&path, f)) { continue; } let size = if e.kind == EntryKind::Gitlink { 0 } else if seals.folder_of(&path).is_some() { app.store.read_file(&e.id).map(|b| b.len() as u64).unwrap_or(0) } else { sizes.sizes.get(&e.id.hex()).copied().unwrap_or(0) }; let stat = shown(&path, size); base.entries.insert(path, ws::IndexEntry { entry: e, stat }); } base.tree = Some(*tree); let dir = backing(workspace); std::fs::create_dir_all(dir.join(ws::DIR))?; base.save_as(&dir, "lazy-base")?; Ok(base) } #[cfg(target_os = "linux")] pub use mount_fs::serve; #[cfg(not(target_os = "linux"))] pub fn serve(_app: &App, _root: &Path) -> Result<()> { Err(anyhow!("lazy workspaces need Linux for now (macOS comes with FSKit)")) } #[cfg(target_os = "linux")] mod mount_fs { use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::ffi::{CString, OsStr}; use std::fs; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Result, anyhow}; use fuser::{ AccessFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, Generation, INodeNo, LockOwner, MountOption, OpenFlags, RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyStatfs, ReplyWrite, Request, TimeOrNow, WriteFlags, }; use serde_json::json; use onset_core::Id; use onset_core::tree::{Entry, EntryKind}; use super::{MTIME_NS, backing, ino}; use crate::app::App; use crate::objects::Store; use crate::remote::Remote; use crate::ws::{self, Index}; /// `on mount --serve `: runs the mount until it's unmounted. pub fn serve(app: &App, root: &Path) -> Result<()> { let w = super::workspace_at(app, root)?.filter(|w| w.path == root).ok_or_else(|| anyhow!("{} isn't a lazy workspace on this machine", root.display()))?; let project = app.db.project(&w.project)?.ok_or_else(|| anyhow!("the workspace's project isn't known here"))?; let dir = backing(&w.id); let base = Index::load_from(&dir, "lazy-base").ok_or_else(|| anyhow!("{} has no lazy index", dir.display()))?; let tree = base.tree.ok_or_else(|| anyhow!("{} has no tree", dir.display()))?; let mut files = BTreeMap::new(); let mut dirs: HashMap> = HashMap::new(); dirs.insert(String::new(), BTreeSet::new()); for (path, e) in base.entries { let mut child = path.as_str(); while let Some((parent, name)) = child.rsplit_once('/') { dirs.entry(parent.to_string()).or_default().insert(name.to_string()); child = parent; } dirs.entry(String::new()).or_default().insert(child.to_string()); if e.entry.kind == EntryKind::Gitlink { dirs.entry(path.clone()).or_default(); } files.insert(path, (e.entry, e.stat.size)); } let gone: BTreeSet = fs::read_to_string(dir.join(ws::DIR).join("lazy-gone")).unwrap_or_default().lines().filter(|l| !l.is_empty()).map(str::to_string).collect(); let mut state = State { inodes: HashMap::new(), paths: HashMap::new(), gone, handles: HashMap::new(), next_fh: 1 }; state.inodes.insert(1, String::new()); state.paths.insert(String::new(), 1); let fs_ = Lazy { backing: dir, files, dirs, tree, store: Store::open(&crate::paths::objects())?, remote: Remote::for_project(&project), state: Mutex::new(state), }; let mut config = Config::default(); config.mount_options = vec![MountOption::FSName("onset".into()), MountOption::Subtype("onset".into()), MountOption::DefaultPermissions, MountOption::NoAtime]; fuser::mount(fs_, root, &config)?; Ok(()) } const TTL_ENTRY: Duration = Duration::from_secs(1); /// Attributes are asked for every time: a save compares them with the index. const TTL_ATTR: Duration = Duration::ZERO; enum Node { /// In the backing: written here, or fetched. Real(fs::Metadata), /// In the tree, not fetched yet: (entry, size). File(Entry, u64), Dir, } impl Node { fn is_dir(&self) -> bool { match self { Node::Real(m) => m.is_dir(), Node::File(..) => false, Node::Dir => true, } } } struct State { inodes: HashMap, paths: HashMap, /// Paths in the tree deleted here (and everything under them). gone: BTreeSet, handles: HashMap, next_fh: u64, } struct Lazy { backing: PathBuf, files: BTreeMap, dirs: HashMap>, tree: Id, store: Store, remote: Remote, state: Mutex, } fn join(dir: &str, name: &str) -> String { if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") } } fn parent(path: &str) -> &str { path.rsplit_once('/').map(|(p, _)| p).unwrap_or("") } fn time(ns: i64) -> SystemTime { UNIX_EPOCH + Duration::from_nanos(ns.max(0) as u64) } fn cpath(path: &Path) -> Result { CString::new(path.as_os_str().as_bytes()).map_err(|_| Errno::EINVAL) } fn timespec(t: Option) -> libc::timespec { match t { None => libc::timespec { tv_sec: 0, tv_nsec: libc::UTIME_OMIT }, Some(TimeOrNow::Now) => libc::timespec { tv_sec: 0, tv_nsec: libc::UTIME_NOW }, Some(TimeOrNow::SpecificTime(t)) => { let d = t.duration_since(UNIX_EPOCH).unwrap_or_default(); libc::timespec { tv_sec: d.as_secs() as libc::time_t, tv_nsec: d.subsec_nanos() as libc::c_long } } } } fn set_times(path: &Path, atime: Option, mtime: Option) -> Result<(), Errno> { let c = cpath(path)?; let times = [timespec(atime), timespec(mtime)]; // SAFETY: a NUL-terminated path and two timespecs, as utimensat takes. if unsafe { libc::utimensat(libc::AT_FDCWD, c.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW) } != 0 { return Err(Errno::from(std::io::Error::last_os_error())); } Ok(()) } fn io(e: std::io::Error) -> Errno { Errno::from(e) } impl Lazy { fn real(&self, path: &str) -> PathBuf { if path.is_empty() { self.backing.clone() } else { self.backing.join(path) } } fn in_tree(&self, path: &str) -> bool { self.files.contains_key(path) || self.dirs.contains_key(path) } /// Whether `path`, or a folder holding it, was deleted from the tree here. fn hidden(&self, st: &State, path: &str) -> bool { let mut p = path; loop { if st.gone.contains(p) { return true; } match p.rsplit_once('/') { Some((up, _)) => p = up, None => return false, } } } fn find(&self, st: &State, path: &str) -> Option { if let Ok(m) = fs::symlink_metadata(self.real(path)) { return Some(Node::Real(m)); } if self.hidden(st, path) { return None; } // A real file where a folder holding it was. let mut p = path; while let Some((up, _)) = p.rsplit_once('/') { p = up; if fs::symlink_metadata(self.real(p)).is_ok_and(|m| !m.is_dir()) { return None; } } match self.files.get(path) { Some((e, _)) if e.kind == EntryKind::Gitlink => Some(Node::Dir), Some((e, size)) => Some(Node::File(*e, *size)), None if self.dirs.contains_key(path) => Some(Node::Dir), None => None, } } fn ino_of(&self, st: &mut State, path: &str) -> u64 { if let Some(i) = st.paths.get(path) { return *i; } let mut i = ino(path); while st.inodes.get(&i).is_some_and(|p| p != path) { i = i.wrapping_add(1).max(2); } st.inodes.insert(i, path.to_string()); st.paths.insert(path.to_string(), i); i } fn attr(&self, st: &mut State, path: &str, node: &Node) -> FileAttr { let ino = INodeNo(self.ino_of(st, path)); let (uid, gid) = (unsafe { libc::getuid() }, unsafe { libc::getgid() }); let at = time(MTIME_NS); let base = FileAttr { ino, size: 0, blocks: 0, atime: at, mtime: at, ctime: at, crtime: at, kind: FileType::Directory, perm: 0o755, nlink: 2, uid, gid, rdev: 0, blksize: 4096, flags: 0, }; match node { Node::Real(m) => { let kind = if m.is_dir() { FileType::Directory } else if m.file_type().is_symlink() { FileType::Symlink } else { FileType::RegularFile }; FileAttr { size: m.len(), blocks: m.blocks(), atime: time(m.atime() * 1_000_000_000 + m.atime_nsec()), mtime: time(m.mtime() * 1_000_000_000 + m.mtime_nsec()), ctime: time(m.ctime() * 1_000_000_000 + m.ctime_nsec()), crtime: time(m.mtime() * 1_000_000_000 + m.mtime_nsec()), kind, perm: (m.mode() & 0o7777) as u16, nlink: m.nlink() as u32, uid: m.uid(), gid: m.gid(), ..base } } Node::File(e, size) => FileAttr { size: *size, blocks: size.div_ceil(512), kind: if e.kind == EntryKind::Link { FileType::Symlink } else { FileType::RegularFile }, perm: match e.kind { EntryKind::Exec => 0o755, EntryKind::Link => 0o777, _ => 0o644, }, nlink: 1, ..base }, Node::Dir => base, } } /// What's in folder `path`: the backing's files over the tree's. fn listing(&self, st: &State, path: &str) -> Vec<(String, Node)> { let mut names = BTreeSet::new(); if let Ok(read) = fs::read_dir(self.real(path)) { for e in read.flatten() { if let Some(n) = e.file_name().to_str() { names.insert(n.to_string()); } } } if let Some(kids) = self.dirs.get(path) { names.extend(kids.iter().cloned()); } names.into_iter().filter_map(|n| self.find(st, &join(path, &n)).map(|node| (n, node))).collect() } /// A file's content from the store, fetched from the host first if it isn't here. fn content(&self, path: &str, entry: &Entry) -> Result> { if let Ok(bytes) = self.store.read_file(&entry.id) { return Ok(bytes); } let bundle = self.remote.post_for_bytes(&format!("/trees/{}/files", self.tree), &json!({ "paths": [path] }))?; self.store.ingest(&bundle)?; Ok(self.store.read_file(&entry.id)?) } fn real_dir(&self, path: &str) -> Result<(), Errno> { fs::create_dir_all(self.real(path)).map_err(io) } /// A file in the tree, fetched into the backing: from here on it's an ordinary file. fn copy_up(&self, path: &str, entry: &Entry) -> Result<(), Errno> { let bytes = self.content(path, entry).map_err(|e| { eprintln!("on mount: couldn't fetch {path}: {e:#}"); Errno::EIO })?; self.real_dir(parent(path))?; let real = self.real(path); if entry.kind == EntryKind::Link { std::os::unix::fs::symlink(OsStr::from_bytes(&bytes), &real).map_err(io)?; } else { fs::write(&real, &bytes).map_err(io)?; fs::set_permissions(&real, fs::Permissions::from_mode(if entry.kind == EntryKind::Exec { 0o755 } else { 0o644 })).map_err(io)?; } // The time it showed before, so the index still knows it. let shown = Some(TimeOrNow::SpecificTime(time(MTIME_NS))); set_times(&real, shown, shown) } /// `path` and everything under it into the backing (before it moves). fn copy_up_all(&self, st: &State, path: &str) -> Result<(), Errno> { match self.find(st, path) { Some(Node::File(e, _)) => self.copy_up(path, &e), Some(node) if node.is_dir() => { self.real_dir(path)?; for (name, _) in self.listing(st, path) { self.copy_up_all(st, &join(path, &name))?; } Ok(()) } _ => Ok(()), } } fn save_gone(&self, st: &State) { let text: String = st.gone.iter().map(|p| format!("{p}\n")).collect(); let _ = fs::write(self.backing.join(ws::DIR).join("lazy-gone"), text); } fn path_of(&self, st: &State, ino: INodeNo) -> Result { st.inodes.get(&ino.0).cloned().ok_or(Errno::ENOENT) } fn child(&self, st: &State, parent: INodeNo, name: &OsStr) -> Result { let dir = self.path_of(st, parent)?; let name = name.to_str().filter(|n| !n.is_empty() && !n.contains('/')).ok_or(Errno::EINVAL)?; Ok(join(&dir, name)) } fn entry_reply(&self, st: &mut State, path: &str, reply: ReplyEntry) { match self.find(st, path) { Some(node) => { let a = self.attr(st, path, &node); reply.entry(&TTL_ENTRY, &a, Generation(0)); } None => reply.error(Errno::ENOENT), } } } impl Filesystem for Lazy { fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { let mut st = self.state.lock().unwrap(); match self.child(&st, parent, name) { Ok(path) => self.entry_reply(&mut st, &path, reply), Err(_) => reply.error(Errno::ENOENT), } } fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { let mut st = self.state.lock().unwrap(); let Ok(path) = self.path_of(&st, ino) else { return reply.error(Errno::ENOENT) }; match self.find(&st, &path) { Some(node) => { let a = self.attr(&mut st, &path, &node); reply.attr(&TTL_ATTR, &a); } None => reply.error(Errno::ENOENT), } } fn setattr( &self, _req: &Request, ino: INodeNo, mode: Option, _uid: Option, _gid: Option, size: Option, atime: Option, mtime: Option, _ctime: Option, fh: Option, _crtime: Option, _chgtime: Option, _bkuptime: Option, _flags: Option, reply: ReplyAttr, ) { let mut st = self.state.lock().unwrap(); let result = (|| -> Result<(), Errno> { let path = self.path_of(&st, ino)?; match self.find(&st, &path).ok_or(Errno::ENOENT)? { Node::File(e, _) => self.copy_up(&path, &e)?, Node::Dir => self.real_dir(&path)?, Node::Real(_) => {} } let real = self.real(&path); if let Some(m) = mode { fs::set_permissions(&real, fs::Permissions::from_mode(m & 0o7777)).map_err(io)?; } if let Some(s) = size { match fh.and_then(|h| st.handles.get(&h.0)) { Some(f) => f.set_len(s).map_err(io)?, None => fs::OpenOptions::new().write(true).open(&real).and_then(|f| f.set_len(s)).map_err(io)?, } } if atime.is_some() || mtime.is_some() { set_times(&real, atime, mtime)?; } Ok(()) })(); match result { Ok(()) => { let Ok(path) = self.path_of(&st, ino) else { return reply.error(Errno::ENOENT) }; match self.find(&st, &path) { Some(node) => { let a = self.attr(&mut st, &path, &node); reply.attr(&TTL_ATTR, &a); } None => reply.error(Errno::ENOENT), } } Err(e) => reply.error(e), } } fn readlink(&self, _req: &Request, ino: INodeNo, reply: ReplyData) { let st = self.state.lock().unwrap(); let Ok(path) = self.path_of(&st, ino) else { return reply.error(Errno::ENOENT) }; match self.find(&st, &path) { Some(Node::Real(_)) => match fs::read_link(self.real(&path)) { Ok(target) => reply.data(target.as_os_str().as_bytes()), Err(e) => reply.error(io(e)), }, Some(Node::File(e, _)) if e.kind == EntryKind::Link => match self.content(&path, &e) { Ok(bytes) => reply.data(&bytes), Err(_) => reply.error(Errno::EIO), }, Some(_) => reply.error(Errno::EINVAL), None => reply.error(Errno::ENOENT), } } fn mkdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, mode: u32, umask: u32, reply: ReplyEntry) { let mut st = self.state.lock().unwrap(); let result = (|| -> Result { let path = self.child(&st, parent, name)?; if self.find(&st, &path).is_some() { return Err(Errno::EEXIST); } self.real_dir(parent_of(&path))?; fs::create_dir(self.real(&path)).map_err(io)?; fs::set_permissions(self.real(&path), fs::Permissions::from_mode(mode & !umask & 0o7777)).map_err(io)?; Ok(path) })(); match result { Ok(path) => self.entry_reply(&mut st, &path, reply), Err(e) => reply.error(e), } } fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { let mut st = self.state.lock().unwrap(); let result = (|| -> Result<(), Errno> { let path = self.child(&st, parent, name)?; match self.find(&st, &path).ok_or(Errno::ENOENT)? { node if node.is_dir() => return Err(Errno::EISDIR), Node::Real(_) => fs::remove_file(self.real(&path)).map_err(io)?, _ => {} } if self.in_tree(&path) { st.gone.insert(path); self.save_gone(&st); } Ok(()) })(); match result { Ok(()) => reply.ok(), Err(e) => reply.error(e), } } fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { let mut st = self.state.lock().unwrap(); let result = (|| -> Result<(), Errno> { let path = self.child(&st, parent, name)?; let node = self.find(&st, &path).ok_or(Errno::ENOENT)?; if !node.is_dir() { return Err(Errno::ENOTDIR); } if !self.listing(&st, &path).is_empty() { return Err(Errno::ENOTEMPTY); } if let Node::Real(_) = node { fs::remove_dir(self.real(&path)).map_err(io)?; } if self.in_tree(&path) { st.gone.insert(path); self.save_gone(&st); } Ok(()) })(); match result { Ok(()) => reply.ok(), Err(e) => reply.error(e), } } fn symlink(&self, _req: &Request, parent: INodeNo, link_name: &OsStr, target: &Path, reply: ReplyEntry) { let mut st = self.state.lock().unwrap(); let result = (|| -> Result { let path = self.child(&st, parent, link_name)?; if self.find(&st, &path).is_some() { return Err(Errno::EEXIST); } self.real_dir(parent_of(&path))?; std::os::unix::fs::symlink(target, self.real(&path)).map_err(io)?; Ok(path) })(); match result { Ok(path) => self.entry_reply(&mut st, &path, reply), Err(e) => reply.error(e), } } fn rename(&self, _req: &Request, parent: INodeNo, name: &OsStr, newparent: INodeNo, newname: &OsStr, flags: RenameFlags, reply: ReplyEmpty) { let mut st = self.state.lock().unwrap(); let result = (|| -> Result<(String, String), Errno> { if flags.contains(RenameFlags::RENAME_EXCHANGE) { return Err(Errno::EINVAL); } let from = self.child(&st, parent, name)?; let to = self.child(&st, newparent, newname)?; if from == to { return Ok((from, to)); } if to.starts_with(&format!("{from}/")) { return Err(Errno::EINVAL); } let src = self.find(&st, &from).ok_or(Errno::ENOENT)?; if let Some(dst) = self.find(&st, &to) { if flags.contains(RenameFlags::RENAME_NOREPLACE) { return Err(Errno::EEXIST); } match (src.is_dir(), dst.is_dir()) { (true, false) => return Err(Errno::ENOTDIR), (false, true) => return Err(Errno::EISDIR), (true, true) if !self.listing(&st, &to).is_empty() => return Err(Errno::ENOTEMPTY), _ => {} } if dst.is_dir() && fs::symlink_metadata(self.real(&to)).is_ok() { fs::remove_dir(self.real(&to)).map_err(io)?; } } self.copy_up_all(&st, &from)?; self.real_dir(parent_of(&to))?; fs::rename(self.real(&from), self.real(&to)).map_err(io)?; // What the tree has at either name stays out of sight. for p in [&from, &to] { if self.in_tree(p) { st.gone.insert(p.clone()); } } self.save_gone(&st); Ok((from, to)) })(); match result { Ok((from, to)) => { // Inodes the kernel holds follow the move. let prefix = format!("{from}/"); let moved: Vec<(String, u64)> = st.paths.iter().filter(|(p, _)| **p == from || p.starts_with(&prefix)).map(|(p, i)| (p.clone(), *i)).collect(); let to_prefix = format!("{to}/"); st.paths.retain(|p, _| *p != to && !p.starts_with(&to_prefix)); for (old, i) in moved { let new = format!("{to}{}", &old[from.len()..]); st.paths.remove(&old); st.paths.insert(new.clone(), i); st.inodes.insert(i, new); } reply.ok(); } Err(e) => reply.error(e), } } fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { let mut st = self.state.lock().unwrap(); let result = (|| -> Result { let path = self.path_of(&st, ino)?; match self.find(&st, &path).ok_or(Errno::ENOENT)? { node if node.is_dir() => return Err(Errno::EISDIR), Node::File(e, _) => self.copy_up(&path, &e)?, _ => {} } let acc = flags.0 & libc::O_ACCMODE; let write = acc == libc::O_WRONLY || acc == libc::O_RDWR; let file = fs::OpenOptions::new() .read(acc == libc::O_RDONLY || acc == libc::O_RDWR) .write(write) .truncate(write && flags.0 & libc::O_TRUNC != 0) .open(self.real(&path)) .map_err(io)?; let fh = st.next_fh; st.next_fh += 1; st.handles.insert(fh, file); Ok(fh) })(); match result { Ok(fh) => reply.opened(FileHandle(fh), FopenFlags::empty()), Err(e) => reply.error(e), } } fn read(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, offset: u64, size: u32, _flags: OpenFlags, _lock: Option, reply: ReplyData) { let st = self.state.lock().unwrap(); let Some(file) = st.handles.get(&fh.0) else { return reply.error(Errno::EBADF) }; let mut buf = vec![0u8; size as usize]; let mut got = 0; while got < buf.len() { match file.read_at(&mut buf[got..], offset + got as u64) { Ok(0) => break, Ok(n) => got += n, Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} Err(e) => return reply.error(io(e)), } } reply.data(&buf[..got]); } fn write(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, offset: u64, data: &[u8], _wf: WriteFlags, _flags: OpenFlags, _lock: Option, reply: ReplyWrite) { let st = self.state.lock().unwrap(); let Some(file) = st.handles.get(&fh.0) else { return reply.error(Errno::EBADF) }; match file.write_all_at(data, offset) { Ok(()) => reply.written(data.len() as u32), Err(e) => reply.error(io(e)), } } fn flush(&self, _req: &Request, _ino: INodeNo, _fh: FileHandle, _lock: LockOwner, reply: ReplyEmpty) { reply.ok(); } fn release(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, _flags: OpenFlags, _lock: Option, _flush: bool, reply: ReplyEmpty) { self.state.lock().unwrap().handles.remove(&fh.0); reply.ok(); } fn fsync(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, _datasync: bool, reply: ReplyEmpty) { let st = self.state.lock().unwrap(); match st.handles.get(&fh.0).map(|f| f.sync_all()) { Some(Err(e)) => reply.error(io(e)), _ => reply.ok(), } } fn readdir(&self, _req: &Request, ino: INodeNo, _fh: FileHandle, offset: u64, mut reply: ReplyDirectory) { let mut st = self.state.lock().unwrap(); let Ok(path) = self.path_of(&st, ino) else { return reply.error(Errno::ENOENT) }; if !self.find(&st, &path).is_some_and(|n| n.is_dir()) { return reply.error(Errno::ENOTDIR); } let up = if path.is_empty() { 1 } else { self.ino_of(&mut st, parent(&path)) }; let mut list = vec![(ino.0, FileType::Directory, ".".to_string()), (up, FileType::Directory, "..".to_string())]; for (name, node) in self.listing(&st, &path) { let child = join(&path, &name); let kind = self.attr(&mut st, &child, &node).kind; list.push((self.ino_of(&mut st, &child), kind, name)); } for (i, (child, kind, name)) in list.into_iter().enumerate().skip(offset as usize) { if reply.add(INodeNo(child), (i + 1) as u64, kind, name) { break; } } reply.ok(); } fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) { let Ok(c) = cpath(&self.backing) else { return reply.error(Errno::EINVAL) }; let mut s: libc::statvfs = unsafe { std::mem::zeroed() }; // SAFETY: a NUL-terminated path and a statvfs to fill. if unsafe { libc::statvfs(c.as_ptr(), &mut s) } != 0 { return reply.error(Errno::from(std::io::Error::last_os_error())); } reply.statfs(s.f_blocks, s.f_bfree, s.f_bavail, s.f_files, s.f_ffree, s.f_bsize as u32, s.f_namemax as u32, s.f_frsize as u32); } fn access(&self, _req: &Request, _ino: INodeNo, _mask: AccessFlags, reply: ReplyEmpty) { reply.ok(); } fn create(&self, _req: &Request, parent: INodeNo, name: &OsStr, mode: u32, umask: u32, flags: i32, reply: ReplyCreate) { let mut st = self.state.lock().unwrap(); let result = (|| -> Result<(String, u64), Errno> { let path = self.child(&st, parent, name)?; if flags & libc::O_EXCL != 0 && self.find(&st, &path).is_some() { return Err(Errno::EEXIST); } if let Some(Node::File(e, _)) = self.find(&st, &path) { self.copy_up(&path, &e)?; } self.real_dir(parent_of(&path))?; let file = fs::OpenOptions::new() .read(true) .write(true) .create(true) .truncate(flags & libc::O_TRUNC != 0) .mode(mode & !umask & 0o7777) .open(self.real(&path)) .map_err(io)?; let fh = st.next_fh; st.next_fh += 1; st.handles.insert(fh, file); Ok((path, fh)) })(); match result { Ok((path, fh)) => match self.find(&st, &path) { Some(node) => { let a = self.attr(&mut st, &path, &node); reply.created(&TTL_ENTRY, &a, Generation(0), FileHandle(fh), FopenFlags::empty()); } None => reply.error(Errno::EIO), }, Err(e) => reply.error(e), } } } fn parent_of(path: &str) -> &str { parent(path) } }