use crate::error::{ClientError, Result}; use furumi_common::proto::{ remote_file_system_client::RemoteFileSystemClient, AttrResponse, DirEntry, FileChunk, PathRequest, ReadRequest, }; use moka::future::Cache; use std::future::Future; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Duration; use tokio::net::TcpStream; use tokio_stream::StreamExt; use tonic::codegen::InterceptedService; use tonic::metadata::MetadataValue; use tonic::transport::{Channel, Endpoint, Uri}; use tonic::{Request, Status}; use tracing::{debug, info}; // ── Auth interceptor ─────────────────────────────────────────── #[derive(Clone)] pub struct AuthInterceptor { token: String, } impl tonic::service::Interceptor for AuthInterceptor { fn call(&mut self, mut request: Request<()>) -> std::result::Result, Status> { if !self.token.is_empty() { let token_str = format!("Bearer {}", self.token); let meta_val = MetadataValue::try_from(&token_str) .map_err(|_| Status::invalid_argument("Invalid token format"))?; request.metadata_mut().insert("authorization", meta_val); } Ok(request) } } pub type GrpcClient = RemoteFileSystemClient>; // ── TLS: accept any certificate (encryption only) ────────────── /// A rustls verifier that accepts any server certificate without validation. /// Provides encryption without certificate trust chain verification. #[derive(Debug)] struct NoVerifier; impl rustls::client::danger::ServerCertVerifier for NoVerifier { fn verify_server_cert( &self, _end_entity: &rustls::pki_types::CertificateDer<'_>, _intermediates: &[rustls::pki_types::CertificateDer<'_>], _server_name: &rustls::pki_types::ServerName<'_>, _ocsp_response: &[u8], _now: rustls::pki_types::UnixTime, ) -> std::result::Result { Ok(rustls::client::danger::ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, _message: &[u8], _cert: &rustls::pki_types::CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> std::result::Result { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], _cert: &rustls::pki_types::CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> std::result::Result { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { rustls::crypto::ring::default_provider() .signature_verification_algorithms .supported_schemes() } } /// Custom tower connector: TCP + TLS with NoVerifier, compatible with tonic's /// `connect_with_connector` API. Bypasses tonic's built-in TLS which doesn't /// support skip-verify in v0.12. #[derive(Clone)] struct InsecureTlsConnector { tls: tokio_rustls::TlsConnector, } impl tower::Service for InsecureTlsConnector { type Response = hyper_util::rt::TokioIo>; type Error = Box; type Future = Pin> + Send>>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { Poll::Ready(Ok(())) } fn call(&mut self, uri: Uri) -> Self::Future { let tls = self.tls.clone(); Box::pin(async move { let host = uri.host().unwrap_or("127.0.0.1").to_string(); let port = uri.port_u16().unwrap_or(50051); let addr = format!("{}:{}", host, port); let tcp = TcpStream::connect(&addr).await?; // Parse the server name — works with both hostnames and IP addresses let server_name = rustls::pki_types::ServerName::try_from(host.as_str()) .unwrap_or_else(|_| { rustls::pki_types::ServerName::try_from("furumi-ng") .expect("static string is valid") }); let tls_stream = tls.connect(server_name.to_owned(), tcp).await?; Ok(hyper_util::rt::TokioIo::new(tls_stream)) }) } } // ── Client ────────────────────────────────────────────────────── #[derive(Clone)] pub struct FurumiClient { client: GrpcClient, attr_cache: Cache, } impl FurumiClient { /// Connects to the Furumi-ng server. /// /// - Use `https://` for encrypted connection (accepts any server certificate) /// - Use `http://` for plaintext (not recommended) /// /// No certificate files needed — TLS provides encryption only. pub async fn connect(addr: &str, token: &str) -> Result { // Install ring as the default crypto provider for rustls let _ = rustls::crypto::ring::default_provider().install_default(); let is_https = addr.starts_with("https://"); let tonic_addr = if is_https { // Trick tonic into thinking this is a plaintext connection so it doesn't // error out with HttpsUriWithoutTlsSupport. Our custom connector will // handle the actual TLS wrapping. addr.replacen("https://", "http://", 1) } else { addr.to_string() }; let endpoint = Endpoint::from_shared(tonic_addr) .map_err(|e| ClientError::Internal(format!("Invalid URI: {}", e)))? .timeout(Duration::from_secs(30)) .concurrency_limit(256) .tcp_keepalive(Some(Duration::from_secs(60))) .http2_keep_alive_interval(Duration::from_secs(60)) .keep_alive_while_idle(true); let channel = if is_https { info!("TLS enabled (encryption only, certificate verification disabled)"); let crypto = rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(NoVerifier)) .with_no_client_auth(); let connector = InsecureTlsConnector { tls: tokio_rustls::TlsConnector::from(Arc::new(crypto)), }; endpoint.connect_with_connector(connector).await? } else { info!("Connecting without TLS (plaintext)"); endpoint.connect().await? }; let interceptor = AuthInterceptor { token: token.to_string(), }; let client = RemoteFileSystemClient::with_interceptor(channel, interceptor); let attr_cache = Cache::builder() .max_capacity(100_000) .time_to_live(Duration::from_secs(5)) .build(); Ok(Self { client, attr_cache }) } /// Fetches file attributes from the server, utilizing an internal cache. pub async fn get_attr(&self, path: &str) -> Result { if let Some(attr) = self.attr_cache.get(path).await { return Ok(attr); } debug!("get_attr (cache miss): {}", path); let mut client = self.client.clone(); let req = tonic::Request::new(PathRequest { path: path.to_string(), }); let response = client.get_attr(req).await?.into_inner(); self.attr_cache.insert(path.to_string(), response.clone()).await; Ok(response) } /// Reads directory contents from the server stream. pub async fn read_dir(&self, path: &str) -> Result> { debug!("read_dir: {}", path); let mut client = self.client.clone(); let req = tonic::Request::new(PathRequest { path: path.to_string(), }); let mut stream = client.read_dir(req).await?.into_inner(); let mut entries = Vec::new(); while let Some(chunk) = stream.next().await { let entry = chunk?; entries.push(entry); } Ok(entries) } /// Fetches file chunk stream from the server. pub async fn read_file( &self, path: &str, offset: u64, size: u32, chunk_size: u32, ) -> Result> { debug!("read_file: {} offset={} size={}", path, offset, size); let mut client = self.client.clone(); let req = tonic::Request::new(ReadRequest { path: path.to_string(), offset, size, chunk_size, }); let stream = client.read_file(req).await?.into_inner(); Ok(stream) } } #[cfg(test)] mod tests;