From 57c9a3f45634aca921e02564370b0df72686a8c7 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 11 Mar 2026 00:05:13 +0000 Subject: [PATCH] Added macos client --- Cargo.lock | 159 +++++++++++-- Cargo.toml | 8 +- furumi-mount-macos/Cargo.toml | 18 ++ furumi-mount-macos/src/main.rs | 125 ++++++++++ furumi-mount-macos/src/nfs.rs | 406 +++++++++++++++++++++++++++++++++ 5 files changed, 692 insertions(+), 24 deletions(-) create mode 100644 furumi-mount-macos/Cargo.toml create mode 100644 furumi-mount-macos/src/main.rs create mode 100644 furumi-mount-macos/src/nfs.rs diff --git a/Cargo.lock b/Cargo.lock index 7675767..c082b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,7 +91,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -103,7 +103,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -136,7 +136,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -147,7 +147,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -266,12 +266,27 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytestream" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f720842a717d6afaf69fee2dc69b771edc165f12cc3eb1b0e8eeef53a86454" +dependencies = [ + "byteorder", +] + [[package]] name = "cc" version = "1.2.56" @@ -327,7 +342,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -460,7 +475,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -518,6 +533,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -606,6 +632,24 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "furumi-mount-macos" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "ctrlc", + "furumi-client-core", + "furumi-common", + "libc", + "nfsserve", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", +] + [[package]] name = "furumi-server" version = "0.1.0" @@ -708,7 +752,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1032,6 +1076,18 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1129,6 +1185,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "nfsserve" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73615e054238e6bf5e554407b5b23e82fc63616db459057c51b794799eda6fb" +dependencies = [ + "anyhow", + "async-trait", + "byteorder", + "bytestream", + "filetime", + "futures", + "num-derive", + "num-traits", + "smallvec", + "tokio", + "tracing", + "tracing-attributes", +] + [[package]] name = "nix" version = "0.29.0" @@ -1188,6 +1264,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1282,7 +1369,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1330,7 +1417,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1351,6 +1438,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1379,7 +1472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1456,7 +1549,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn", + "syn 2.0.117", "tempfile", ] @@ -1470,7 +1563,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1585,6 +1678,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1804,7 +1906,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1933,6 +2035,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -1958,7 +2071,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2006,7 +2119,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2017,7 +2130,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2085,7 +2198,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2166,7 +2279,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2237,7 +2350,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2391,7 +2504,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2587,7 +2700,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2603,7 +2716,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2689,7 +2802,7 @@ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c9a3719..4e596cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,12 @@ members = [ "furumi-common", "furumi-server", "furumi-client-core", - "furumi-mount-linux" + "furumi-mount-linux", + "furumi-mount-macos" +] +default-members = [ + "furumi-common", + "furumi-server", + "furumi-client-core", ] resolver = "2" diff --git a/furumi-mount-macos/Cargo.toml b/furumi-mount-macos/Cargo.toml new file mode 100644 index 0000000..86d2d47 --- /dev/null +++ b/furumi-mount-macos/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "furumi-mount-macos" +version = "0.1.0" +edition = "2024" + +[dependencies] +furumi-common = { path = "../furumi-common" } +furumi-client-core = { path = "../furumi-client-core" } +anyhow = "1.0.102" +nfsserve = "0.10.2" +async-trait = "0.1.89" +clap = { version = "4.5.60", features = ["derive", "env"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } +tokio = { version = "1.50.0", features = ["full"] } +tokio-stream = "0.1.18" +ctrlc = "3.5.2" +libc = "0.2.183" diff --git a/furumi-mount-macos/src/main.rs b/furumi-mount-macos/src/main.rs new file mode 100644 index 0000000..64c4bde --- /dev/null +++ b/furumi-mount-macos/src/main.rs @@ -0,0 +1,125 @@ +pub mod nfs; + +use clap::Parser; +use furumi_client_core::FurumiClient; +use nfsserve::tcp::{NFSTcp, NFSTcpListener}; +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tracing::info; + +#[derive(Parser, Debug)] +#[command(version, about = "Furumi-ng: mount remote filesystem via encrypted gRPC + NFS (macOS)")] +struct Args { + /// Server address (IP:PORT, will use https:// by default unless --no-tls is specified) + #[arg(short, long, env = "FURUMI_SERVER", default_value = "0.0.0.0:50051")] + server: String, + + /// Authentication Bearer token (leave empty if auth is disabled on server) + #[arg(short, long, env = "FURUMI_TOKEN", default_value = "")] + token: String, + + /// Mount point directory + #[arg(short, long, env = "FURUMI_MOUNT")] + mount: PathBuf, + + /// Disable TLS encryption + #[arg(long, default_value_t = false)] + no_tls: bool, +} + +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + + if !args.mount.exists() || !args.mount.is_dir() { + eprintln!( + "Error: Mount point {:?} does not exist or is not a directory", + args.mount + ); + std::process::exit(1); + } + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + let full_addr = if args.no_tls { + if args.server.starts_with("http://") || args.server.starts_with("https://") { + args.server.clone() + } else { + format!("http://{}", args.server) + } + } else if args.server.starts_with("http://") || args.server.starts_with("https://") { + args.server.clone() + } else { + format!("https://{}", args.server) + }; + + let client = rt.block_on(async { FurumiClient::connect(&full_addr, &args.token).await })?; + + let furumi_nfs = nfs::FurumiNfs::new(client); + + let mount_point = args.mount.clone(); + let mount_point_umount = args.mount.clone(); + + // Set up signal handler for graceful shutdown + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); + ctrlc::set_handler(move || { + eprintln!("\nReceived signal, unmounting..."); + r.store(false, Ordering::SeqCst); + })?; + + rt.block_on(async move { + // Bind NFS server to a random available port on localhost + let listener = NFSTcpListener::bind("127.0.0.1:0", furumi_nfs) + .await + .expect("Failed to bind NFS listener"); + + let port = listener.get_listen_port(); + info!("NFS server listening on 127.0.0.1:{}", port); + + // Start handling NFS connections BEFORE mounting — + // mount_nfs needs to talk to the server during mount. + let handle = tokio::spawn(async move { + if let Err(e) = listener.handle_forever().await { + eprintln!("NFS server error: {}", e); + } + }); + + // Mount via macOS native mount_nfs + let mount_path = mount_point.to_string_lossy().to_string(); + let opts = format!( + "rdonly,locallocks,noresvport,nfsvers=3,tcp,rsize=1048576,port={},mountport={}", + port, port + ); + + let status = Command::new("mount_nfs") + .args(["-o", &opts, "127.0.0.1:/", &mount_path]) + .status() + .expect("Failed to execute mount_nfs"); + + if !status.success() { + handle.abort(); + eprintln!("Error: mount_nfs failed with status {}", status); + std::process::exit(1); + } + + println!("Mounted Furumi-ng to {:?}", mount_path); + + // Wait for shutdown signal + while running.load(Ordering::SeqCst) { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + // Unmount + let _ = Command::new("umount").arg(mount_point_umount.to_string_lossy().as_ref()).status(); + handle.abort(); + println!("Unmounted successfully."); + }); + + Ok(()) +} diff --git a/furumi-mount-macos/src/nfs.rs b/furumi-mount-macos/src/nfs.rs new file mode 100644 index 0000000..77deef7 --- /dev/null +++ b/furumi-mount-macos/src/nfs.rs @@ -0,0 +1,406 @@ +use async_trait::async_trait; +use furumi_client_core::FurumiClient; +use nfsserve::nfs::{ + fattr3, fileid3, filename3, ftype3, mode3, nfspath3, nfsstat3, nfstime3, sattr3, specdata3, +}; +use nfsserve::vfs::{DirEntry, NFSFileSystem, ReadDirResult, VFSCapabilities}; +use std::collections::HashMap; +use std::sync::Mutex; +use tokio_stream::StreamExt; +use tracing::{debug, error}; + +// ── ID Mapper ────────────────────────────────────────────────── + +/// Maps NFS file IDs (u64) to remote virtual filesystem paths and vice versa. +/// ID 1 is permanently assigned to the root directory ("/"). +/// ID 0 is reserved by NFS and never used. +struct IdMapper { + id_to_path: HashMap, + path_to_id: HashMap, + next_id: fileid3, +} + +impl IdMapper { + fn new() -> Self { + let mut id_to_path = HashMap::new(); + let mut path_to_id = HashMap::new(); + + // Root is always ID 1 (0 is reserved in NFS) + id_to_path.insert(1, "/".to_string()); + path_to_id.insert("/".to_string(), 1); + + Self { + id_to_path, + path_to_id, + next_id: 2, + } + } + + fn get_or_insert(&mut self, path: &str) -> fileid3 { + if let Some(&id) = self.path_to_id.get(path) { + return id; + } + let id = self.next_id; + self.next_id += 1; + self.id_to_path.insert(id, path.to_string()); + self.path_to_id.insert(path.to_string(), id); + id + } + + fn get_path(&self, id: fileid3) -> Option<&str> { + self.id_to_path.get(&id).map(|s| s.as_str()) + } +} + +// ── FurumiNfs ────────────────────────────────────────────────── + +pub struct FurumiNfs { + client: FurumiClient, + mapper: Mutex, +} + +impl FurumiNfs { + pub fn new(client: FurumiClient) -> Self { + Self { + client, + mapper: Mutex::new(IdMapper::new()), + } + } + + /// Build the full remote path from a parent directory ID and a child filename. + fn resolve_child_path(&self, dirid: fileid3, name: &filename3) -> Result { + let mapper = self.mapper.lock().unwrap(); + let parent = mapper.get_path(dirid).ok_or(nfsstat3::NFS3ERR_STALE)?; + let name_str = String::from_utf8_lossy(&name.0); + let path = if parent == "/" { + format!("/{}", name_str) + } else { + format!("{}/{}", parent, name_str) + }; + Ok(path) + } + + /// Convert a gRPC AttrResponse into NFS fattr3, assigning/looking up the file ID. + fn make_fattr( + &self, + path: &str, + attr: &furumi_common::proto::AttrResponse, + ) -> fattr3 { + let mut mapper = self.mapper.lock().unwrap(); + let id = mapper.get_or_insert(path); + + let is_dir = attr.mode & (libc::S_IFDIR as u32) != 0; + let ftype = if is_dir { + ftype3::NF3DIR + } else { + ftype3::NF3REG + }; + + let mtime = nfstime3 { + seconds: attr.mtime as u32, + nseconds: 0, + }; + + let uid = unsafe { libc::getuid() }; + let gid = unsafe { libc::getgid() }; + + fattr3 { + ftype, + mode: (attr.mode & 0o777) as mode3, + nlink: if is_dir { 2 } else { 1 }, + uid, + gid, + size: attr.size, + used: attr.size, + rdev: specdata3::default(), + fsid: 0, + fileid: id, + atime: mtime, + mtime, + ctime: mtime, + } + } +} + +#[async_trait] +impl NFSFileSystem for FurumiNfs { + fn capabilities(&self) -> VFSCapabilities { + VFSCapabilities::ReadOnly + } + + fn root_dir(&self) -> fileid3 { + 1 + } + + async fn lookup(&self, dirid: fileid3, filename: &filename3) -> Result { + let name_str = String::from_utf8_lossy(&filename.0); + debug!("lookup: dirid={} name={}", dirid, name_str); + + let path = self.resolve_child_path(dirid, filename)?; + + let attr = self + .client + .get_attr(&path) + .await + .map_err(|_| nfsstat3::NFS3ERR_NOENT)?; + + let mut mapper = self.mapper.lock().unwrap(); + let id = mapper.get_or_insert(&path); + + // Sanity: verify the attr makes sense (file exists) + let _ = attr; + debug!("lookup: {} -> id={}", path, id); + Ok(id) + } + + async fn getattr(&self, id: fileid3) -> Result { + debug!("getattr: id={}", id); + + let path = { + let mapper = self.mapper.lock().unwrap(); + mapper + .get_path(id) + .ok_or(nfsstat3::NFS3ERR_STALE)? + .to_string() + }; + + let attr = self + .client + .get_attr(&path) + .await + .map_err(|_| nfsstat3::NFS3ERR_IO)?; + + Ok(self.make_fattr(&path, &attr)) + } + + async fn setattr(&self, _id: fileid3, _setattr: sattr3) -> Result { + Err(nfsstat3::NFS3ERR_ROFS) + } + + async fn read( + &self, + id: fileid3, + offset: u64, + count: u32, + ) -> Result<(Vec, bool), nfsstat3> { + debug!("read: id={} offset={} count={}", id, offset, count); + + let path = { + let mapper = self.mapper.lock().unwrap(); + mapper + .get_path(id) + .ok_or(nfsstat3::NFS3ERR_STALE)? + .to_string() + }; + + let mut stream = self + .client + .read_file(&path, offset, count, count) + .await + .map_err(|e| { + error!("read_file error: {:?}", e); + nfsstat3::NFS3ERR_IO + })?; + + 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) => { + error!("read stream error: {:?}", e); + return Err(nfsstat3::NFS3ERR_IO); + } + } + } + + // EOF if we got less data than requested + let eof = (data.len() as u32) < count; + Ok((data, eof)) + } + + async fn write(&self, _id: fileid3, _offset: u64, _data: &[u8]) -> Result { + Err(nfsstat3::NFS3ERR_ROFS) + } + + async fn create( + &self, + _dirid: fileid3, + _filename: &filename3, + _attr: sattr3, + ) -> Result<(fileid3, fattr3), nfsstat3> { + Err(nfsstat3::NFS3ERR_ROFS) + } + + async fn create_exclusive( + &self, + _dirid: fileid3, + _filename: &filename3, + ) -> Result { + Err(nfsstat3::NFS3ERR_ROFS) + } + + async fn mkdir( + &self, + _dirid: fileid3, + _dirname: &filename3, + ) -> Result<(fileid3, fattr3), nfsstat3> { + Err(nfsstat3::NFS3ERR_ROFS) + } + + async fn remove(&self, _dirid: fileid3, _filename: &filename3) -> Result<(), nfsstat3> { + Err(nfsstat3::NFS3ERR_ROFS) + } + + async fn rename( + &self, + _from_dirid: fileid3, + _from_filename: &filename3, + _to_dirid: fileid3, + _to_filename: &filename3, + ) -> Result<(), nfsstat3> { + Err(nfsstat3::NFS3ERR_ROFS) + } + + async fn readdir( + &self, + dirid: fileid3, + start_after: fileid3, + max_entries: usize, + ) -> Result { + debug!( + "readdir: dirid={} start_after={} max={}", + dirid, start_after, max_entries + ); + + let path = { + let mapper = self.mapper.lock().unwrap(); + mapper + .get_path(dirid) + .ok_or(nfsstat3::NFS3ERR_STALE)? + .to_string() + }; + + let entries = self.client.read_dir(&path).await.map_err(|e| { + error!("read_dir error: {:?}", e); + nfsstat3::NFS3ERR_IO + })?; + + // Build full list with IDs and attributes + let mut nfs_entries: Vec = Vec::new(); + + for entry in &entries { + let child_path = if path == "/" { + format!("/{}", entry.name) + } else { + format!("{}/{}", path, entry.name) + }; + + let is_dir = entry.r#type == 4; // DT_DIR + + let child_id = { + let mut mapper = self.mapper.lock().unwrap(); + mapper.get_or_insert(&child_path) + }; + + // Try to get real attributes, fall back to synthetic ones + let attr = match self.client.get_attr(&child_path).await { + Ok(a) => self.make_fattr(&child_path, &a), + Err(_) => { + let mut mapper = self.mapper.lock().unwrap(); + let id = mapper.get_or_insert(&child_path); + let ftype = if is_dir { + ftype3::NF3DIR + } else { + ftype3::NF3REG + }; + fattr3 { + ftype, + mode: if is_dir { 0o755 } else { 0o644 }, + nlink: if is_dir { 2 } else { 1 }, + uid: unsafe { libc::getuid() }, + gid: unsafe { libc::getgid() }, + size: 0, + used: 0, + rdev: specdata3::default(), + fsid: 0, + fileid: id, + atime: nfstime3::default(), + mtime: nfstime3::default(), + ctime: nfstime3::default(), + } + } + }; + + nfs_entries.push(DirEntry { + fileid: child_id, + name: entry.name.as_bytes().into(), + attr, + }); + } + + // Handle pagination: skip entries up to and including start_after + let filtered: Vec = if start_after > 0 { + let skip_pos = nfs_entries + .iter() + .position(|e| e.fileid == start_after) + .map(|p| p + 1) + .unwrap_or(0); + nfs_entries.into_iter().skip(skip_pos).collect() + } else { + nfs_entries + }; + + let end = filtered.len() <= max_entries; + let entries: Vec = filtered.into_iter().take(max_entries).collect(); + + Ok(ReadDirResult { entries, end }) + } + + async fn symlink( + &self, + _dirid: fileid3, + _linkname: &filename3, + _symlink: &nfspath3, + _attr: &sattr3, + ) -> Result<(fileid3, fattr3), nfsstat3> { + Err(nfsstat3::NFS3ERR_ROFS) + } + + async fn readlink(&self, _id: fileid3) -> Result { + Err(nfsstat3::NFS3ERR_NOTSUPP) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_id_mapper_root() { + let mapper = IdMapper::new(); + assert_eq!(mapper.get_path(1).unwrap(), "/"); + } + + #[test] + fn test_id_mapper_assignment() { + let mut mapper = IdMapper::new(); + + let id1 = mapper.get_or_insert("/hello.txt"); + assert_eq!(id1, 2); + + let id2 = mapper.get_or_insert("/dir/world.mkv"); + assert_eq!(id2, 3); + + assert_eq!(mapper.get_path(2).unwrap(), "/hello.txt"); + assert_eq!(mapper.get_path(3).unwrap(), "/dir/world.mkv"); + + // Same path returns same ID + assert_eq!(mapper.get_or_insert("/hello.txt"), 2); + } + + #[test] + fn test_id_mapper_non_existent() { + let mapper = IdMapper::new(); + assert!(mapper.get_path(999).is_none()); + } +}