320 lines
9.7 KiB
Rust
320 lines
9.7 KiB
Rust
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<Mutex<HashMap<u64, String>>>,
|
|
path_to_inode: Arc<Mutex<HashMap<String, u64>>>,
|
|
next_inode: Arc<Mutex<u64>>,
|
|
}
|
|
|
|
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<String> {
|
|
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<u64>, 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<u64>,
|
|
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<Vec<u8>, 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());
|
|
}
|
|
}
|