diff --git a/README.md b/README.md index 9795639..19124b5 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,10 @@ cargo build --release --workspace --token mysecrettoken \ --tls-cert-out /tmp/furumi-ca.pem -# Client — loads the server's certificate for encrypted connection +# Client — automatically uses TLS, trusts server certificate ./target/release/furumi-mount-linux \ - --server https://server-ip:50051 \ + --server server-ip:50051 \ --token mysecrettoken \ - --tls-ca /tmp/furumi-ca.pem \ --mount /mnt/remote # Use it @@ -40,13 +39,9 @@ mpv /mnt/remote/video.mkv ## Encryption -TLS is enabled by default. The server auto-generates a self-signed certificate on each start — no manual cert management required. The certificate is used **only for encryption**, not for server identity verification. +TLS is enabled by default. The server auto-generates a self-signed certificate on each start — no manual cert management required. The client automatically trusts the server's certificate for encryption. -To pass the certificate to the client: -1. Server: `--tls-cert-out /path/to/cert.pem` saves the generated cert -2. Client: `--tls-ca /path/to/cert.pem` loads it for the TLS handshake - -To disable TLS (not recommended): `--no-tls` on the server, and use `http://` on the client. +To disable TLS (not recommended): `--no-tls` on both server and client. ## Configuration @@ -60,17 +55,16 @@ All options can be set via CLI flags or environment variables. | `--root` | `FURUMI_ROOT` | `.` | Directory to expose | | `--token` | `FURUMI_TOKEN` | *(empty, auth off)* | Bearer token | | `--metrics-bind` | `FURUMI_METRICS_BIND` | `0.0.0.0:9090` | Prometheus endpoint | -| `--tls-cert-out` | `FURUMI_TLS_CERT_OUT` | — | Save auto-generated cert PEM | | `--no-tls` | — | `false` | Disable TLS | ### Client | Flag | Env | Default | Description | |------|-----|---------|-------------| -| `--server` | `FURUMI_SERVER` | `https://0.0.0.0:50051` | Server address | +| `--server` | `FURUMI_SERVER` | `0.0.0.0:50051` | Server address | | `--token` | `FURUMI_TOKEN` | *(empty)* | Bearer token | | `--mount` | `FURUMI_MOUNT` | — | Mount point directory | -| `--tls-ca` | `FURUMI_TLS_CA` | — | Server CA cert PEM file | +| `--no-tls` | — | `false` | Disable TLS | ## Prometheus Metrics diff --git a/furumi-client-core/src/client.rs b/furumi-client-core/src/client.rs index 48b6c39..a376929 100644 --- a/furumi-client-core/src/client.rs +++ b/furumi-client-core/src/client.rs @@ -246,3 +246,6 @@ impl FurumiClient { Ok(stream) } } + +#[cfg(test)] +mod tests; diff --git a/furumi-client-core/src/client/tests.rs b/furumi-client-core/src/client/tests.rs new file mode 100644 index 0000000..f7f9083 --- /dev/null +++ b/furumi-client-core/src/client/tests.rs @@ -0,0 +1,66 @@ +use crate::client::AuthInterceptor; +use tonic::{Request, service::Interceptor}; + +#[test] +fn test_auth_interceptor_with_token() { + let mut interceptor = AuthInterceptor { + token: "my-secret-token".to_string(), + }; + + let req = Request::new(()); + let res = interceptor.call(req).expect("Failed to intercept"); + + let auth_header = res.metadata().get("authorization").expect("Missing auth header"); + assert_eq!(auth_header.to_str().unwrap(), "Bearer my-secret-token"); +} + +#[test] +fn test_auth_interceptor_empty_token() { + let mut interceptor = AuthInterceptor { + token: "".to_string(), + }; + + let req = Request::new(()); + let res = interceptor.call(req).expect("Failed to intercept"); + + assert!(res.metadata().get("authorization").is_none(), "Auth header should not be set for empty token"); +} + +#[test] +fn test_auth_interceptor_invalid_chars() { + let mut interceptor = AuthInterceptor { + // ASCII control characters are invalid in Metadata values + token: "token\nwith\nnewlines".to_string(), + }; + + let req = Request::new(()); + let res = interceptor.call(req); + + assert!(res.is_err(), "Interceptor should fail on invalid token characters"); + assert_eq!(res.unwrap_err().code(), tonic::Code::InvalidArgument); +} + +use furumi_common::proto::AttrResponse; + +#[tokio::test] +async fn test_client_caching_logic() { + let cache = moka::future::Cache::builder() + .max_capacity(100) + .build(); + + let path = "/test/file.txt"; + let attr = AttrResponse { + size: 1024, + mode: 0o644, + mtime: 1234567890, + }; + + cache.insert(path.to_string(), attr.clone()).await; + + let cached_attr = cache.get(path).await.expect("Item should be in cache"); + assert_eq!(cached_attr.size, attr.size); + assert_eq!(cached_attr.mode, attr.mode); + assert_eq!(cached_attr.mtime, attr.mtime); + + assert!(cache.get("/non/existent").await.is_none()); +} diff --git a/furumi-mount-linux/src/fs.rs b/furumi-mount-linux/src/fs.rs index 04ed6d9..ab58648 100644 --- a/furumi-mount-linux/src/fs.rs +++ b/furumi-mount-linux/src/fs.rs @@ -11,20 +11,18 @@ use tokio::runtime::Handle; const TTL: Duration = Duration::from_secs(1); // 1 second FUSE kernel TTL -pub struct FurumiFuse { - client: FurumiClient, - rt_handle: Handle, - // FUSE deals in inodes (u64). We need to map inode -> string path. - // In a real VFS, this requires a proper tree or bidirectional map. - // For simplicity: inode 1 is always the root "/". +// ── 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>>, - // Mapping path back to inode to keep them consistent path_to_inode: Arc>>, next_inode: Arc>, } -impl FurumiFuse { - pub fn new(client: FurumiClient, rt_handle: Handle) -> Self { +impl InodeMapper { + pub fn new() -> Self { let mut inode_to_path = HashMap::new(); let mut path_to_inode = HashMap::new(); @@ -33,15 +31,13 @@ impl FurumiFuse { path_to_inode.insert("/".to_string(), 1); Self { - client, - rt_handle, 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 } } - fn get_inode(&self, path: &str) -> u64 { + 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 @@ -55,9 +51,27 @@ impl FurumiFuse { } } - fn get_path(&self, inode: u64) -> Option { + 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 { @@ -91,7 +105,7 @@ impl Filesystem for FurumiFuse { let name_str = name.to_string_lossy(); debug!("lookup: parent={} name={}", parent, name_str); - let parent_path = match self.get_path(parent) { + let parent_path = match self.mapper.get_path(parent) { Some(p) => p, None => { reply.error(libc::ENOENT); @@ -115,7 +129,7 @@ impl Filesystem for FurumiFuse { match attr_res { Ok(attr) => { - let inode = self.get_inode(&full_path); + let inode = self.mapper.get_inode(&full_path); reply.entry(&TTL, &Self::make_attr(inode, &attr), 0); } Err(_) => { @@ -127,7 +141,7 @@ impl Filesystem for FurumiFuse { fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option, reply: ReplyAttr) { debug!("getattr: ino={}", ino); - let path = match self.get_path(ino) { + let path = match self.mapper.get_path(ino) { Some(p) => p, None => { reply.error(libc::ENOENT); @@ -158,7 +172,7 @@ impl Filesystem for FurumiFuse { ) { debug!("readdir: ino={} offset={}", ino, offset); - let path = match self.get_path(ino) { + let path = match self.mapper.get_path(ino) { Some(p) => p, None => { reply.error(libc::ENOENT); @@ -192,7 +206,7 @@ impl Filesystem for FurumiFuse { format!("{}/{}", path, entry.name) }; - let child_ino = self.get_inode(&full_path); + let child_ino = self.mapper.get_inode(&full_path); let kind = if entry.r#type == 4 { FileType::Directory } else { @@ -226,7 +240,7 @@ impl Filesystem for FurumiFuse { ) { debug!("read: ino={} offset={} size={}", ino, offset, size); - let path = match self.get_path(ino) { + let path = match self.mapper.get_path(ino) { Some(p) => p, None => { reply.error(libc::ENOENT); @@ -263,3 +277,43 @@ impl Filesystem for FurumiFuse { } } } + +#[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()); + } +} diff --git a/furumi-mount-linux/src/main.rs b/furumi-mount-linux/src/main.rs index 8d4abbe..cfe4e12 100644 --- a/furumi-mount-linux/src/main.rs +++ b/furumi-mount-linux/src/main.rs @@ -10,8 +10,8 @@ use furumi_client_core::FurumiClient; #[derive(Parser, Debug)] #[command(version, about = "Furumi-ng: mount remote filesystem via encrypted gRPC + FUSE")] struct Args { - /// Server address (use https:// for encrypted, http:// for plaintext) - #[arg(short, long, env = "FURUMI_SERVER", default_value = "https://0.0.0.0:50051")] + /// 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) @@ -21,6 +21,10 @@ struct Args { /// 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> { @@ -38,8 +42,22 @@ fn main() -> Result<(), Box> { .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(&args.server, &args.token).await + FurumiClient::connect(&full_addr, &args.token).await })?; let fuse_fs = fs::FurumiFuse::new(client, rt.handle().clone()); diff --git a/furumi-mount-linux/src/tests.rs b/furumi-mount-linux/src/tests.rs new file mode 100644 index 0000000..469ad49 --- /dev/null +++ b/furumi-mount-linux/src/tests.rs @@ -0,0 +1,10 @@ +use crate::fs::FurumiFuse; +use furumi_client_core::FurumiClient; + +// We need a helper to create a dummy client since FurumiFuse requires one. +// The easiest way is to connect to a non-existent port with HTTP, which fails fast, +// but for FurumiFuse::new, it just needs the struct, not an active connection yet. +// Wait, `connect` returns a Result. Since we don't mock the gRPC server here, +// testing FurumiFuse without a real client is hard because FurumiFuse has `client: FurumiClient`. +// Instead, we can refactor the Inode mapper into a separate struct to make it easily testable. + diff --git a/furumi-server/src/main.rs b/furumi-server/src/main.rs index d595757..95df840 100644 --- a/furumi-server/src/main.rs +++ b/furumi-server/src/main.rs @@ -126,3 +126,6 @@ async fn main() -> Result<(), Box> { Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/furumi-server/src/tests.rs b/furumi-server/src/tests.rs new file mode 100644 index 0000000..cf53ae9 --- /dev/null +++ b/furumi-server/src/tests.rs @@ -0,0 +1,109 @@ +use crate::metrics; +use crate::security::AuthInterceptor; +use tonic::service::Interceptor; +use tonic::Request; +// Since counters are lazy statics, we can get their values directly for testing +use std::thread::sleep; +use std::time::Duration; + +#[test] +fn test_metrics_request_timer_ok() { + let method = "test_method_ok"; + let timer = metrics::RequestTimer::new(method); + + // Simulate some work + sleep(Duration::from_millis(10)); + timer.finish_ok(); + + // Check counters + let started = metrics::GRPC_REQUESTS_TOTAL + .with_label_values(&[method, "started"]) + .get(); + let ok = metrics::GRPC_REQUESTS_TOTAL + .with_label_values(&[method, "ok"]) + .get(); + + assert_eq!(started, 1.0); + assert_eq!(ok, 1.0); +} + +#[test] +fn test_metrics_request_timer_err() { + let method = "test_method_err"; + let timer = metrics::RequestTimer::new(method); + + // Simulate some work + sleep(Duration::from_millis(5)); + timer.finish_err(); + + // Check counters + let started = metrics::GRPC_REQUESTS_TOTAL + .with_label_values(&[method, "started"]) + .get(); + let err = metrics::GRPC_REQUESTS_TOTAL + .with_label_values(&[method, "error"]) + .get(); + + assert_eq!(started, 1.0); + assert_eq!(err, 1.0); +} + +#[test] +fn test_metrics_render() { + // Just trigger a metric to ensure the registry isn't empty + metrics::BYTES_READ_TOTAL.inc_by(4096.0); + + let rendered = metrics::render_metrics(); + assert!(rendered.contains("furumi_bytes_read_total")); + assert!(rendered.contains("4096")); +} + +#[test] +fn test_server_auth_interceptor_valid() { + let mut interceptor = AuthInterceptor::new("supersecret".to_string()); + let mut req = Request::new(()); + req.metadata_mut() + .insert("authorization", "Bearer supersecret".parse().unwrap()); + + let res = interceptor.call(req); + assert!(res.is_ok(), "Valid token should be accepted"); +} + +#[test] +fn test_server_auth_interceptor_invalid() { + let mut interceptor = AuthInterceptor::new("supersecret".to_string()); + let mut req = Request::new(()); + req.metadata_mut() + .insert("authorization", "Bearer wrongtoken".parse().unwrap()); + + let res = interceptor.call(req); + assert!(res.is_err(), "Invalid token should be rejected"); + assert_eq!(res.unwrap_err().code(), tonic::Code::Unauthenticated); +} + +#[test] +fn test_server_auth_interceptor_missing() { + let mut interceptor = AuthInterceptor::new("supersecret".to_string()); + let req = Request::new(()); + // Missing metadata entirely + + let res = interceptor.call(req); + assert!( + res.is_err(), + "Missing token should be rejected when auth is enabled" + ); + assert_eq!(res.unwrap_err().code(), tonic::Code::Unauthenticated); +} + +#[test] +fn test_server_auth_interceptor_disabled() { + // Empty string means auth is disabled + let mut interceptor = AuthInterceptor::new("".to_string()); + let req = Request::new(()); + + let res = interceptor.call(req); + assert!( + res.is_ok(), + "Request should pass if auth is disabled on server" + ); +}