//! What a lazy workspace's mount shows, whatever serves it (FUSE on Linux, //! NFS on macOS): the change's tree, each file fetched from the host the //! first time it's opened, with the backing's files over it. Writes go to //! the backing (copy-up); a file deleted from the tree is remembered as gone. 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::{MetadataExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Result, anyhow}; use onset_core::Id; use onset_core::tree::{Entry, EntryKind}; use serde_json::json; use super::{MTIME_NS, backing, ino}; use crate::app::App; use crate::objects::Store; use crate::remote::Remote; use crate::ws::{self, Index}; /// An error for the system's mount call: an errno. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Errno(pub i32); impl Errno { pub const ENOENT: Errno = Errno(libc::ENOENT); pub const EIO: Errno = Errno(libc::EIO); pub const EINVAL: Errno = Errno(libc::EINVAL); pub const EEXIST: Errno = Errno(libc::EEXIST); pub const EISDIR: Errno = Errno(libc::EISDIR); pub const ENOTDIR: Errno = Errno(libc::ENOTDIR); pub const ENOTEMPTY: Errno = Errno(libc::ENOTEMPTY); } impl From for Errno { fn from(e: std::io::Error) -> Errno { Errno(e.raw_os_error().filter(|n| *n > 0).unwrap_or(libc::EIO)) } } pub 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 { pub fn is_dir(&self) -> bool { match self { Node::Real(m) => m.is_dir(), Node::File(..) => false, Node::Dir => true, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { Dir, File, Link, } /// What a node shows, before FUSE or NFS puts it in its own words. Times are /// in ns; a node's creation time is its modification time. pub struct Attr { pub ino: u64, pub kind: Kind, pub size: u64, pub blocks: u64, pub atime_ns: i64, pub mtime_ns: i64, pub ctime_ns: i64, pub perm: u16, pub nlink: u32, pub uid: u32, pub gid: u32, } /// A time to set: now, or a given one. #[derive(Clone, Copy)] pub enum SetTime { Now, At(SystemTime), } pub struct State { inodes: HashMap, paths: HashMap, /// Paths in the tree deleted here (and everything under them). gone: BTreeSet, /// Open files, for a mount that has opens (FUSE). #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub handles: HashMap, #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub next_fh: u64, } pub struct Overlay { pub backing: PathBuf, files: BTreeMap, dirs: HashMap>, tree: Id, store: Store, remote: Remote, pub state: Mutex, } pub fn join(dir: &str, name: &str) -> String { if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") } } pub fn parent(path: &str) -> &str { path.rsplit_once('/').map(|(p, _)| p).unwrap_or("") } pub fn time(ns: i64) -> SystemTime { UNIX_EPOCH + Duration::from_nanos(ns.max(0) as u64) } pub 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(SetTime::Now) => libc::timespec { tv_sec: 0, tv_nsec: libc::UTIME_NOW }, Some(SetTime::At(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 } } } } pub 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 Overlay { /// The overlay of the lazy workspace whose folder is `root`: its tree /// (from the index it started with) and its backing. pub fn load(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); Ok(Overlay { backing: dir, files, dirs, tree, store: Store::open(&crate::paths::objects())?, remote: Remote::for_project(&project), state: Mutex::new(state), }) } pub fn real(&self, path: &str) -> PathBuf { if path.is_empty() { self.backing.clone() } else { self.backing.join(path) } } pub 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, } } } pub 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, } } pub 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 } pub fn attr(&self, st: &mut State, path: &str, node: &Node) -> Attr { let ino = self.ino_of(st, path); let (uid, gid) = (unsafe { libc::getuid() }, unsafe { libc::getgid() }); let base = Attr { ino, kind: Kind::Dir, size: 0, blocks: 0, atime_ns: MTIME_NS, mtime_ns: MTIME_NS, ctime_ns: MTIME_NS, perm: 0o755, nlink: 2, uid, gid, }; match node { Node::Real(m) => { let kind = if m.is_dir() { Kind::Dir } else if m.file_type().is_symlink() { Kind::Link } else { Kind::File }; Attr { size: m.len(), blocks: m.blocks(), atime_ns: m.atime() * 1_000_000_000 + m.atime_nsec(), mtime_ns: m.mtime() * 1_000_000_000 + m.mtime_nsec(), ctime_ns: m.ctime() * 1_000_000_000 + m.ctime_nsec(), kind, perm: (m.mode() & 0o7777) as u16, nlink: m.nlink() as u32, uid: m.uid(), gid: m.gid(), ..base } } Node::File(e, size) => Attr { size: *size, blocks: size.div_ceil(512), kind: if e.kind == EntryKind::Link { Kind::Link } else { Kind::File }, perm: match e.kind { EntryKind::Exec => 0o755, EntryKind::Link => 0o777, _ => 0o644, }, nlink: 1, ..base }, Node::Dir => base, } } /// The names in folder `path`: the backing's and the tree's, some of /// which may be gone (`find` says). pub fn names(&self, path: &str) -> BTreeSet { 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 } /// What's in folder `path`: the backing's files over the tree's. pub fn listing(&self, st: &State, path: &str) -> Vec<(String, Node)> { self.names(path).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. pub 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)?) } pub 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. pub 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(SetTime::At(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); } pub fn path_of(&self, st: &State, ino: u64) -> Result { st.inodes.get(&ino).cloned().ok_or(Errno::ENOENT) } pub fn child(&self, st: &State, parent: u64, 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)) } /// A file's mode, size and times, set; it's fetched first if it's in the tree. pub fn setattr(&self, st: &State, path: &str, mode: Option, size: Option, open: Option<&fs::File>, atime: Option, mtime: Option) -> Result<(), Errno> { 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 open { 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(()) } /// The target of link `path`. pub fn readlink(&self, st: &State, path: &str) -> Result, Errno> { match self.find(st, path) { Some(Node::Real(_)) => fs::read_link(self.real(path)).map(|t| t.as_os_str().as_bytes().to_vec()).map_err(io), Some(Node::File(e, _)) if e.kind == EntryKind::Link => self.content(path, &e).map_err(|_| Errno::EIO), Some(_) => Err(Errno::EINVAL), None => Err(Errno::ENOENT), } } pub fn mkdir(&self, st: &State, path: &str, mode: u32) -> Result<(), Errno> { if self.find(st, path).is_some() { return Err(Errno::EEXIST); } self.real_dir(parent(path))?; fs::create_dir(self.real(path)).map_err(io)?; fs::set_permissions(self.real(path), fs::Permissions::from_mode(mode & 0o7777)).map_err(io) } pub fn unlink(&self, st: &mut State, path: String) -> Result<(), Errno> { 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(()) } pub fn rmdir(&self, st: &mut State, path: String) -> Result<(), Errno> { 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(()) } pub fn symlink(&self, st: &State, path: &str, target: &Path) -> Result<(), Errno> { if self.find(st, path).is_some() { return Err(Errno::EEXIST); } self.real_dir(parent(path))?; std::os::unix::fs::symlink(target, self.real(path)).map_err(io) } /// Moves `from` to `to`, fetching what it holds first. `noreplace` /// refuses to replace what's at `to`. pub fn rename(&self, st: &mut State, from: String, to: String, noreplace: bool) -> Result<(), Errno> { if from == to { return Ok(()); } 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 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(&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); // Inodes the system 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); } Ok(()) } /// A file of the workspace opened in the backing, fetched first if /// it's in the tree. pub fn open(&self, st: &State, path: &str, read: bool, write: bool, truncate: bool) -> Result { 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)?, _ => {} } fs::OpenOptions::new().read(read).write(write).truncate(write && truncate).open(self.real(path)).map_err(io) } /// Creates (or opens) file `path`, read and write. `exclusive` refuses one that's there. pub fn create(&self, st: &State, path: &str, mode: u32, exclusive: bool, truncate: bool) -> Result { if exclusive && 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(path))?; fs::OpenOptions::new().read(true).write(true).create(true).truncate(truncate).mode(mode & 0o7777).open(self.real(path)).map_err(io) } }