Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 1 | //! The mount on macOS: an NFSv3 server on localhost (`nfsserve`) that macOS's |
| 2 | //! own NFS client mounts with `mount_nfs`, so nothing is installed and |
| 3 | //! nothing needs root. It serves the same overlay FUSE serves on Linux. |
| 4 | //! |
| 5 | //! A port on localhost is open to every user of the machine, and a FUSE mount |
| 6 | //! answers only the user who made it, so this one does the same: |
| 7 | //! - The mount request is answered once, for an export path with a secret in |
| 8 | //! it, and every file handle starts with another secret, so nobody else can |
| 9 | //! mount it or make up a handle. |
| 10 | //! - The port the system mounts is a relay in front of the server that passes |
| 11 | //! on only calls made as this user (or root), so other users can't go |
| 12 | //! through the mounted folder either. |
| 13 | //! |
| 14 | //! macOS keeps files of its own next to people's (`._name` for extended |
| 15 | //! attributes, `.nfs.*` for a file deleted while open, Finder's `.DS_Store`): |
| 16 | //! they're kept, but out of listings, so saves never see them. |
| 17 | //! |
| 18 | //! The serving process holds `mount.lock` in the workspace's backing, so a |
| 19 | //! mount whose process is gone (a crash) is told apart and taken down before |
| 20 | //! the next one starts. It stops when its mount goes (`on mount --stop`). |
| 21 |
| 22 | use std::collections::{HashMap, HashSet}; |
| 23 | use std::ffi::{CStr, OsStr}; |
| 24 | use std::fs; |
| 25 | use std::ops::Bound; |
| 26 | use std::os::unix::ffi::OsStrExt; |
| 27 | use std::os::unix::fs::FileExt; |
| 28 | use std::path::{Path, PathBuf}; |
| 29 | use std::process::Command; |
| 30 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 31 | use std::sync::{Arc, Mutex}; |
| 32 | use std::time::{Duration, Instant, UNIX_EPOCH}; |
| 33 |
| 34 | use anyhow::{Context, Result, anyhow}; |
| 35 | use async_trait::async_trait; |
| 36 | use nfsserve::nfs::{ |
| 37 | FSF_CANSETTIME, FSF_HOMOGENEOUS, FSF_SYMLINK, fattr3, fileid3, filename3, fsinfo3, ftype3, nfs_fh3, nfspath3, nfsstat3, nfstime3, post_op_attr, sattr3, set_atime, set_mode3, |
| 38 | set_mtime, set_size3, specdata3, |
| 39 | }; |
| 40 | use nfsserve::tcp::{NFSTcp, NFSTcpListener}; |
| 41 | use nfsserve::vfs::{DirEntry, NFSFileSystem, ReadDirResult, VFSCapabilities}; |
| 42 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 43 | use tokio::net::{TcpListener, TcpStream}; |
| 44 |
| 45 | use super::overlay::{Attr, Errno, Kind, Node, Overlay, SetTime, join, parent, set_times}; |
| 46 | use crate::app::App; |
| 47 | use crate::ws; |
| 48 |
| 49 | /// The options `mount_nfs` mounts with. The server also answers the mount |
| 50 | /// protocol on its port, so no portmapper is asked. `soft,intr`: if this |
| 51 | /// process dies, calls fail instead of hanging. Locks stay on this machine, |
| 52 | /// where everything using the folder is. |
| 53 | fn mount_options(port: u16) -> String { |
| 54 | format!("vers=3,tcp,port={port},mountport={port},soft,intr,locallocks,noquota,nobrowse,rsize=1048576,wsize=1048576,actimeo=1") |
| 55 | } |
| 56 |
| 57 | /// How often the server checks that its mount is still there. |
| 58 | const MOUNT_CHECK: Duration = Duration::from_millis(500); |
| 59 |
| 60 | /// The largest RPC message the relay passes on: a 1 MiB write and its header. |
| 61 | const MAX_MESSAGE: usize = 4 << 20; |
| 62 |
| 63 | /// Every mount on this machine: (where, what), from the kernel's table |
| 64 | /// without asking any filesystem (a dead server's mount doesn't hang this). |
| 65 | fn mounts() -> Vec<(PathBuf, String)> { |
| 66 | // SAFETY: a null buffer asks only how many there are. |
| 67 | let n = unsafe { libc::getfsstat(std::ptr::null_mut(), 0, libc::MNT_NOWAIT) }; |
| 68 | if n <= 0 { |
| 69 | return Vec::new(); |
| 70 | } |
| 71 | // Room for a few more, in case one comes meanwhile. |
| 72 | let mut all: Vec<libc::statfs> = vec![unsafe { std::mem::zeroed() }; n as usize + 8]; |
| 73 | let size = (all.len() * std::mem::size_of::<libc::statfs>()) as libc::c_int; |
| 74 | // SAFETY: a buffer of `size` bytes of statfs, as getfsstat takes. |
| 75 | let n = unsafe { libc::getfsstat(all.as_mut_ptr(), size, libc::MNT_NOWAIT) }; |
| 76 | all.truncate(n.max(0) as usize); |
| 77 | all.iter() |
| 78 | .map(|s| { |
| 79 | // SAFETY: the kernel NUL-terminates both names. |
| 80 | let on = unsafe { CStr::from_ptr(s.f_mntonname.as_ptr()) }; |
| 81 | let from = unsafe { CStr::from_ptr(s.f_mntfromname.as_ptr()) }; |
| 82 | (PathBuf::from(OsStr::from_bytes(on.to_bytes())), from.to_string_lossy().into_owned()) |
| 83 | }) |
| 84 | .collect() |
| 85 | } |
| 86 |
| 87 | /// What's mounted at `root` (the topmost mount there), if anything. |
| 88 | fn mounted_from(root: &Path) -> Option<String> { |
| 89 | mounts().into_iter().rev().find(|(on, _)| on == root).map(|(_, from)| from) |
| 90 | } |
| 91 |
| 92 | /// Whether something is mounted at `root`. |
| 93 | pub fn mounted(root: &Path) -> bool { |
| 94 | mounted_from(root).is_some() |
| 95 | } |
| 96 |
| 97 | /// The lock the serving process holds. |
| 98 | fn lock_path(backing: &Path) -> PathBuf { |
| 99 | backing.join(ws::DIR).join("mount.lock") |
| 100 | } |
| 101 |
| 102 | /// Whether a process is serving the workspace whose backing is `backing`. |
| 103 | fn serving(backing: &Path) -> bool { |
| 104 | use fs4::fs_std::FileExt; |
| 105 | let Ok(file) = fs::OpenOptions::new().create(true).truncate(false).write(true).open(lock_path(backing)) else { return false }; |
| 106 | match file.try_lock_exclusive() { |
| 107 | Ok(true) => { |
| 108 | let _ = FileExt::unlock(&file); |
| 109 | false |
| 110 | } |
| 111 | _ => true, |
| 112 | } |
| 113 | } |
| 114 |
| 115 | /// Takes down a mount at `root` whose process is gone, so a new one can start. |
| 116 | pub fn clear_dead(root: &Path, backing: &Path) -> Result<()> { |
| 117 | if mounted(root) && !serving(backing) { |
| 118 | force_unmount(root)?; |
| 119 | } |
| 120 | Ok(()) |
| 121 | } |
| 122 |
| 123 | fn run_quietly(program: &str, args: &[&str], root: &Path) -> bool { |
| 124 | Command::new(program).args(args).arg(root).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().is_ok_and(|s| s.success()) |
| 125 | } |
| 126 |
| 127 | fn force_unmount(root: &Path) -> Result<()> { |
| 128 | if run_quietly("/sbin/umount", &["-f"], root) || run_quietly("/usr/sbin/diskutil", &["unmount", "force"], root) || !mounted(root) { |
| 129 | return Ok(()); |
| 130 | } |
| 131 | Err(anyhow!("couldn't take down the stopped mount at {}", root.display())) |
| 132 | } |
| 133 |
| 134 | /// The backing of the workspace a mount serves, from what's mounted |
| 135 | /// (`127.0.0.1:/onset/<workspace>/<secret>`). |
| 136 | fn backing_of(from: &str) -> Option<PathBuf> { |
| 137 | let (_, export) = from.split_once(":/onset/")?; |
| 138 | let (workspace, _) = export.split_once('/')?; |
| 139 | Some(super::backing(workspace)) |
| 140 | } |
| 141 |
| 142 | /// Stops the mount at `root`, and waits for its process to let go. |
| 143 | pub fn unmount(root: &Path) -> Result<()> { |
| 144 | let Some(from) = mounted_from(root) else { return Ok(()) }; |
| 145 | let backing = backing_of(&from); |
| 146 | if backing.as_deref().is_some_and(|b| !serving(b)) { |
| 147 | return force_unmount(root); |
| 148 | } |
| 149 | if !(run_quietly("/sbin/umount", &[], root) || run_quietly("/usr/sbin/diskutil", &["unmount"], root)) && mounted(root) { |
| 150 | return Err(anyhow!("couldn't unmount {} (is something still using it?)", root.display())); |
| 151 | } |
| 152 | let deadline = Instant::now() + Duration::from_secs(5); |
| 153 | while backing.as_deref().is_some_and(serving) && Instant::now() < deadline { |
| 154 | std::thread::sleep(Duration::from_millis(20)); |
| 155 | } |
| 156 | Ok(()) |
| 157 | } |
| 158 |
| 159 | /// Why the mount didn't start, for `on get --lazy` and `on mount`: what the |
| 160 | /// serving process said, and that a full workspace always works. |
| 161 | pub fn stopped(root: &Path, said: &str) -> anyhow::Error { |
| 162 | let said: Vec<&str> = said.lines().map(|l| l.strip_prefix("error: ").unwrap_or(l)).filter(|l| !l.trim().is_empty() && !l.starts_with("next:")).collect(); |
| 163 | crate::out::Fail::new("failed", format!("the lazy mount at {} didn't start: {}", root.display(), said.join("; "))) |
| 164 | .state("the workspace is kept, without its files") |
| 165 | .next("on get <project> <folder> without --lazy gets every file now") |
| 166 | .into_anyhow() |
| 167 | } |
| 168 |
| 169 | /// `on mount --serve <folder>`: serves the mount until it's unmounted. |
| 170 | pub fn serve(app: &App, root: &Path) -> Result<()> { |
| 171 | // Nothing of this process stays in the folder it covers. |
| 172 | let _ = std::env::set_current_dir("/"); |
| 173 | let overlay = Overlay::load(app, root)?; |
| 174 | let lock = fs::OpenOptions::new().create(true).truncate(false).write(true).open(lock_path(&overlay.backing))?; |
| 175 | { |
| 176 | use fs4::fs_std::FileExt; |
| 177 | // Another process serving it (two commands starting it at once, or one stopping): wait for it. |
| 178 | let deadline = Instant::now() + Duration::from_secs(10); |
| 179 | while !matches!(lock.try_lock_exclusive(), Ok(true)) { |
| 180 | if mounted(root) { |
| 181 | return Ok(()); |
| 182 | } |
| 183 | if Instant::now() > deadline { |
| 184 | return Err(anyhow!("another process is serving {}", root.display())); |
| 185 | } |
| 186 | std::thread::sleep(Duration::from_millis(50)); |
| 187 | } |
| 188 | } |
| 189 | if mounted(root) { |
| 190 | return Ok(()); |
| 191 | } |
| 192 | let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; |
| 193 | let result = runtime.block_on(run(overlay, root)); |
| 194 | runtime.shutdown_background(); |
| 195 | drop(lock); |
| 196 | result |
| 197 | } |
| 198 |
| 199 | async fn run(overlay: Overlay, root: &Path) -> Result<()> { |
| 200 | // The workspace (so `on mount --stop` finds its process), and a secret. |
| 201 | let workspace = overlay.backing.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); |
| 202 | let export = format!("onset/{workspace}/{}", hex(&rand::random::<[u8; 16]>())); |
| 203 | let fs = Lazy { overlay, secret: rand::random(), mounted: AtomicBool::new(false), listed: Mutex::new(HashMap::new()) }; |
| 204 | let mut server = NFSTcpListener::bind("127.0.0.1:0", fs).await.context("starting the NFS server")?; |
| 205 | server.with_export_name(&export); |
| 206 | let inner = server.get_listen_port(); |
| 207 | tokio::spawn(async move { server.handle_forever().await }); |
| 208 | let front = TcpListener::bind("127.0.0.1:0").await.context("starting the NFS server")?; |
| 209 | let port = front.local_addr()?.port(); |
| 210 | // SAFETY: getuid can't fail. |
| 211 | tokio::spawn(relay(front, inner, unsafe { libc::getuid() })); |
| 212 |
| 213 | let (options, source, point) = (mount_options(port), format!("127.0.0.1:/{export}"), root.to_path_buf()); |
| 214 | let out = tokio::task::spawn_blocking(move || Command::new("/sbin/mount_nfs").arg("-o").arg(options).arg(source).arg(point).output()).await?.context("running /sbin/mount_nfs")?; |
| 215 | if !out.status.success() { |
| 216 | let said = String::from_utf8_lossy(&out.stderr).trim().replace('\n', " "); |
| 217 | return Err(anyhow!("macOS's NFS client (mount_nfs) couldn't mount it ({}): {said}", out.status)); |
| 218 | } |
| 219 | eprintln!("on mount: serving {} ({}) on 127.0.0.1:{port}", root.display(), mounted_from(root).unwrap_or_default()); |
| 220 | // Until it's unmounted (or something else is mounted over it). |
| 221 | let mut check = tokio::time::interval(MOUNT_CHECK); |
| 222 | while mounted_from(root).is_some_and(|from| from.contains(&export)) { |
| 223 | check.tick().await; |
| 224 | } |
| 225 | Ok(()) |
| 226 | } |
| 227 |
| 228 | fn hex(bytes: &[u8]) -> String { |
| 229 | bytes.iter().map(|b| format!("{b:02x}")).collect() |
| 230 | } |
| 231 |
| 232 | /// Files macOS keeps for itself next to people's: left out of listings |
| 233 | /// unless the tree has them. |
| 234 | fn bookkeeping(name: &str) -> bool { |
| 235 | name.starts_with("._") || name.starts_with(".nfs.") || name == ".DS_Store" |
| 236 | } |
| 237 |
| 238 | struct Lazy { |
| 239 | overlay: Overlay, |
| 240 | /// Starts every file handle, so handles can't be made up. |
| 241 | secret: [u8; 16], |
| 242 | /// Whether the mount request was answered (it's answered once). |
| 243 | mounted: AtomicBool, |
| 244 | /// By folder, the name each entry was listed under: a listing's next |
| 245 | /// page starts after it, even if the entry moved since. |
| 246 | listed: Mutex<HashMap<u64, HashMap<u64, String>>>, |
| 247 | } |
| 248 |
| 249 | fn status(e: Errno) -> nfsstat3 { |
| 250 | match e.0 { |
| 251 | libc::ENOENT => nfsstat3::NFS3ERR_NOENT, |
| 252 | libc::EEXIST => nfsstat3::NFS3ERR_EXIST, |
| 253 | libc::ENOTDIR => nfsstat3::NFS3ERR_NOTDIR, |
| 254 | libc::EISDIR => nfsstat3::NFS3ERR_ISDIR, |
| 255 | libc::ENOTEMPTY => nfsstat3::NFS3ERR_NOTEMPTY, |
| 256 | libc::EINVAL => nfsstat3::NFS3ERR_INVAL, |
| 257 | libc::EACCES => nfsstat3::NFS3ERR_ACCES, |
| 258 | libc::EPERM => nfsstat3::NFS3ERR_PERM, |
| 259 | libc::ENOSPC => nfsstat3::NFS3ERR_NOSPC, |
| 260 | libc::EDQUOT => nfsstat3::NFS3ERR_DQUOT, |
| 261 | libc::EROFS => nfsstat3::NFS3ERR_ROFS, |
| 262 | libc::EFBIG => nfsstat3::NFS3ERR_FBIG, |
| 263 | libc::ENAMETOOLONG => nfsstat3::NFS3ERR_NAMETOOLONG, |
| 264 | libc::EXDEV => nfsstat3::NFS3ERR_XDEV, |
| 265 | libc::EMLINK => nfsstat3::NFS3ERR_MLINK, |
| 266 | _ => nfsstat3::NFS3ERR_IO, |
| 267 | } |
| 268 | } |
| 269 |
| 270 | fn nfs_time(ns: i64) -> nfstime3 { |
| 271 | let ns = ns.max(0); |
| 272 | nfstime3 { seconds: (ns / 1_000_000_000) as u32, nseconds: (ns % 1_000_000_000) as u32 } |
| 273 | } |
| 274 |
| 275 | fn client_time(t: &nfstime3) -> SetTime { |
| 276 | SetTime::At(UNIX_EPOCH + Duration::new(t.seconds.into(), t.nseconds)) |
| 277 | } |
| 278 |
| 279 | fn atime(t: &set_atime) -> Option<SetTime> { |
| 280 | match t { |
| 281 | set_atime::DONT_CHANGE => None, |
| 282 | set_atime::SET_TO_SERVER_TIME => Some(SetTime::Now), |
| 283 | set_atime::SET_TO_CLIENT_TIME(t) => Some(client_time(t)), |
| 284 | } |
| 285 | } |
| 286 |
| 287 | fn mtime(t: &set_mtime) -> Option<SetTime> { |
| 288 | match t { |
| 289 | set_mtime::DONT_CHANGE => None, |
| 290 | set_mtime::SET_TO_SERVER_TIME => Some(SetTime::Now), |
| 291 | set_mtime::SET_TO_CLIENT_TIME(t) => Some(client_time(t)), |
| 292 | } |
| 293 | } |
| 294 |
| 295 | fn fattr(a: &Attr) -> fattr3 { |
| 296 | fattr3 { |
| 297 | ftype: match a.kind { |
| 298 | Kind::Dir => ftype3::NF3DIR, |
| 299 | Kind::File => ftype3::NF3REG, |
| 300 | Kind::Link => ftype3::NF3LNK, |
| 301 | }, |
| 302 | mode: a.perm.into(), |
| 303 | nlink: a.nlink, |
| 304 | uid: a.uid, |
| 305 | gid: a.gid, |
| 306 | size: a.size, |
| 307 | used: a.blocks * 512, |
| 308 | rdev: specdata3::default(), |
| 309 | fsid: 0, |
| 310 | fileid: a.ino, |
| 311 | atime: nfs_time(a.atime_ns), |
| 312 | mtime: nfs_time(a.mtime_ns), |
| 313 | ctime: nfs_time(a.ctime_ns), |
| 314 | } |
| 315 | } |
| 316 |
| 317 | fn name_of(name: &filename3) -> &OsStr { |
| 318 | OsStr::from_bytes(&name.0) |
| 319 | } |
| 320 |
| 321 | /// Runs blocking work (the disk, a fetch from the host) without holding up |
| 322 | /// the server's other calls. |
| 323 | fn blocking<T>(f: impl FnOnce() -> T) -> T { |
| 324 | tokio::task::block_in_place(f) |
| 325 | } |
| 326 |
| 327 | impl Lazy { |
| 328 | /// What `path` shows, or STALE if it's gone (a handle the client kept). |
| 329 | fn attr_at(&self, path: &str) -> Result<fattr3, nfsstat3> { |
| 330 | let mut st = self.overlay.state.lock().unwrap(); |
| 331 | let node = self.overlay.find(&st, path).ok_or(nfsstat3::NFS3ERR_STALE)?; |
| 332 | Ok(fattr(&self.overlay.attr(&mut st, path, &node))) |
| 333 | } |
| 334 |
| 335 | fn path(&self, id: fileid3) -> Result<String, nfsstat3> { |
| 336 | self.overlay.path_of(&self.overlay.state.lock().unwrap(), id).map_err(|_| nfsstat3::NFS3ERR_STALE) |
| 337 | } |
| 338 |
| 339 | fn child(&self, dir: fileid3, name: &filename3) -> Result<String, nfsstat3> { |
| 340 | self.overlay.child(&self.overlay.state.lock().unwrap(), dir, name_of(name)).map_err(status) |
| 341 | } |
| 342 |
| 343 | /// The ID and attributes of `path`, just made. |
| 344 | fn made(&self, path: &str) -> Result<(fileid3, fattr3), nfsstat3> { |
| 345 | let attr = self.attr_at(path).map_err(|_| nfsstat3::NFS3ERR_IO)?; |
| 346 | Ok((attr.fileid, attr)) |
| 347 | } |
| 348 |
| 349 | /// Fetches a file of the tree from the host into the store before the |
| 350 | /// overlay is locked, so other calls aren't held up meanwhile (copying |
| 351 | /// it up is quick after). |
| 352 | fn prefetch(&self, path: &str) { |
| 353 | let node = self.overlay.find(&self.overlay.state.lock().unwrap(), path); |
| 354 | if let Some(Node::File(e, _)) = node { |
| 355 | let _ = self.overlay.content(path, &e); |
| 356 | } |
| 357 | } |
| 358 |
| 359 | /// Whether listings show `name` in folder `dir`. |
| 360 | fn shows(&self, dir: &str, name: &str) -> bool { |
| 361 | !bookkeeping(name) || self.overlay.in_tree(&join(dir, name)) |
| 362 | } |
| 363 | } |
| 364 |
| 365 | #[async_trait] |
| 366 | impl NFSFileSystem for Lazy { |
| 367 | fn capabilities(&self) -> VFSCapabilities { |
| 368 | VFSCapabilities::ReadWrite |
| 369 | } |
| 370 |
| 371 | fn root_dir(&self) -> fileid3 { |
| 372 | 1 |
| 373 | } |
| 374 |
| 375 | fn id_to_fh(&self, id: fileid3) -> nfs_fh3 { |
| 376 | nfs_fh3 { data: [&self.secret[..], &id.to_le_bytes()].concat() } |
| 377 | } |
| 378 |
| 379 | fn fh_to_id(&self, fh: &nfs_fh3) -> Result<fileid3, nfsstat3> { |
| 380 | match fh.data.split_at_checked(self.secret.len()) { |
| 381 | Some((secret, id)) if secret == self.secret => id.try_into().map(u64::from_le_bytes).map_err(|_| nfsstat3::NFS3ERR_BADHANDLE), |
| 382 | _ => Err(nfsstat3::NFS3ERR_BADHANDLE), |
| 383 | } |
| 384 | } |
| 385 |
| 386 | /// The mount request (the server has checked the export path): the root, |
| 387 | /// once, for the `mount_nfs` this process ran. |
| 388 | async fn path_to_id(&self, path: &[u8]) -> Result<fileid3, nfsstat3> { |
| 389 | if path == b"/" && !self.mounted.swap(true, Ordering::SeqCst) { Ok(1) } else { Err(nfsstat3::NFS3ERR_ACCES) } |
| 390 | } |
| 391 |
| 392 | async fn lookup(&self, dirid: fileid3, filename: &filename3) -> Result<fileid3, nfsstat3> { |
| 393 | blocking(|| { |
| 394 | let mut st = self.overlay.state.lock().unwrap(); |
| 395 | let dir = self.overlay.path_of(&st, dirid).map_err(|_| nfsstat3::NFS3ERR_STALE)?; |
| 396 | match &filename.0[..] { |
| 397 | b"." => return Ok(dirid), |
| 398 | b".." => return Ok(if dir.is_empty() { 1 } else { self.overlay.ino_of(&mut st, parent(&dir)) }), |
| 399 | _ => {} |
| 400 | } |
| 401 | let path = self.overlay.child(&st, dirid, name_of(filename)).map_err(|_| nfsstat3::NFS3ERR_NOENT)?; |
| 402 | match self.overlay.find(&st, &path) { |
| 403 | Some(_) => Ok(self.overlay.ino_of(&mut st, &path)), |
| 404 | None => Err(nfsstat3::NFS3ERR_NOENT), |
| 405 | } |
| 406 | }) |
| 407 | } |
| 408 |
| 409 | async fn getattr(&self, id: fileid3) -> Result<fattr3, nfsstat3> { |
| 410 | blocking(|| self.attr_at(&self.path(id)?)) |
| 411 | } |
| 412 |
| 413 | async fn setattr(&self, id: fileid3, attr: sattr3) -> Result<fattr3, nfsstat3> { |
| 414 | blocking(|| { |
| 415 | let path = self.path(id)?; |
| 416 | let mode = match attr.mode { |
| 417 | set_mode3::mode(m) => Some(m), |
| 418 | set_mode3::Void => None, |
| 419 | }; |
| 420 | let size = match attr.size { |
| 421 | set_size3::size(n) => Some(n), |
| 422 | set_size3::Void => None, |
| 423 | }; |
| 424 | // Owners stay as they are: the mount is one user's. |
| 425 | self.prefetch(&path); |
| 426 | let st = self.overlay.state.lock().unwrap(); |
| 427 | self.overlay.setattr(&st, &path, mode, size, None, atime(&attr.atime), mtime(&attr.mtime)).map_err(status)?; |
| 428 | drop(st); |
| 429 | self.attr_at(&path) |
| 430 | }) |
| 431 | } |
| 432 |
| 433 | async fn read(&self, id: fileid3, offset: u64, count: u32) -> Result<(Vec<u8>, bool), nfsstat3> { |
| 434 | blocking(|| { |
| 435 | let path = self.path(id)?; |
| 436 | self.prefetch(&path); |
| 437 | let file = { |
| 438 | let st = self.overlay.state.lock().unwrap(); |
| 439 | self.overlay.open(&st, &path, true, false, false).map_err(status)? |
| 440 | }; |
| 441 | let len = file.metadata().map_err(|e| status(Errno::from(e)))?.len(); |
| 442 | let mut buf = vec![0u8; (count as u64).min(len.saturating_sub(offset)) as usize]; |
| 443 | let mut got = 0; |
| 444 | while got < buf.len() { |
| 445 | match file.read_at(&mut buf[got..], offset + got as u64) { |
| 446 | Ok(0) => break, |
| 447 | Ok(n) => got += n, |
| 448 | Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} |
| 449 | Err(e) => return Err(status(Errno::from(e))), |
| 450 | } |
| 451 | } |
| 452 | buf.truncate(got); |
| 453 | Ok((buf, offset + got as u64 >= len)) |
| 454 | }) |
| 455 | } |
| 456 |
| 457 | async fn write(&self, id: fileid3, offset: u64, data: &[u8]) -> Result<fattr3, nfsstat3> { |
| 458 | blocking(|| { |
| 459 | let path = self.path(id)?; |
| 460 | self.prefetch(&path); |
| 461 | let file = { |
| 462 | let st = self.overlay.state.lock().unwrap(); |
| 463 | self.overlay.open(&st, &path, false, true, false).map_err(status)? |
| 464 | }; |
| 465 | file.write_all_at(data, offset).map_err(|e| status(Errno::from(e)))?; |
| 466 | self.attr_at(&path) |
| 467 | }) |
| 468 | } |
| 469 |
| 470 | async fn create(&self, dirid: fileid3, filename: &filename3, attr: sattr3) -> Result<(fileid3, fattr3), nfsstat3> { |
| 471 | blocking(|| { |
| 472 | let path = self.child(dirid, filename)?; |
| 473 | let mode = match attr.mode { |
| 474 | set_mode3::mode(m) => m, |
| 475 | set_mode3::Void => 0o644, |
| 476 | }; |
| 477 | let st = self.overlay.state.lock().unwrap(); |
| 478 | self.overlay.create(&st, &path, mode, false, matches!(attr.size, set_size3::size(0))).map_err(status)?; |
| 479 | let (at, mt) = (atime(&attr.atime), mtime(&attr.mtime)); |
| 480 | if at.is_some() || mt.is_some() { |
| 481 | self.overlay.setattr(&st, &path, None, None, None, at, mt).map_err(status)?; |
| 482 | } |
| 483 | drop(st); |
| 484 | self.made(&path) |
| 485 | }) |
| 486 | } |
| 487 |
| 488 | async fn create_exclusive(&self, dirid: fileid3, filename: &filename3) -> Result<fileid3, nfsstat3> { |
| 489 | blocking(|| { |
| 490 | let path = self.child(dirid, filename)?; |
| 491 | let st = self.overlay.state.lock().unwrap(); |
| 492 | self.overlay.create(&st, &path, 0o644, true, false).map_err(status)?; |
| 493 | drop(st); |
| 494 | Ok(self.made(&path)?.0) |
| 495 | }) |
| 496 | } |
| 497 |
| 498 | async fn mkdir(&self, dirid: fileid3, dirname: &filename3) -> Result<(fileid3, fattr3), nfsstat3> { |
| 499 | blocking(|| { |
| 500 | let path = self.child(dirid, dirname)?; |
| 501 | let st = self.overlay.state.lock().unwrap(); |
| 502 | self.overlay.mkdir(&st, &path, 0o755).map_err(status)?; |
| 503 | drop(st); |
| 504 | self.made(&path) |
| 505 | }) |
| 506 | } |
| 507 |
| 508 | /// REMOVE and RMDIR both come here. |
| 509 | async fn remove(&self, dirid: fileid3, filename: &filename3) -> Result<(), nfsstat3> { |
| 510 | blocking(|| { |
| 511 | let path = self.child(dirid, filename)?; |
| 512 | let mut st = self.overlay.state.lock().unwrap(); |
| 513 | let node = self.overlay.find(&st, &path).ok_or(nfsstat3::NFS3ERR_NOENT)?; |
| 514 | if node.is_dir() { |
| 515 | // macOS's own files are all that's left: they go with it. |
| 516 | let left = self.overlay.listing(&st, &path); |
| 517 | if !left.is_empty() && left.iter().all(|(name, _)| !self.shows(&path, name)) { |
| 518 | for (name, _) in left { |
| 519 | let _ = fs::remove_file(self.overlay.real(&join(&path, &name))); |
| 520 | } |
| 521 | } |
| 522 | self.overlay.rmdir(&mut st, path.clone()).map_err(status)?; |
| 523 | } else { |
| 524 | self.overlay.unlink(&mut st, path.clone()).map_err(status)?; |
| 525 | } |
| 526 | // The folder shows a new time, so the client reads it again. |
| 527 | let up = parent(&path); |
| 528 | if self.overlay.real_dir(up).is_ok() { |
| 529 | let _ = set_times(&self.overlay.real(up), None, Some(SetTime::Now)); |
| 530 | } |
| 531 | Ok(()) |
| 532 | }) |
| 533 | } |
| 534 |
| 535 | async fn rename(&self, from_dirid: fileid3, from_filename: &filename3, to_dirid: fileid3, to_filename: &filename3) -> Result<(), nfsstat3> { |
| 536 | blocking(|| { |
| 537 | let from = self.child(from_dirid, from_filename)?; |
| 538 | let to = self.child(to_dirid, to_filename)?; |
| 539 | let mut st = self.overlay.state.lock().unwrap(); |
| 540 | self.overlay.rename(&mut st, from, to, false).map_err(status) |
| 541 | }) |
| 542 | } |
| 543 |
| 544 | /// A page of folder `dirid`, in name order, after the entry the last page |
| 545 | /// ended on (`start_after`, 0 for the first page). |
| 546 | async fn readdir(&self, dirid: fileid3, start_after: fileid3, max_entries: usize) -> Result<ReadDirResult, nfsstat3> { |
| 547 | blocking(|| { |
| 548 | let mut st = self.overlay.state.lock().unwrap(); |
| 549 | let path = self.overlay.path_of(&st, dirid).map_err(|_| nfsstat3::NFS3ERR_STALE)?; |
| 550 | if !self.overlay.find(&st, &path).is_some_and(|n| n.is_dir()) { |
| 551 | return Err(nfsstat3::NFS3ERR_NOTDIR); |
| 552 | } |
| 553 | let names = self.overlay.names(&path); |
| 554 | let mut listed = self.listed.lock().unwrap(); |
| 555 | let seen = listed.entry(dirid).or_default(); |
| 556 | let after = if start_after == 0 { |
| 557 | Bound::Unbounded |
| 558 | } else { |
| 559 | // The name it was listed under, or else the one it has now if it's still in this folder. |
| 560 | let now = self.overlay.path_of(&st, start_after).ok().filter(|p| !p.is_empty() && parent(p) == path).map(|p| p.rsplit('/').next().unwrap_or_default().to_string()); |
| 561 | Bound::Excluded(seen.get(&start_after).cloned().or(now).ok_or(nfsstat3::NFS3ERR_BAD_COOKIE)?) |
| 562 | }; |
| 563 | let (mut entries, mut end) = (Vec::new(), true); |
| 564 | for name in names.range((after, Bound::Unbounded)) { |
| 565 | if !self.shows(&path, name) { |
| 566 | continue; |
| 567 | } |
| 568 | let child = join(&path, name); |
| 569 | let Some(node) = self.overlay.find(&st, &child) else { continue }; |
| 570 | if entries.len() >= max_entries.max(1) { |
| 571 | end = false; |
| 572 | break; |
| 573 | } |
| 574 | let attr = self.overlay.attr(&mut st, &child, &node); |
| 575 | seen.insert(attr.ino, name.clone()); |
| 576 | entries.push(DirEntry { fileid: attr.ino, name: name.as_bytes().into(), attr: fattr(&attr) }); |
| 577 | } |
| 578 | Ok(ReadDirResult { entries, end }) |
| 579 | }) |
| 580 | } |
| 581 |
| 582 | async fn symlink(&self, dirid: fileid3, linkname: &filename3, symlink: &nfspath3, _attr: &sattr3) -> Result<(fileid3, fattr3), nfsstat3> { |
| 583 | blocking(|| { |
| 584 | let path = self.child(dirid, linkname)?; |
| 585 | let st = self.overlay.state.lock().unwrap(); |
| 586 | self.overlay.symlink(&st, &path, Path::new(OsStr::from_bytes(&symlink.0))).map_err(status)?; |
| 587 | drop(st); |
| 588 | self.made(&path) |
| 589 | }) |
| 590 | } |
| 591 |
| 592 | async fn readlink(&self, id: fileid3) -> Result<nfspath3, nfsstat3> { |
| 593 | blocking(|| { |
| 594 | let path = self.path(id)?; |
| 595 | let st = self.overlay.state.lock().unwrap(); |
| 596 | self.overlay.readlink(&st, &path).map(nfspath3::from).map_err(status) |
| 597 | }) |
| 598 | } |
| 599 |
| 600 | async fn fsinfo(&self, root_fileid: fileid3) -> Result<fsinfo3, nfsstat3> { |
| 601 | let attr = match self.getattr(root_fileid).await { |
| 602 | Ok(a) => post_op_attr::attributes(a), |
| 603 | Err(_) => post_op_attr::Void, |
| 604 | }; |
| 605 | Ok(fsinfo3 { |
| 606 | obj_attributes: attr, |
| 607 | rtmax: 1 << 20, |
| 608 | rtpref: 1 << 20, |
| 609 | rtmult: 4096, |
| 610 | wtmax: 1 << 20, |
| 611 | wtpref: 1 << 20, |
| 612 | wtmult: 4096, |
| 613 | dtpref: 64 << 10, |
| 614 | maxfilesize: 1 << 50, |
| 615 | time_delta: nfstime3 { seconds: 0, nseconds: 1 }, |
| 616 | properties: FSF_SYMLINK | FSF_HOMOGENEOUS | FSF_CANSETTIME, |
| 617 | }) |
| 618 | } |
| 619 | } |
| 620 |
| 621 | /// Passes each connection to `front` on to the NFS server on `server`. |
| 622 | async fn relay(front: TcpListener, server: u16, uid: u32) { |
| 623 | while let Ok((client, _)) = front.accept().await { |
| 624 | tokio::spawn(async move { |
| 625 | let _ = relay_one(client, server, uid).await; |
| 626 | }); |
| 627 | } |
| 628 | } |
| 629 |
| 630 | /// One connection: calls made as `uid` or root (and pings) go on to the |
| 631 | /// server, the rest are refused; ACCESS replies say a file with no execute |
| 632 | /// bit can't run (macOS trusts the reply even to run a program). |
| 633 | async fn relay_one(client: TcpStream, server: u16, uid: u32) -> std::io::Result<()> { |
| 634 | let backend = TcpStream::connect(("127.0.0.1", server)).await?; |
| 635 | client.set_nodelay(true)?; |
| 636 | backend.set_nodelay(true)?; |
| 637 | let (mut from_client, to_client) = client.into_split(); |
| 638 | let (mut from_server, mut to_server) = backend.into_split(); |
| 639 | let to_client = Arc::new(tokio::sync::Mutex::new(to_client)); |
| 640 | let access: Arc<Mutex<HashSet<u32>>> = Arc::default(); |
| 641 | let replies = { |
| 642 | let (to_client, access) = (to_client.clone(), access.clone()); |
| 643 | tokio::spawn(async move { |
| 644 | while let Ok(Some(mut reply)) = read_message(&mut from_server).await { |
| 645 | if word(&reply, 0).is_some_and(|xid| access.lock().unwrap().remove(&xid)) { |
| 646 | limit_access(&mut reply); |
| 647 | } |
| 648 | if write_message(&mut *to_client.lock().await, &reply).await.is_err() { |
| 649 | break; |
| 650 | } |
| 651 | } |
| 652 | }) |
| 653 | }; |
| 654 | let result = async { |
| 655 | while let Some(call) = read_message(&mut from_client).await? { |
| 656 | if !allowed(&call, uid) { |
| 657 | write_message(&mut *to_client.lock().await, &refusal(&call)).await?; |
| 658 | continue; |
| 659 | } |
| 660 | if is_access(&call) |
| 661 | && let Some(xid) = word(&call, 0) |
| 662 | { |
| 663 | access.lock().unwrap().insert(xid); |
| 664 | } |
| 665 | write_message(&mut to_server, &call).await?; |
| 666 | } |
| 667 | Ok(()) |
| 668 | } |
| 669 | .await; |
| 670 | replies.abort(); |
| 671 | result |
| 672 | } |
| 673 |
| 674 | /// The big-endian word at `at`. |
| 675 | fn word(message: &[u8], at: usize) -> Option<u32> { |
| 676 | message.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])) |
| 677 | } |
| 678 |
| 679 | /// One RPC message: its fragments' bytes, joined (None at the end of the stream). |
| 680 | async fn read_message(stream: &mut tokio::net::tcp::OwnedReadHalf) -> std::io::Result<Option<Vec<u8>>> { |
| 681 | let mut message = Vec::new(); |
| 682 | loop { |
| 683 | let mut mark = [0u8; 4]; |
| 684 | match stream.read_exact(&mut mark).await { |
| 685 | Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof && message.is_empty() => return Ok(None), |
| 686 | r => r?, |
| 687 | }; |
| 688 | let mark = u32::from_be_bytes(mark); |
| 689 | let len = (mark & 0x7fff_ffff) as usize; |
| 690 | if message.len() + len > MAX_MESSAGE { |
| 691 | return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "RPC message too large")); |
| 692 | } |
| 693 | let start = message.len(); |
| 694 | message.resize(start + len, 0); |
| 695 | stream.read_exact(&mut message[start..]).await?; |
| 696 | if mark & 0x8000_0000 != 0 { |
| 697 | return Ok(Some(message)); |
| 698 | } |
| 699 | } |
| 700 | } |
| 701 |
| 702 | /// Sends `message` as one (last) fragment. |
| 703 | async fn write_message(stream: &mut tokio::net::tcp::OwnedWriteHalf, message: &[u8]) -> std::io::Result<()> { |
| 704 | let framed = [&(0x8000_0000 | message.len() as u32).to_be_bytes()[..], message].concat(); |
| 705 | stream.write_all(&framed).await |
| 706 | } |
| 707 |
| 708 | /// Whether a call goes on to the server: a ping (procedure 0) always, |
| 709 | /// anything else only with Unix credentials naming `uid` or root. |
| 710 | fn allowed(call: &[u8], uid: u32) -> bool { |
| 711 | // xid, CALL, RPC version, program, version, procedure, then the |
| 712 | // credentials: flavor, length, and for AUTH_UNIX a stamp, the machine's |
| 713 | // name (length, then padded to 4) and the uid. |
| 714 | if word(call, 4) != Some(0) { |
| 715 | return false; |
| 716 | } |
| 717 | if word(call, 20) == Some(0) { |
| 718 | return true; |
| 719 | } |
| 720 | if word(call, 24) != Some(1) { |
| 721 | return false; |
| 722 | } |
| 723 | let Some(name) = word(call, 36) else { return false }; |
| 724 | word(call, 40 + (name as usize).next_multiple_of(4)).is_some_and(|u| u == uid || u == 0) |
| 725 | } |
| 726 |
| 727 | /// Whether `call` is an NFS ACCESS call (program 100003, procedure 4). |
| 728 | fn is_access(call: &[u8]) -> bool { |
| 729 | word(call, 12) == Some(100003) && word(call, 20) == Some(4) |
| 730 | } |
| 731 |
| 732 | /// Takes execute out of an ACCESS reply for a file with no execute bit. |
| 733 | fn limit_access(reply: &mut [u8]) { |
| 734 | const EXECUTE: u32 = 0x20; |
| 735 | // xid, REPLY, MSG_ACCEPTED, the verifier (flavor, length, body), SUCCESS, |
| 736 | // NFS3_OK, attributes follow, the attributes (84 bytes: type, mode, ...), |
| 737 | // then the access granted. |
| 738 | let Some(verifier) = word(reply, 16) else { return }; |
| 739 | let at = 20 + (verifier as usize).next_multiple_of(4); |
| 740 | if [word(reply, 4), word(reply, 8), word(reply, at), word(reply, at + 4), word(reply, at + 8)] != [Some(1), Some(0), Some(0), Some(0), Some(1)] { |
| 741 | return; |
| 742 | } |
| 743 | let granted = at + 12 + 84; |
| 744 | if let (Some(1), Some(mode), Some(access)) = (word(reply, at + 12), word(reply, at + 16), word(reply, granted)) |
| 745 | && mode & 0o111 == 0 |
| 746 | { |
| 747 | reply[granted..granted + 4].copy_from_slice(&(access & !EXECUTE).to_be_bytes()); |
| 748 | } |
| 749 | } |
| 750 |
| 751 | /// The reply refusing `call`: denied, its credentials too weak. |
| 752 | fn refusal(call: &[u8]) -> Vec<u8> { |
| 753 | // xid, REPLY, MSG_DENIED, AUTH_ERROR, AUTH_TOOWEAK. |
| 754 | [call.get(..4).unwrap_or(&[0; 4]), &[1u32, 1, 1, 5].map(u32::to_be_bytes).concat()].concat() |
| 755 | } |
| 756 |
| 757 | #[cfg(test)] |
| 758 | mod tests { |
| 759 | use super::*; |
| 760 |
| 761 | fn call(prog: u32, proc_: u32, flavor: u32, uid: u32) -> Vec<u8> { |
| 762 | let mut c: Vec<u8> = [7u32, 0, 2, prog, 3, proc_, flavor].iter().flat_map(|w| w.to_be_bytes()).collect(); |
| 763 | // AUTH_UNIX: length, stamp, "mac" (padded), uid, gid, no groups. |
| 764 | let body: Vec<u8> = [&1u32.to_be_bytes()[..], &3u32.to_be_bytes(), b"mac\0", &uid.to_be_bytes(), &20u32.to_be_bytes(), &0u32.to_be_bytes()].concat(); |
| 765 | c.extend((body.len() as u32).to_be_bytes()); |
| 766 | c.extend(body); |
| 767 | c |
| 768 | } |
| 769 |
| 770 | #[test] |
| 771 | fn only_the_owner_gets_in() { |
| 772 | assert!(allowed(&call(100003, 1, 1, 501), 501)); |
| 773 | assert!(allowed(&call(100003, 1, 1, 0), 501)); |
| 774 | assert!(!allowed(&call(100003, 1, 1, 502), 501)); |
| 775 | assert!(!allowed(&call(100003, 1, 0, 501), 501)); |
| 776 | assert!(allowed(&call(100003, 0, 0, 999), 501)); |
| 777 | assert_eq!(refusal(&call(100003, 1, 1, 502))[..4], 7u32.to_be_bytes()); |
| 778 | } |
| 779 |
| 780 | #[test] |
| 781 | fn plain_files_dont_run() { |
| 782 | let reply = |mode: u32| -> Vec<u8> { |
| 783 | let mut r: Vec<u8> = [9u32, 1, 0, 0, 0, 0, 0, 1, 1, mode].iter().flat_map(|w| w.to_be_bytes()).collect(); |
| 784 | r.extend([0u8; 76]); |
| 785 | r.extend(0x3fu32.to_be_bytes()); |
| 786 | r |
| 787 | }; |
| 788 | let mut plain = reply(0o644); |
| 789 | limit_access(&mut plain); |
| 790 | assert_eq!(word(&plain, plain.len() - 4), Some(0x1f)); |
| 791 | let mut exec = reply(0o755); |
| 792 | limit_access(&mut exec); |
| 793 | assert_eq!(word(&exec, exec.len() - 4), Some(0x3f)); |
| 794 | } |
| 795 |
| 796 | #[test] |
| 797 | fn macos_files_are_bookkeeping() { |
| 798 | assert!(bookkeeping("._a.txt") && bookkeeping(".DS_Store") && bookkeeping(".nfs.0001.0002")); |
| 799 | assert!(!bookkeeping("a.txt") && !bookkeeping(".nfsrc") && !bookkeeping(".gitignore")); |
| 800 | } |
| 801 | } |
| 802 |