use fuser::{ FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, Request, }; use furumi_client_core::FurumiClient; use std::collections::HashMap; use std::ffi::OsStr; use std::sync::{Arc, Mutex}; use std::time::{Duration, UNIX_EPOCH}; use tracing::{debug, error}; use tokio::runtime::Handle; const TTL: Duration = Duration::from_secs(5); // 5 second FUSE kernel TTL (matches attr_cache) // ── InodeMapper ────────────────────────────────────────────────── /// Maps FUSE inodes (u64) to remote virtual filesystem paths (String) and vice versa. /// Inode 1 is permanently assigned to the root directory ("/"). pub struct InodeMapper { inode_to_path: Arc>>, path_to_inode: Arc>>, next_inode: Arc>, } impl InodeMapper { pub fn new() -> Self { let mut inode_to_path = HashMap::new(); let mut path_to_inode = HashMap::new(); // Root inode is always 1 inode_to_path.insert(1, "/".to_string()); path_to_inode.insert("/".to_string(), 1); Self { inode_to_path: Arc::new(Mutex::new(inode_to_path)), path_to_inode: Arc::new(Mutex::new(path_to_inode)), next_inode: Arc::new(Mutex::new(2)), // Inodes start from 2 } } pub fn get_inode(&self, path: &str) -> u64 { let mut p2i = self.path_to_inode.lock().unwrap(); if let Some(&inode) = p2i.get(path) { inode } else { let mut next = self.next_inode.lock().unwrap(); let inode = *next; *next += 1; p2i.insert(path.to_string(), inode); self.inode_to_path.lock().unwrap().insert(inode, path.to_string()); inode } } pub fn get_path(&self, inode: u64) -> Option { self.inode_to_path.lock().unwrap().get(&inode).cloned() } } // ── FurumiFuse ────────────────────────────────────────────────── pub struct FurumiFuse { client: FurumiClient, rt_handle: Handle, mapper: InodeMapper, } impl FurumiFuse { pub fn new(client: FurumiClient, rt_handle: Handle) -> Self { Self { client, rt_handle, mapper: InodeMapper::new(), } } fn make_attr(inode: u64, attr: &furumi_common::proto::AttrResponse) -> FileAttr { let kind = if attr.mode & libc::S_IFDIR != 0 { FileType::Directory } else { FileType::RegularFile }; FileAttr { ino: inode, size: attr.size, blocks: (attr.size + 511) / 512, atime: UNIX_EPOCH + Duration::from_secs(attr.mtime), mtime: UNIX_EPOCH + Duration::from_secs(attr.mtime), ctime: UNIX_EPOCH + Duration::from_secs(attr.mtime), crtime: UNIX_EPOCH + Duration::from_secs(attr.mtime), kind, perm: (attr.mode & 0o777) as u16, nlink: if kind == FileType::Directory { 2 } else { 1 }, uid: unsafe { libc::getuid() }, // Mount as current user gid: unsafe { libc::getgid() }, rdev: 0, blksize: 4096, flags: 0, } } } impl Filesystem for FurumiFuse { fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) { let name_str = name.to_string_lossy(); debug!("lookup: parent={} name={}", parent, name_str); let parent_path = match self.mapper.get_path(parent) { Some(p) => p, None => { reply.error(libc::ENOENT); return; } }; let full_path = if parent_path == "/" { format!("/{}", name_str) } else { format!("{}/{}", parent_path, name_str) }; let client = self.client.clone(); let path_clone = full_path.clone(); // Must block inside FUSE callbacks because fuser is synchronous let attr_res = self.rt_handle.block_on(async { client.get_attr(&path_clone).await }); match attr_res { Ok(attr) => { let inode = self.mapper.get_inode(&full_path); reply.entry(&TTL, &Self::make_attr(inode, &attr), 0); } Err(_) => { reply.error(libc::ENOENT); } } } fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option, reply: ReplyAttr) { debug!("getattr: ino={}", ino); let path = match self.mapper.get_path(ino) { Some(p) => p, None => { reply.error(libc::ENOENT); return; } }; let client = self.client.clone(); let attr_res = self.rt_handle.block_on(async { client.get_attr(&path).await }); match attr_res { Ok(attr) => { reply.attr(&TTL, &Self::make_attr(ino, &attr)); } Err(_) => reply.error(libc::EIO), } } fn readdir( &mut self, _req: &Request, ino: u64, _fh: u64, offset: i64, mut reply: ReplyDirectory, ) { debug!("readdir: ino={} offset={}", ino, offset); let path = match self.mapper.get_path(ino) { Some(p) => p, None => { reply.error(libc::ENOENT); return; } }; let client = self.client.clone(); let dir_res = self.rt_handle.block_on(async { client.read_dir(&path).await }); match dir_res { Ok(entries) => { // FUSE readdir requires us to push entries. // offset 0 is ., offset 1 is .. if offset == 0 { let _ = reply.add(ino, 1, FileType::Directory, "."); let _ = reply.add(ino, 2, FileType::Directory, ".."); } for (i, entry) in entries.iter().enumerate() { let entry_offset = (i + 2) as i64; if entry_offset < offset { continue; } let full_path = if path == "/" { format!("/{}", entry.name) } else { format!("{}/{}", path, entry.name) }; let child_ino = self.mapper.get_inode(&full_path); let kind = if entry.r#type == 4 { FileType::Directory } else { FileType::RegularFile }; // reply.add returns true if the buffer is full if reply.add(child_ino, entry_offset + 1, kind, &entry.name) { break; } } reply.ok(); } Err(e) => { error!("readdir error: {:?}", e); reply.error(libc::EIO); } } } fn read( &mut self, _req: &Request, ino: u64, _fh: u64, offset: i64, size: u32, _flags: i32, _lock_owner: Option, reply: ReplyData, ) { debug!("read: ino={} offset={} size={}", ino, offset, size); let path = match self.mapper.get_path(ino) { Some(p) => p, None => { reply.error(libc::ENOENT); return; } }; let client = self.client.clone(); // We use block_on to convert the gRPC stream into a synchronous read for FUSE let read_res: Result, String> = self.rt_handle.block_on(async { match client.read_file(&path, offset as u64, size, size).await { Ok(mut stream) => { use tokio_stream::StreamExt; let mut data = Vec::new(); while let Some(chunk_res) = stream.next().await { match chunk_res { Ok(chunk) => data.extend_from_slice(&chunk.data), Err(e) => return Err(e.to_string()), } } Ok(data) } Err(e) => Err(e.to_string()), } }); match read_res { Ok(data) => reply.data(&data), Err(e) => { error!("read error: {:?}", e); reply.error(libc::EIO); } } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_inode_mapper_root() { let mapper = InodeMapper::new(); // Root is always 1 assert_eq!(mapper.get_path(1).unwrap(), "/"); assert_eq!(mapper.get_inode("/"), 1); } #[test] fn test_inode_mapper_assignment() { let mapper = InodeMapper::new(); // New paths get new inodes starting from 2 let path1 = "/hello.txt"; let ino1 = mapper.get_inode(path1); assert_eq!(ino1, 2); let path2 = "/dir/world.mkv"; let ino2 = mapper.get_inode(path2); assert_eq!(ino2, 3); // Lookup gives the mapped paths back assert_eq!(mapper.get_path(2).unwrap(), path1); assert_eq!(mapper.get_path(3).unwrap(), path2); // Asking for the same path again returns the same inode assert_eq!(mapper.get_inode(path1), 2); } #[test] fn test_inode_mapper_non_existent() { let mapper = InodeMapper::new(); assert!(mapper.get_path(999).is_none()); } }