| //! The FUSE filesystem over a render, and what the host needs for it. | |
| //! | |
| //! One thread serves the kernel's requests; the gets run on the launcher's runtime, | |
| //! blocked on from that thread, so a get here is the same call a `get` tool makes. | |
| use crate::{classify, Error, Kind}; | |
| use dm_server::Memory; | |
| use fuser::{ | |
| Config, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, Generation, INodeNo, | |
| LockOwner, MountOption, OpenAccMode, OpenFlags, ReplyAttr, ReplyData, ReplyDirectory, | |
| ReplyEntry, ReplyOpen, Request, | |
| }; | |
| use nix::fcntl::{Flock, FlockArg}; | |
| use serde_json::json; | |
| use std::collections::HashMap; | |
| use std::ffi::OsStr; | |
| use std::os::unix::fs::{FileTypeExt, MetadataExt}; | |
| use std::path::{Path, PathBuf}; | |
| use std::sync::atomic::{AtomicU64, Ordering}; | |
| use std::sync::{Arc, Mutex}; | |
| use std::time::{Duration, SystemTime, UNIX_EPOCH}; | |
| /// How long the kernel may trust an answer about a name or an attribute. The render | |
| /// refreshes after a write through this service, so a listing is a second behind at most. | |
| const TTL: Duration = Duration::from_secs(1); | |
| /// The device the kernel's FUSE driver is reached through. | |
| const DEVICE: &str = "/dev/fuse"; | |
| /// The setuid helpers an unprivileged mount goes through, newest first. | |
| const HELPERS: [&str; 2] = ["fusermount3", "fusermount"]; | |
| /// A system binary on the PATH, as the mount helper itself would be found. | |
| fn on_path(name: &str) -> Option<PathBuf> { | |
| let path = std::env::var_os("PATH")?; | |
| std::env::split_paths(&path) | |
| .map(|d| d.join(name)) | |
| .find(|p| p.is_file()) | |
| } | |
| pub(crate) fn availability() -> Result<(), String> { | |
| match std::fs::metadata(DEVICE) { | |
| Ok(m) if m.file_type().is_char_device() => {} | |
| Ok(_) => return Err(format!("{DEVICE} is not the FUSE device")), | |
| Err(e) => return Err(format!("{DEVICE}: {e}; the projection is a render here")), | |
| } | |
| if HELPERS.iter().any(|h| on_path(h).is_some()) { | |
| Ok(()) | |
| } else { | |
| Err(format!( | |
| "neither {} nor {} is on PATH, so an unprivileged mount cannot be made; the \ | |
| projection is a render here", | |
| HELPERS[0], HELPERS[1] | |
| )) | |
| } | |
| } | |
| /// The record that a launcher serves the mount at `at`: an exclusive lock on the file | |
| /// beside it, `<at>.lock`, held from before the mountpoint is made until after it is | |
| /// gone. The kernel lets the lock go when the launcher dies, however it dies, so a | |
| /// sweep can tell a dead launcher's mount from a live one's without asking the mount, | |
| /// which a live launcher that is stopped does not answer. | |
| pub(crate) struct Owner { | |
| path: PathBuf, | |
| _lock: Flock<std::fs::File>, | |
| } | |
| impl Drop for Owner { | |
| fn drop(&mut self) { | |
| // removed while still locked, so no sweep takes a lock on a file going away | |
| std::fs::remove_file(&self.path).ok(); | |
| } | |
| } | |
| /// The lock file of the mount at `at`. | |
| pub(crate) fn lock_path(at: &Path) -> PathBuf { | |
| let mut name = at.as_os_str().to_owned(); | |
| name.push(".lock"); | |
| PathBuf::from(name) | |
| } | |
| fn open_lock(path: &Path, create: bool) -> std::io::Result<std::fs::File> { | |
| use std::os::unix::fs::OpenOptionsExt; | |
| std::fs::OpenOptions::new() | |
| .read(true) | |
| .write(true) | |
| .create(create) | |
| .truncate(false) | |
| .custom_flags(nix::fcntl::OFlag::O_NOFOLLOW.bits()) | |
| .open(path) | |
| } | |
| /// Whether the file at `path` is still the one `file` has open. | |
| fn still_at(path: &Path, file: &std::fs::File) -> bool { | |
| match (std::fs::symlink_metadata(path), file.metadata()) { | |
| (Ok(at), Ok(held)) => at.dev() == held.dev() && at.ino() == held.ino(), | |
| _ => false, | |
| } | |
| } | |
| /// Take the record for a mount at `at`, before its mountpoint is made. | |
| pub(crate) fn own(at: &Path) -> Result<Owner, Error> { | |
| let path = lock_path(at); | |
| let failed = |source| Error::Io { | |
| path: path.display().to_string(), | |
| source, | |
| }; | |
| if let Some(dir) = path.parent() { | |
| std::fs::create_dir_all(dir).map_err(|e| Error::Io { | |
| path: dir.display().to_string(), | |
| source: e, | |
| })?; | |
| } | |
| // twice: a sweep can remove a free record between its open here and its lock | |
| for _ in 0..2 { | |
| let file = open_lock(&path, true).map_err(failed)?; | |
| let lock = Flock::lock(file, FlockArg::LockExclusiveNonblock) | |
| .map_err(|(_, e)| failed(std::io::Error::from(e)))?; | |
| if still_at(&path, &lock) { | |
| return Ok(Owner { path, _lock: lock }); | |
| } | |
| } | |
| Err(failed(std::io::Error::other( | |
| "removed while it was being locked", | |
| ))) | |
| } | |
| /// What the record of the mount at `at` says. | |
| pub(crate) enum Held { | |
| /// A launcher holds it: its mount is not to be touched, nor asked anything. | |
| Live, | |
| /// No launcher holds it; the lock is taken here, for as long as this lives. | |
| Gone(Flock<std::fs::File>), | |
| /// There is none: a mount from before records were kept. | |
| Unrecorded, | |
| } | |
| /// Read the record of the mount at `at`. | |
| pub(crate) fn held(at: &Path) -> Held { | |
| let path = lock_path(at); | |
| let Ok(file) = open_lock(&path, false) else { | |
| return if path.exists() || path.is_symlink() { | |
| Held::Live | |
| } else { | |
| Held::Unrecorded | |
| }; | |
| }; | |
| match Flock::lock(file, FlockArg::LockExclusiveNonblock) { | |
| Ok(lock) if still_at(&path, &lock) => Held::Gone(lock), | |
| _ => Held::Live, | |
| } | |
| } | |
| /// Whether `at` is a mount whose server is gone: a killed launcher leaves one, and every | |
| /// call on it fails with "transport endpoint is not connected". The server is the | |
| /// launcher, so this is the record of whether its launcher lives. A link at `at` is not | |
| /// followed, and is not a dead mount. | |
| fn is_dead(at: &Path) -> bool { | |
| if std::fs::symlink_metadata(at).is_ok_and(|m| m.file_type().is_symlink()) { | |
| return false; | |
| } | |
| // an open, which always reaches the server: the kernel answers a stat from its | |
| // cache for as long as the attributes' TTL after the server has gone | |
| std::fs::read_dir(at) | |
| .err() | |
| .is_some_and(|e| e.raw_os_error() == Some(libc_enotconn())) | |
| } | |
| /// `ENOTCONN` without a binding crate: the value is the same on every Linux target. | |
| const fn libc_enotconn() -> i32 { | |
| 107 | |
| } | |
| /// Unmount a dead mount at `at`, through the helper, lazily, so a directory that was | |
| /// a mountpoint can be removed. Nothing is done to a mount that answers. | |
| pub(crate) fn clear_dead(at: &Path) { | |
| if !is_dead(at) { | |
| return; | |
| } | |
| for helper in HELPERS { | |
| let Some(bin) = on_path(helper) else { | |
| continue; | |
| }; | |
| let done = std::process::Command::new(bin) | |
| .arg("-uz") | |
| .arg(at) | |
| .stdout(std::process::Stdio::null()) | |
| .stderr(std::process::Stdio::null()) | |
| .status() | |
| .is_ok_and(|s| s.success()); | |
| if done { | |
| return; | |
| } | |
| } | |
| } | |
| /// Mount `memory`'s view over `render` at `at`, serving from a thread of its own. | |
| pub(crate) fn serve( | |
| memory: Arc<Memory>, | |
| render: &Path, | |
| at: &Path, | |
| ) -> Result<fuser::BackgroundSession, Error> { | |
| let rt = tokio::runtime::Handle::try_current().map_err(|e| { | |
| Error::Unavailable(format!("no runtime to serve the mount's gets from: {e}")) | |
| })?; | |
| let view = View::new(memory, render.to_path_buf(), rt); | |
| let mut cfg = Config::default(); | |
| cfg.mount_options.extend([ | |
| MountOption::RO, | |
| MountOption::NoSuid, | |
| MountOption::NoDev, | |
| MountOption::NoAtime, | |
| MountOption::FSName("amem".to_string()), | |
| MountOption::Subtype("memory".to_string()), | |
| ]); | |
| fuser::spawn_mount(view, at, &cfg).map_err(|source| Error::Mount { | |
| at: at.display().to_string(), | |
| source, | |
| }) | |
| } | |
| /// Inode numbers for the paths the kernel has been told about, stable for the session. | |
| #[derive(Default)] | |
| struct Nodes { | |
| paths: HashMap<u64, PathBuf>, | |
| inos: HashMap<PathBuf, u64>, | |
| next: u64, | |
| } | |
| impl Nodes { | |
| fn new() -> Nodes { | |
| let mut n = Nodes { | |
| next: 2, | |
| ..Nodes::default() | |
| }; | |
| n.paths.insert(1, PathBuf::new()); | |
| n.inos.insert(PathBuf::new(), 1); | |
| n | |
| } | |
| fn ino_of(&mut self, rel: &Path) -> u64 { | |
| if let Some(&i) = self.inos.get(rel) { | |
| return i; | |
| } | |
| let i = self.next; | |
| self.next += 1; | |
| self.paths.insert(i, rel.to_path_buf()); | |
| self.inos.insert(rel.to_path_buf(), i); | |
| i | |
| } | |
| fn path_of(&self, ino: u64) -> Option<PathBuf> { | |
| self.paths.get(&ino).cloned() | |
| } | |
| } | |
| /// The view: the render's shape, the memory's word. | |
| struct View { | |
| memory: Arc<Memory>, | |
| render: PathBuf, | |
| rt: tokio::runtime::Handle, | |
| session: String, | |
| nodes: Mutex<Nodes>, | |
| /// What each open file handle serves: the entry's text as the get returned it, or | |
| /// the render's bytes for a free page. | |
| open: Mutex<HashMap<u64, Arc<Vec<u8>>>>, | |
| next_fh: AtomicU64, | |
| } | |
| /// What the render holds at a path the view shows. | |
| struct Found { | |
| kind: Kind, | |
| meta: std::fs::Metadata, | |
| } | |
| fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> { | |
| m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) | |
| } | |
| impl View { | |
| fn new(memory: Arc<Memory>, render: PathBuf, rt: tokio::runtime::Handle) -> View { | |
| View { | |
| session: memory.session_id().to_string(), | |
| memory, | |
| render, | |
| rt, | |
| nodes: Mutex::new(Nodes::new()), | |
| open: Mutex::new(HashMap::new()), | |
| next_fh: AtomicU64::new(1), | |
| } | |
| } | |
| /// What the view shows at `rel`, if the render holds it in the shape the kind needs: | |
| /// a real directory for a directory, a real file for a page or an entry, never a | |
| /// link, which the render refuses to follow as well. | |
| fn look(&self, rel: &Path) -> Option<Found> { | |
| let kind = classify(rel, &self.session)?; | |
| let meta = std::fs::symlink_metadata(self.render.join(rel)).ok()?; | |
| let fits = match kind { | |
| Kind::Dir => meta.is_dir(), | |
| Kind::Free | Kind::Entry(_) => meta.is_file(), | |
| }; | |
| fits.then_some(Found { kind, meta }) | |
| } | |
| fn attr(&self, ino: u64, f: &Found) -> FileAttr { | |
| let dir = f.kind == Kind::Dir; | |
| let when = |t: std::io::Result<SystemTime>| t.unwrap_or(UNIX_EPOCH); | |
| let size = if dir { 0 } else { f.meta.len() }; | |
| FileAttr { | |
| ino: INodeNo(ino), | |
| size, | |
| blocks: size.div_ceil(512), | |
| atime: when(f.meta.accessed()), | |
| mtime: when(f.meta.modified()), | |
| ctime: when(f.meta.modified()), | |
| crtime: when(f.meta.created()), | |
| kind: if dir { | |
| FileType::Directory | |
| } else { | |
| FileType::RegularFile | |
| }, | |
| perm: if dir { 0o555 } else { 0o444 }, | |
| nlink: 1, | |
| uid: f.meta.uid(), | |
| gid: f.meta.gid(), | |
| rdev: 0, | |
| blksize: 4096, | |
| flags: 0, | |
| } | |
| } | |
| /// The get an open of entry `id` is: the memory's own, through the session's filter, | |
| /// kept on the tape as a `get` call so a re-run sees the read. Returns the entry's | |
| /// on-disk text, which is what the render would have held. | |
| async fn get(&self, id: &str) -> Result<Vec<u8>, Errno> { | |
| let args = json!({ "id": id }); | |
| match self.memory.get(id).await { | |
| Ok(e) => { | |
| let f = &e.front; | |
| let v = json!({ | |
| "id": f.id, | |
| "domain": f.domain, | |
| "type": f.entry_type.to_string(), | |
| "classification": f.classification.to_string(), | |
| "title": e.title(), | |
| "body": e.body, | |
| "via": "file", | |
| }); | |
| self.memory.tape_call("get", &args, &v, false).await.ok(); | |
| Ok(e.render().into_bytes()) | |
| } | |
| Err(err) => { | |
| let why = err.to_string(); | |
| self.memory | |
| .tape_call("get", &args, &json!({ "error": why }), true) | |
| .await | |
| .ok(); | |
| Err(match err { | |
| dm_server::Error::NotFound(_) | dm_server::Error::Denied(_) => Errno::EACCES, | |
| dm_server::Error::Moved { .. } | dm_server::Error::Gone(_) => Errno::ENOENT, | |
| _ => Errno::EIO, | |
| }) | |
| } | |
| } | |
| } | |
| fn handle(&self, bytes: Vec<u8>) -> FileHandle { | |
| let fh = self.next_fh.fetch_add(1, Ordering::SeqCst); | |
| lock(&self.open).insert(fh, Arc::new(bytes)); | |
| FileHandle(fh) | |
| } | |
| } | |
| impl Filesystem for View { | |
| fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { | |
| let Some(dir) = lock(&self.nodes).path_of(parent.0) else { | |
| return reply.error(Errno::ENOENT); | |
| }; | |
| let rel = dir.join(name); | |
| let Some(found) = self.look(&rel) else { | |
| return reply.error(Errno::ENOENT); | |
| }; | |
| let ino = lock(&self.nodes).ino_of(&rel); | |
| reply.entry(&TTL, &self.attr(ino, &found), Generation(0)); | |
| } | |
| fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option<FileHandle>, reply: ReplyAttr) { | |
| let Some(rel) = lock(&self.nodes).path_of(ino.0) else { | |
| return reply.error(Errno::ENOENT); | |
| }; | |
| match self.look(&rel) { | |
| Some(found) => reply.attr(&TTL, &self.attr(ino.0, &found)), | |
| None => reply.error(Errno::ENOENT), | |
| } | |
| } | |
| fn readdir( | |
| &self, | |
| _req: &Request, | |
| ino: INodeNo, | |
| _fh: FileHandle, | |
| offset: u64, | |
| mut reply: ReplyDirectory, | |
| ) { | |
| let Some(rel) = lock(&self.nodes).path_of(ino.0) else { | |
| return reply.error(Errno::ENOENT); | |
| }; | |
| if !self.look(&rel).is_some_and(|f| f.kind == Kind::Dir) { | |
| return reply.error(Errno::ENOTDIR); | |
| } | |
| let Ok(dir) = std::fs::read_dir(self.render.join(&rel)) else { | |
| return reply.error(Errno::EIO); | |
| }; | |
| let mut listed: Vec<(u64, FileType, std::ffi::OsString)> = vec![ | |
| (ino.0, FileType::Directory, ".".into()), | |
| (ino.0, FileType::Directory, "..".into()), | |
| ]; | |
| for e in dir.flatten() { | |
| let child = rel.join(e.file_name()); | |
| let Some(found) = self.look(&child) else { | |
| continue; | |
| }; | |
| let kind = if found.kind == Kind::Dir { | |
| FileType::Directory | |
| } else { | |
| FileType::RegularFile | |
| }; | |
| let i = lock(&self.nodes).ino_of(&child); | |
| listed.push((i, kind, e.file_name())); | |
| } | |
| // names in a stable order, so an offset means the same thing on the next call | |
| listed[2..].sort_by(|a, b| a.2.cmp(&b.2)); | |
| for (i, (ino, kind, name)) in listed.into_iter().enumerate().skip(offset as usize) { | |
| if reply.add(INodeNo(ino), i as u64 + 1, kind, name) { | |
| break; | |
| } | |
| } | |
| reply.ok(); | |
| } | |
| fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { | |
| if flags.acc_mode() != OpenAccMode::O_RDONLY { | |
| return reply.error(Errno::EROFS); | |
| } | |
| let Some(rel) = lock(&self.nodes).path_of(ino.0) else { | |
| return reply.error(Errno::ENOENT); | |
| }; | |
| let Some(found) = self.look(&rel) else { | |
| return reply.error(Errno::ENOENT); | |
| }; | |
| let bytes = match found.kind { | |
| Kind::Dir => return reply.error(Errno::EISDIR), | |
| Kind::Free => match std::fs::read(self.render.join(&rel)) { | |
| Ok(b) => b, | |
| Err(_) => return reply.error(Errno::EIO), | |
| }, | |
| Kind::Entry(id) => match self.rt.block_on(self.get(&id)) { | |
| Ok(b) => b, | |
| Err(errno) => return reply.error(errno), | |
| }, | |
| }; | |
| // direct I/O: the kernel reads until a short read rather than trusting the | |
| // size it was told, which is the render's and may differ from the record's | |
| reply.opened(self.handle(bytes), FopenFlags::FOPEN_DIRECT_IO); | |
| } | |
| #[allow(clippy::too_many_arguments)] // the trait's signature | |
| fn read( | |
| &self, | |
| _req: &Request, | |
| _ino: INodeNo, | |
| fh: FileHandle, | |
| offset: u64, | |
| size: u32, | |
| _flags: OpenFlags, | |
| _lock_owner: Option<LockOwner>, | |
| reply: ReplyData, | |
| ) { | |
| let Some(bytes) = lock(&self.open).get(&fh.0).cloned() else { | |
| return reply.error(Errno::EBADF); | |
| }; | |
| let from = usize::try_from(offset) | |
| .unwrap_or(usize::MAX) | |
| .min(bytes.len()); | |
| let to = from.saturating_add(size as usize).min(bytes.len()); | |
| reply.data(&bytes[from..to]); | |
| } | |
| fn release( | |
| &self, | |
| _req: &Request, | |
| _ino: INodeNo, | |
| fh: FileHandle, | |
| _flags: OpenFlags, | |
| _lock_owner: Option<LockOwner>, | |
| _flush: bool, | |
| reply: fuser::ReplyEmpty, | |
| ) { | |
| lock(&self.open).remove(&fh.0); | |
| reply.ok(); | |
| } | |
| } |