| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | |
|
| 13 | |
|
| 14 | | use anyhow::{bail, Context, Result}; |
| 15 | | use fuser::{FileAttr, FileType, Filesystem, MountOption, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, Request}; |
| 16 | | use oxy_core::ids::NodeId; |
| 17 | | use oxy_core::objects::NodeKind; |
| 18 | | use oxy_core::repo::Repo; |
| 19 | | use oxy_core::state::State; |
| 20 | | use std::collections::HashMap; |
| 21 | | use std::ffi::{OsStr, OsString}; |
| 22 | | use std::path::{Path, PathBuf}; |
| 23 | | use std::sync::Arc; |
| 24 | | use std::time::{Duration, Instant, SystemTime}; |
| 25 | |
|
| 26 | |
|
| 27 | |
|
| 28 | | const TTL: Duration = Duration::from_secs(3600); |
| 29 | |
|
| 30 | |
|
| 31 | | const MEMORY_BYTES: usize = 256 << 20; |
| 32 | |
|
| 33 | | struct Inode { |
| 34 | | parent: u64, |
| 35 | | kind: NodeKind, |
| 36 | | node: NodeId, |
| 37 | | exec: bool, |
| 38 | | opaque: bool, |
| 39 | | |
| 40 | | children: Vec<(OsString, u64)>, |
| 41 | | } |
| 42 | |
|
| 43 | | struct CasFs { |
| 44 | | repo: Repo, |
| 45 | | state: State, |
| 46 | | inodes: Vec<Inode>, |
| 47 | | |
| 48 | | |
| 49 | | bytes: HashMap<u64, Arc<Vec<u8>>>, |
| 50 | | order: std::collections::VecDeque<u64>, |
| 51 | | held: usize, |
| 52 | | cache_dir: PathBuf, |
| 53 | | uid: u32, |
| 54 | | gid: u32, |
| 55 | | mounted: SystemTime, |
| 56 | | } |
| 57 | |
|
| 58 | | impl CasFs { |
| 59 | 2× | fn new(repo: Repo, state: State) -> CasFs { |
| 60 | 2× | let view = state.tree().view(); |
| 61 | 2× | let root = Inode { parent: 1, kind: NodeKind::Dir, node: NodeId::ROOT, exec: false, opaque: false, children: Vec::new() }; |
| 62 | 2× | let mut inodes = vec![root]; |
| 63 | 2× | let mut by_path: HashMap<&str, u64> = HashMap::with_capacity(view.entries.len()); |
| 64 | 2× | by_path.insert("", 1); |
| 65 | | |
| 66 | 10× | for (path, e) in view.entries.iter() { |
| 67 | 10× | let (dir, name) = path.rsplit_once('/').unwrap_or(("", path.as_str())); |
| 68 | 10× | let Some(&parent) = by_path.get(dir) else { continue }; |
| 69 | 10× | let ino = inodes.len() as u64 + 1; |
| 70 | 10× | inodes.push(Inode { parent, kind: e.kind, node: e.node, exec: e.mode.exec, opaque: e.mode.opaque, children: Vec::new() }); |
| 71 | 10× | inodes[parent as usize - 1].children.push((OsString::from(name), ino)); |
| 72 | 10× | if e.kind == NodeKind::Dir || e.kind == NodeKind::Subrepo { |
| 73 | 4× | by_path.insert(path.as_str(), ino); |
| 74 | 6× | } |
| 75 | | } |
| 76 | 12× | for i in inodes.iter_mut() { |
| 77 | 12× | i.children.sort(); |
| 78 | 12× | } |
| 79 | 2× | let cache_dir = repo.root().join(".oxy").join("cache").join("render"); |
| 80 | 2× | CasFs { |
| 81 | 2× | repo, |
| 82 | 2× | state, |
| 83 | 2× | inodes, |
| 84 | 2× | bytes: HashMap::new(), |
| 85 | 2× | order: std::collections::VecDeque::new(), |
| 86 | 2× | held: 0, |
| 87 | 2× | cache_dir, |
| 88 | 2× | uid: unsafe { libc::getuid() }, |
| 89 | 2× | gid: unsafe { libc::getgid() }, |
| 90 | 2× | mounted: SystemTime::now(), |
| 91 | 2× | } |
| 92 | 2× | } |
| 93 | |
|
| 94 | 16× | fn inode(&self, ino: u64) -> Option<&Inode> { |
| 95 | 16× | self.inodes.get(ino.checked_sub(1)? as usize) |
| 96 | 16× | } |
| 97 | |
|
| 98 | | |
| 99 | | |
| 100 | 5× | fn content(&mut self, ino: u64) -> Result<Arc<Vec<u8>>> { |
| 101 | 5× | if let Some(b) = self.bytes.get(&ino) { |
| 102 | 1× | return Ok(b.clone()); |
| 103 | 4× | } |
| 104 | 4× | let i = self.inode(ino).context("no such inode")?; |
| 105 | 4× | let (node, kind, opaque, parent) = (i.node, i.kind, i.opaque, i.parent); |
| 106 | | |
| 107 | | |
| 108 | | |
| 109 | 4× | if kind == NodeKind::File && !opaque && self.state.single_text(&node).is_some() { |
| 110 | 2× | let siblings: Vec<(u64, oxy_core::objects::TextRef)> = self.inodes[parent as usize - 1] |
| 111 | 2× | .children |
| 112 | 2× | .iter() |
| 113 | 2× | .map(|(_, c)| *c) |
| 114 | 3× | .filter(|c| !self.bytes.contains_key(c)) |
| 115 | 3× | .filter_map(|c| { |
| 116 | 3× | let i = &self.inodes[c as usize - 1]; |
| 117 | 3× | (i.kind == NodeKind::File && !i.opaque).then_some(())?; |
| 118 | 2× | Some((c, self.state.single_text(&i.node)?)) |
| 119 | 3× | }) |
| 120 | 2× | .collect(); |
| 121 | 2× | let texts: Vec<oxy_core::objects::TextRef> = siblings.iter().map(|(_, t)| *t).collect(); |
| 122 | 2× | for ((c, _), b) in siblings.iter().zip(self.repo.single_texts(&texts)?) { |
| 123 | 2× | self.keep(*c, Arc::new(b)); |
| 124 | 2× | } |
| 125 | 2× | if let Some(b) = self.bytes.get(&ino) { |
| 126 | 2× | return Ok(b.clone()); |
| 127 | 0× | } |
| 128 | 2× | } |
| 129 | 2× | let bytes = if kind == NodeKind::File && !opaque { |
| 130 | 2× | self.rendering(&node)? |
| 131 | | } else { |
| 132 | 0× | match self.state.live_blobs(&node).first() { |
| 133 | 0× | Some(b) => self.repo.read_blob(b)?, |
| 134 | 0× | None => Vec::new(), |
| 135 | | } |
| 136 | | }; |
| 137 | 2× | let bytes = Arc::new(bytes); |
| 138 | 2× | self.keep(ino, bytes.clone()); |
| 139 | 2× | Ok(bytes) |
| 140 | 5× | } |
| 141 | |
|
| 142 | | |
| 143 | 4× | fn keep(&mut self, ino: u64, bytes: Arc<Vec<u8>>) { |
| 144 | 4× | self.held += bytes.len(); |
| 145 | 4× | if let Some(old) = self.bytes.insert(ino, bytes) { |
| 146 | 0× | self.held -= old.len(); |
| 147 | 4× | } else { |
| 148 | 4× | self.order.push_back(ino); |
| 149 | 4× | } |
| 150 | 4× | while self.held > MEMORY_BYTES && self.order.len() > 1 { |
| 151 | 0× | let Some(oldest) = self.order.pop_front() else { break }; |
| 152 | 0× | if let Some(b) = self.bytes.remove(&oldest) { |
| 153 | 0× | self.held -= b.len(); |
| 154 | 0× | } |
| 155 | | } |
| 156 | 4× | } |
| 157 | |
|
| 158 | 2× | fn rendering(&self, node: &NodeId) -> Result<Vec<u8>> { |
| 159 | | |
| 160 | | |
| 161 | 2× | if let Some(t) = self.state.single_text(node) { |
| 162 | 0× | return Ok(self.repo.single_texts(&[t])?.pop().unwrap_or_default()); |
| 163 | 2× | } |
| 164 | 2× | let key = self.state.graph_key(node); |
| 165 | 2× | let cached = key.map(|k| self.cache_dir.join(hex(&k))); |
| 166 | 2× | if let Some(p) = &cached { |
| 167 | 2× | if let Ok(b) = std::fs::read(p) { |
| 168 | 1× | return Ok(b); |
| 169 | 1× | } |
| 170 | 0× | } |
| 171 | 1× | let graphs = self.repo.build_graphs_detached(&self.state, &[*node])?; |
| 172 | 1× | let bytes = match graphs.get(node) { |
| 173 | 1× | Some(g) => self.state.render_graph(g).0, |
| 174 | 0× | None => Vec::new(), |
| 175 | | }; |
| 176 | 1× | if let Some(p) = cached { |
| 177 | | |
| 178 | | |
| 179 | 1× | let _ = std::fs::create_dir_all(&self.cache_dir); |
| 180 | 1× | let tmp = p.with_extension(format!("tmp-{}", std::process::id())); |
| 181 | 1× | if std::fs::write(&tmp, &bytes).is_ok() { |
| 182 | 1× | let _ = std::fs::rename(&tmp, &p); |
| 183 | 1× | } |
| 184 | 0× | } |
| 185 | 1× | Ok(bytes) |
| 186 | 2× | } |
| 187 | |
|
| 188 | 2× | fn attr(&mut self, ino: u64) -> Result<FileAttr> { |
| 189 | 2× | let i = self.inode(ino).context("no such inode")?; |
| 190 | 2× | let (kind, perm) = match i.kind { |
| 191 | 1× | NodeKind::Dir | NodeKind::Subrepo => (FileType::Directory, 0o555), |
| 192 | 0× | NodeKind::Symlink => (FileType::Symlink, 0o777), |
| 193 | 0× | _ if i.exec => (FileType::RegularFile, 0o555), |
| 194 | 1× | _ => (FileType::RegularFile, 0o444), |
| 195 | | }; |
| 196 | 2× | let nlink = if kind == FileType::Directory { 2 } else { 1 }; |
| 197 | 2× | let size = match kind { |
| 198 | 1× | FileType::Directory => 0, |
| 199 | 1× | _ => self.content(ino)?.len() as u64, |
| 200 | | }; |
| 201 | 2× | Ok(FileAttr { |
| 202 | 2× | ino, |
| 203 | 2× | size, |
| 204 | 2× | blocks: size.div_ceil(512), |
| 205 | 2× | atime: self.mounted, |
| 206 | 2× | mtime: self.mounted, |
| 207 | 2× | ctime: self.mounted, |
| 208 | 2× | crtime: self.mounted, |
| 209 | 2× | kind, |
| 210 | 2× | perm, |
| 211 | 2× | nlink, |
| 212 | 2× | uid: self.uid, |
| 213 | 2× | gid: self.gid, |
| 214 | 2× | rdev: 0, |
| 215 | 2× | blksize: 4096, |
| 216 | 2× | flags: 0, |
| 217 | 2× | }) |
| 218 | 2× | } |
| 219 | | } |
| 220 | |
|
| 221 | 2× | fn hex(b: &[u8]) -> String { |
| 222 | 64× | b.iter().map(|x| format!("{x:02x}")).collect() |
| 223 | 2× | } |
| 224 | |
|
| 225 | | impl Filesystem for CasFs { |
| 226 | 0× | fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) { |
| 227 | 0× | let found = |
| 228 | 0× | self.inode(parent).and_then(|p| p.children.binary_search_by(|(n, _)| n.as_os_str().cmp(name)).ok().map(|i| p.children[i].1)); |
| 229 | 0× | match found.map(|ino| self.attr(ino)) { |
| 230 | 0× | Some(Ok(a)) => reply.entry(&TTL, &a, 0), |
| 231 | 0× | Some(Err(_)) => reply.error(libc::EIO), |
| 232 | 0× | None => reply.error(libc::ENOENT), |
| 233 | | } |
| 234 | 0× | } |
| 235 | |
|
| 236 | 0× | fn getattr(&mut self, _req: &Request<'_>, ino: u64, _fh: Option<u64>, reply: ReplyAttr) { |
| 237 | 0× | match self.attr(ino) { |
| 238 | 0× | Ok(a) => reply.attr(&TTL, &a), |
| 239 | 0× | Err(_) => reply.error(libc::ENOENT), |
| 240 | | } |
| 241 | 0× | } |
| 242 | |
|
| 243 | 0× | fn readlink(&mut self, _req: &Request<'_>, ino: u64, reply: ReplyData) { |
| 244 | 0× | match self.content(ino) { |
| 245 | 0× | Ok(b) => reply.data(&b), |
| 246 | 0× | Err(_) => reply.error(libc::EIO), |
| 247 | | } |
| 248 | 0× | } |
| 249 | |
|
| 250 | | #[allow(clippy::too_many_arguments)] |
| 251 | 0× | fn read(&mut self, _req: &Request<'_>, ino: u64, _fh: u64, offset: i64, size: u32, _flags: i32, _lock: Option<u64>, reply: ReplyData) { |
| 252 | 0× | match self.content(ino) { |
| 253 | 0× | Ok(b) => { |
| 254 | 0× | let start = (offset.max(0) as usize).min(b.len()); |
| 255 | 0× | let end = (start + size as usize).min(b.len()); |
| 256 | 0× | reply.data(&b[start..end]); |
| 257 | 0× | } |
| 258 | 0× | Err(_) => reply.error(libc::EIO), |
| 259 | | } |
| 260 | 0× | } |
| 261 | |
|
| 262 | 0× | fn readdir(&mut self, _req: &Request<'_>, ino: u64, _fh: u64, offset: i64, mut reply: ReplyDirectory) { |
| 263 | 0× | let Some(dir) = self.inode(ino) else { |
| 264 | 0× | reply.error(libc::ENOENT); |
| 265 | 0× | return; |
| 266 | | }; |
| 267 | 0× | let mut entries: Vec<(u64, FileType, OsString)> = |
| 268 | 0× | vec![(ino, FileType::Directory, ".".into()), (dir.parent, FileType::Directory, "..".into())]; |
| 269 | 0× | for (name, child) in &dir.children { |
| 270 | 0× | let kind = match self.inodes[*child as usize - 1].kind { |
| 271 | 0× | NodeKind::Dir | NodeKind::Subrepo => FileType::Directory, |
| 272 | 0× | NodeKind::Symlink => FileType::Symlink, |
| 273 | 0× | _ => FileType::RegularFile, |
| 274 | | }; |
| 275 | 0× | entries.push((*child, kind, name.clone())); |
| 276 | | } |
| 277 | 0× | for (i, (child, kind, name)) in entries.into_iter().enumerate().skip(offset.max(0) as usize) { |
| 278 | 0× | if reply.add(child, (i + 1) as i64, kind, name) { |
| 279 | 0× | break; |
| 280 | 0× | } |
| 281 | | } |
| 282 | 0× | reply.ok(); |
| 283 | 0× | } |
| 284 | | } |
| 285 | |
|
| 286 | 0× | fn main() -> Result<()> { |
| 287 | 0× | let args: Vec<String> = std::env::args().collect(); |
| 288 | 0× | let usage = "usage: flow-casfs mount REPO MOUNTPOINT [--bookmark NAME]"; |
| 289 | 0× | if args.len() < 4 || args[1] != "mount" { |
| 290 | 0× | bail!("{usage}"); |
| 291 | 0× | } |
| 292 | 0× | let (repo_dir, mountpoint) = (Path::new(&args[2]), Path::new(&args[3])); |
| 293 | 0× | let bookmark = args.iter().position(|a| a == "--bookmark").and_then(|i| args.get(i + 1)).cloned(); |
| 294 | 0× | let started = Instant::now(); |
| 295 | 0× | let mut repo = Repo::open(repo_dir).with_context(|| format!("open {}", repo_dir.display()))?; |
| 296 | 0× | if let Some(b) = &bookmark { |
| 297 | 0× | repo.config.bookmark = b.clone(); |
| 298 | 0× | } |
| 299 | 0× | let heads = repo.bookmark_heads()?; |
| 300 | 0× | let state = repo.load_state_indexed(&heads)?; |
| 301 | 0× | let loaded = started.elapsed(); |
| 302 | 0× | let fs = CasFs::new(repo, state); |
| 303 | 0× | eprintln!( |
| 304 | | "flow-casfs: {} entries; state loaded in {:.1} ms, tree built in {:.1} ms", |
| 305 | 0× | fs.inodes.len(), |
| 306 | 0× | loaded.as_secs_f64() * 1000.0, |
| 307 | 0× | (started.elapsed() - loaded).as_secs_f64() * 1000.0 |
| 308 | | ); |
| 309 | 0× | let options = [MountOption::RO, MountOption::FSName("flow-casfs".into()), MountOption::DefaultPermissions]; |
| 310 | 0× | let session = fuser::spawn_mount2(fs, mountpoint, &options).context("mount")?; |
| 311 | 0× | eprintln!("flow-casfs: mounted at {} in {:.1} ms", mountpoint.display(), started.elapsed().as_secs_f64() * 1000.0); |
| 312 | | |
| 313 | 0× | session.guard.join().ok(); |
| 314 | 0× | Ok(()) |
| 315 | 0× | } |
| 316 | |
|
| 317 | | #[cfg(test)] |
| 318 | | mod tests { |
| 319 | | use super::*; |
| 320 | |
|
| 321 | | |
| 322 | 6× | fn ino_of(fs: &CasFs, path: &str) -> Option<u64> { |
| 323 | 6× | let mut ino = 1u64; |
| 324 | 10× | for part in path.split('/') { |
| 325 | 10× | let dir = fs.inode(ino)?; |
| 326 | 19× | let i = dir.children.binary_search_by(|(n, _)| n.as_os_str().cmp(OsStr::new(part))).ok()?; |
| 327 | 9× | ino = dir.children[i].1; |
| 328 | | } |
| 329 | 5× | Some(ino) |
| 330 | 6× | } |
| 331 | |
|
| 332 | | #[test] |
| 333 | 1× | fn a_mounted_state_holds_the_committed_files() { |
| 334 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 335 | 1× | std::env::set_var("OXY_HOME", dir.path().join("home")); |
| 336 | 1× | let root = dir.path().join("repo"); |
| 337 | 1× | let repo = Repo::init(&root).unwrap(); |
| 338 | 3000× | let big: String = (0..3000).map(|i| format!("line {i}\n")).collect(); |
| 339 | 1× | let files = [("a.txt", "one\ntwo\n".to_string()), ("src/lib.rs", "pub fn f() {}\n".to_string()), ("src/deep/big.txt", big)]; |
| 340 | 3× | for (p, c) in &files { |
| 341 | 3× | let full = root.join(p); |
| 342 | 3× | std::fs::create_dir_all(full.parent().unwrap()).unwrap(); |
| 343 | 3× | std::fs::write(full, c).unwrap(); |
| 344 | 3× | } |
| 345 | 1× | repo.commit("first", &[]).unwrap(); |
| 346 | | |
| 347 | 1× | std::fs::write(root.join("a.txt"), "one\n2\ntwo\n").unwrap(); |
| 348 | 1× | repo.commit("second", &[]).unwrap(); |
| 349 | 1× | let heads = repo.bookmark_heads().unwrap(); |
| 350 | 1× | let state = repo.load_state_indexed(&heads).unwrap(); |
| 351 | 1× | let mut fs = CasFs::new(Repo::open(&root).unwrap(), state); |
| 352 | 2× | for (p, c) in [("a.txt", "one\n2\ntwo\n"), ("src/lib.rs", "pub fn f() {}\n")] { |
| 353 | 2× | let ino = ino_of(&fs, p).unwrap(); |
| 354 | 2× | assert_eq!(fs.content(ino).unwrap().as_slice(), c.as_bytes(), "{p}"); |
| 355 | | } |
| 356 | 1× | let big = ino_of(&fs, "src/deep/big.txt").unwrap(); |
| 357 | 1× | assert_eq!(fs.content(big).unwrap().as_slice(), files[2].1.as_bytes()); |
| 358 | 1× | assert_eq!(fs.attr(big).unwrap().size, files[2].1.len() as u64); |
| 359 | 1× | assert_eq!(fs.attr(ino_of(&fs, "src/deep").unwrap()).unwrap().kind, FileType::Directory); |
| 360 | 1× | assert!(ino_of(&fs, "missing.txt").is_none()); |
| 361 | | |
| 362 | | |
| 363 | | |
| 364 | 1× | let cached = std::fs::read_dir(&fs.cache_dir).unwrap().count(); |
| 365 | 1× | assert_eq!(cached, 1, "renderings cached"); |
| 366 | 1× | let state = repo.load_state_indexed(&heads).unwrap(); |
| 367 | 1× | let mut again = CasFs::new(Repo::open(&root).unwrap(), state); |
| 368 | 1× | let ino = ino_of(&again, "a.txt").unwrap(); |
| 369 | 1× | assert_eq!(again.content(ino).unwrap().as_slice(), b"one\n2\ntwo\n"); |
| 370 | 1× | } |
| 371 | | } |