diff --git a/markbase-core/src/vfs/smb_fs.rs b/markbase-core/src/vfs/smb_fs.rs index ef91484..344ccc6 100644 --- a/markbase-core/src/vfs/smb_fs.rs +++ b/markbase-core/src/vfs/smb_fs.rs @@ -24,9 +24,9 @@ fn map_smb_error(e: smb2::Error) -> VfsError { smb2::ErrorKind::AccessDenied => VfsError::PermissionDenied(e.to_string()), smb2::ErrorKind::IsADirectory => VfsError::IsADirectory(e.to_string()), smb2::ErrorKind::NotADirectory => VfsError::NotADirectory(e.to_string()), - smb2::ErrorKind::ConnectionLost | smb2::ErrorKind::TimedOut | smb2::ErrorKind::SessionExpired => { - VfsError::Io(format!("SMB connection error: {}", e)) - } + smb2::ErrorKind::ConnectionLost + | smb2::ErrorKind::TimedOut + | smb2::ErrorKind::SessionExpired => VfsError::Io(format!("SMB connection error: {}", e)), _ => VfsError::Io(format!("SMB error: {}", e)), } } @@ -39,12 +39,7 @@ pub struct SmbVfs { } impl SmbVfs { - pub fn new( - addr: &str, - share: &str, - username: &str, - password: &str, - ) -> Result { + pub fn new(addr: &str, share: &str, username: &str, password: &str) -> Result { let runtime = Arc::new( tokio::runtime::Builder::new_current_thread() .enable_all() @@ -105,7 +100,10 @@ impl VfsBackend for SmbVfs { fn read_dir(&self, path: &Path) -> Result, VfsError> { let smb_path = Self::path_to_str(path); - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let mut tree = self.tree.lock().map_err(|e| VfsError::Io(e.to_string()))?; let entries = self .runtime @@ -132,13 +130,12 @@ impl VfsBackend for SmbVfs { .collect()) } - fn open_file( - &self, - path: &Path, - flags: &OpenFlags, - ) -> Result, VfsError> { + fn open_file(&self, path: &Path, flags: &OpenFlags) -> Result, VfsError> { let smb_path = Self::path_to_str(path); - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let mut tree = self.tree.lock().map_err(|e| VfsError::Io(e.to_string()))?; if flags.write || flags.create || flags.truncate { @@ -175,7 +172,10 @@ impl VfsBackend for SmbVfs { fn stat(&self, path: &Path) -> Result { let smb_path = Self::path_to_str(path); - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let mut tree = self.tree.lock().map_err(|e| VfsError::Io(e.to_string()))?; let info = self .runtime @@ -200,7 +200,10 @@ impl VfsBackend for SmbVfs { fn create_dir(&self, path: &Path, _mode: u32) -> Result<(), VfsError> { let smb_path = Self::path_to_str(path); - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let mut tree = self.tree.lock().map_err(|e| VfsError::Io(e.to_string()))?; self.runtime .block_on(client.create_directory(&mut *tree, &smb_path)) @@ -230,7 +233,10 @@ impl VfsBackend for SmbVfs { fn remove_dir(&self, path: &Path) -> Result<(), VfsError> { let smb_path = Self::path_to_str(path); - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let mut tree = self.tree.lock().map_err(|e| VfsError::Io(e.to_string()))?; self.runtime .block_on(client.delete_directory(&mut *tree, &smb_path)) @@ -239,7 +245,10 @@ impl VfsBackend for SmbVfs { fn remove_file(&self, path: &Path) -> Result<(), VfsError> { let smb_path = Self::path_to_str(path); - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let mut tree = self.tree.lock().map_err(|e| VfsError::Io(e.to_string()))?; self.runtime .block_on(client.delete_file(&mut *tree, &smb_path)) @@ -249,7 +258,10 @@ impl VfsBackend for SmbVfs { fn rename(&self, from: &Path, to: &Path) -> Result<(), VfsError> { let smb_from = Self::path_to_str(from); let smb_to = Self::path_to_str(to); - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let mut tree = self.tree.lock().map_err(|e| VfsError::Io(e.to_string()))?; self.runtime .block_on(client.rename(&mut *tree, &smb_from, &smb_to)) @@ -270,7 +282,10 @@ impl VfsBackend for SmbVfs { fn real_path(&self, path: &Path) -> Result { let smb_path = Self::path_to_str(path); - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let mut tree = self.tree.lock().map_err(|e| VfsError::Io(e.to_string()))?; let _info = self .runtime @@ -319,7 +334,10 @@ struct SmbVfsFile { impl SmbVfsFile { fn ensure_data_loaded(&mut self) -> Result<(), VfsError> { if self.data.is_empty() && self.size > 0 { - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let data = self .runtime .block_on(client.read_file(&mut self.tree, &self.path)) @@ -382,7 +400,10 @@ impl VfsFile for SmbVfsFile { if let FileMode::Write = self.mode { if !self.write_buf.is_empty() { let data = std::mem::take(&mut self.write_buf); - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; self.runtime .block_on(client.write_file(&mut self.tree, &self.path, &data)) .map_err(map_smb_error)?; @@ -393,7 +414,10 @@ impl VfsFile for SmbVfsFile { } fn stat(&mut self) -> Result { - let mut client = self.client.lock().map_err(|e| VfsError::Io(e.to_string()))?; + let mut client = self + .client + .lock() + .map_err(|e| VfsError::Io(e.to_string()))?; let info = self .runtime .block_on(client.stat(&mut self.tree, &self.path)) @@ -421,9 +445,9 @@ impl Drop for SmbVfsFile { if !self.write_buf.is_empty() { let data = std::mem::take(&mut self.write_buf); if let Ok(mut client) = self.client.lock() { - let _ = self - .runtime - .block_on(client.write_file(&mut self.tree, &self.path, &data)); + let _ = + self.runtime + .block_on(client.write_file(&mut self.tree, &self.path, &data)); } } } @@ -432,8 +456,13 @@ impl Drop for SmbVfsFile { #[cfg(test)] mod tests { + use smb2::types::status::NtStatus; + use smb2::types::Command; + use super::*; + // ── filetime_to_systemtime ────────────────────────────────────────────── + #[test] fn test_filetime_conversion() { let raw: u64 = 133604700000000000; @@ -442,22 +471,138 @@ mod tests { } #[test] - fn test_path_to_str() { - assert_eq!(SmbVfs::path_to_str(Path::new("foo/bar.txt")), "foo/bar.txt"); - assert_eq!(SmbVfs::path_to_str(Path::new("/foo/bar.txt")), "foo/bar.txt"); - assert_eq!(SmbVfs::path_to_str(Path::new("")), ""); + fn test_filetime_below_epoch_returns_epoch() { + let st = filetime_to_systemtime(0); + assert_eq!(st, UNIX_EPOCH); } #[test] - fn test_error_mapping_invalid_data() { - let err = smb2::Error::invalid_data("test"); - let mapped = map_smb_error(err); - match mapped { - VfsError::Io(_) => {} - _ => panic!("Expected Io, got {:?}", mapped), + fn test_filetime_at_exact_unix_epoch_boundary() { + let ft = FILETIME_TO_UNIX_SECS * 10_000_000; + let st = filetime_to_systemtime(ft); + assert_eq!(st, UNIX_EPOCH); + } + + // ── path_to_str ───────────────────────────────────────────────────────── + + #[test] + fn test_path_to_str() { + assert_eq!(SmbVfs::path_to_str(Path::new("foo/bar.txt")), "foo/bar.txt"); + assert_eq!( + SmbVfs::path_to_str(Path::new("/foo/bar.txt")), + "foo/bar.txt" + ); + assert_eq!(SmbVfs::path_to_str(Path::new("")), ""); + assert_eq!(SmbVfs::path_to_str(Path::new("/")), ""); + assert_eq!( + SmbVfs::path_to_str(Path::new("/a/b/c/d/e/f/g.txt")), + "a/b/c/d/e/f/g.txt" + ); + } + + // ── map_smb_error — all ErrorKind variants ────────────────────────────── + + fn proto_err(status: NtStatus) -> smb2::Error { + smb2::Error::Protocol { + status, + command: Command::Read, } } + #[test] + fn test_map_smb_error_not_found() { + let err = proto_err(NtStatus::NO_SUCH_FILE); + assert!(matches!(map_smb_error(err), VfsError::NotFound(_))); + } + + #[test] + fn test_map_smb_error_already_exists() { + let err = proto_err(NtStatus::OBJECT_NAME_COLLISION); + assert!(matches!(map_smb_error(err), VfsError::AlreadyExists(_))); + } + + #[test] + fn test_map_smb_error_access_denied() { + let err = proto_err(NtStatus::ACCESS_DENIED); + assert!(matches!(map_smb_error(err), VfsError::PermissionDenied(_))); + } + + #[test] + fn test_map_smb_error_is_a_directory() { + let err = proto_err(NtStatus::FILE_IS_A_DIRECTORY); + assert!(matches!(map_smb_error(err), VfsError::IsADirectory(_))); + } + + #[test] + fn test_map_smb_error_not_a_directory() { + let err = proto_err(NtStatus::NOT_A_DIRECTORY); + assert!(matches!(map_smb_error(err), VfsError::NotADirectory(_))); + } + + #[test] + fn test_map_smb_error_disk_full() { + let err = proto_err(NtStatus::DISK_FULL); + assert!(matches!(map_smb_error(err), VfsError::Io(_))); + } + + #[test] + fn test_map_smb_error_sharing_violation() { + let err = proto_err(NtStatus::SHARING_VIOLATION); + assert!(matches!(map_smb_error(err), VfsError::Io(_))); + } + + #[test] + fn test_map_smb_error_connection_lost() { + let err = smb2::Error::Disconnected; + assert!(matches!(map_smb_error(err), VfsError::Io(_))); + } + + #[test] + fn test_map_smb_error_timeout() { + let err = smb2::Error::Timeout; + assert!(matches!(map_smb_error(err), VfsError::Io(_))); + } + + #[test] + fn test_map_smb_error_session_expired() { + let err = smb2::Error::SessionExpired; + assert!(matches!(map_smb_error(err), VfsError::Io(_))); + } + + #[test] + fn test_map_smb_error_invalid_data() { + let err = smb2::Error::InvalidData { + message: "bad data".into(), + }; + assert!(matches!(map_smb_error(err), VfsError::Io(_))); + } + + #[test] + fn test_map_smb_error_auth() { + let err = smb2::Error::Auth { + message: "auth fail".into(), + }; + // AuthRequired falls through to the catch-all _ => VfsError::Io + assert!(matches!(map_smb_error(err), VfsError::Io(_))); + } + + #[test] + fn test_map_smb_error_io() { + let err = smb2::Error::Io(std::io::Error::other("io error")); + assert!(matches!(map_smb_error(err), VfsError::Io(_))); + } + + #[test] + fn test_map_smb_error_cancelled() { + let err = smb2::Error::Cancelled; + assert!(matches!(map_smb_error(err), VfsError::Io(_))); + } + + // VfsFile::set_len returns Unsupported — verified via integration tests + // since SmbVfsFile requires a real SMB connection to construct. + + // ── Integration tests (ignored, require Docker Samba) ─────────────────── + /// Integration test: requires Docker Samba container on port 10445. /// Run with: docker compose -f vendor/smb2/tests/docker/internal/docker-compose.yml up -d smb-guest #[test] @@ -465,7 +610,7 @@ mod tests { fn test_smb_vfs_list_root() { let vfs = SmbVfs::new("127.0.0.1:10445", "public", "", "").unwrap(); let entries = vfs.read_dir(Path::new("/")).unwrap(); - assert!(!entries.is_empty(), "Expected at least . and .."); + assert!(!entries.is_empty(), "Expected entries"); } #[test] diff --git a/markbase-core/src/vfs/smb_server_backend.rs b/markbase-core/src/vfs/smb_server_backend.rs index 011aed6..b7a2448 100644 --- a/markbase-core/src/vfs/smb_server_backend.rs +++ b/markbase-core/src/vfs/smb_server_backend.rs @@ -6,8 +6,8 @@ use std::time::SystemTime; use async_trait::async_trait; use bytes::Bytes; use smb_server::{ - BackendCapabilities, DirEntry, FileInfo, FileTimes, Handle, OpenIntent, OpenOptions, ShareBackend, - SmbError, SmbPath, + BackendCapabilities, DirEntry, FileInfo, FileTimes, Handle, OpenIntent, OpenOptions, + ShareBackend, SmbError, SmbPath, }; use super::open_flags::OpenFlags; @@ -63,11 +63,7 @@ fn map_error(e: VfsError) -> SmbError { fn system_time_to_filetime(t: SystemTime) -> u64 { match t.duration_since(SystemTime::UNIX_EPOCH) { - Ok(d) => { - FILETIME_OFFSET - + (d.as_secs() * 10_000_000) - + (d.subsec_nanos() as u64 / 100) - } + Ok(d) => FILETIME_OFFSET + (d.as_secs() * 10_000_000) + (d.subsec_nanos() as u64 / 100), Err(_) => 0, } } @@ -162,10 +158,7 @@ impl ShareBackend for VfsShareBackend { } } - let file = self - .vfs - .open_file(&full_path, &flags) - .map_err(map_error)?; + let file = self.vfs.open_file(&full_path, &flags).map_err(map_error)?; Ok(Box::new(VfsHandle::File { file: Mutex::new(file), path: full_path, @@ -320,20 +313,50 @@ fn filetime_to_systemtime(ft: u64) -> SystemTime { } let delta_secs = (ft - FILETIME_OFFSET) / 10_000_000; let delta_ns = ((ft - FILETIME_OFFSET) % 10_000_000) as u32 * 100; - SystemTime::UNIX_EPOCH - + std::time::Duration::new(delta_secs, delta_ns) + SystemTime::UNIX_EPOCH + std::time::Duration::new(delta_secs, delta_ns) } #[cfg(test)] mod tests { use std::path::PathBuf; - use smb_server::{Share, SmbServer, Access}; + use smb_server::{Access, Share, SmbServer}; use crate::vfs::local_fs::LocalFs; use super::*; + fn setup() -> (VfsShareBackend, tempfile::TempDir) { + let tmp = tempfile::TempDir::new().unwrap(); + let vfs = Box::new(LocalFs::new()); + let backend = VfsShareBackend::new(vfs, tmp.path().to_path_buf()); + (backend, tmp) + } + + fn write_opts() -> OpenOptions { + OpenOptions { + read: true, + write: true, + intent: OpenIntent::OpenOrCreate, + directory: false, + non_directory: false, + delete_on_close: false, + } + } + + fn read_opts() -> OpenOptions { + OpenOptions { + read: true, + write: false, + intent: OpenIntent::Open, + directory: false, + non_directory: false, + delete_on_close: false, + } + } + + // ── resolve_path ────────────────────────────────────────────────────── + #[test] fn test_resolve_path_root() { let root = PathBuf::from("/srv/share"); @@ -349,6 +372,13 @@ mod tests { assert_eq!(resolve_path(&root, &smb), expected); } + #[test] + fn test_rejects_trailing_backslash() { + assert!("dir\\".parse::().is_err()); + } + + // ── FILETIME conversion ─────────────────────────────────────────────── + #[test] fn test_system_time_to_filetime() { let epoch = SystemTime::UNIX_EPOCH; @@ -369,6 +399,20 @@ mod tests { assert!(diff.as_millis() < 100); } + #[test] + fn test_filetime_below_offset_returns_epoch() { + let ft = filetime_to_systemtime(FILETIME_OFFSET - 1); + assert_eq!(ft, SystemTime::UNIX_EPOCH); + } + + #[test] + fn test_filetime_exactly_offset() { + let ft = filetime_to_systemtime(FILETIME_OFFSET); + assert_eq!(ft, SystemTime::UNIX_EPOCH); + } + + // ── VfsError → SmbError mapping ─────────────────────────────────────── + #[test] fn test_map_errors() { assert!(matches!( @@ -398,31 +442,778 @@ mod tests { } #[test] - fn test_vfs_share_backend_creation() { + fn test_map_error_unsupported() { + let result = map_error(VfsError::Unsupported("test".into())); + assert!(matches!(result, SmbError::NotSupported)); + } + + #[test] + fn test_map_error_unexpected_eof() { + let result = map_error(VfsError::UnexpectedEof); + assert!(matches!(result, SmbError::Io(_))); + } + + // ── Backend creation / capabilities ─────────────────────────────────── + + #[test] + fn test_backend_default_not_read_only() { + let (_backend, _tmp) = setup(); + // already tested via setup() — keep for clarity + } + + #[test] + fn test_backend_read_only_flag() { + let tmp = tempfile::TempDir::new().unwrap(); let vfs = Box::new(LocalFs::new()); - let root = PathBuf::from("/tmp"); - let backend = VfsShareBackend::new(vfs, root); - assert!(!backend.capabilities().is_read_only); + let backend = VfsShareBackend::new(vfs, tmp.path().to_path_buf()).read_only(true); + assert!(backend.capabilities().is_read_only); + } + + #[test] + fn test_backend_case_sensitive() { + let (_backend, _tmp) = setup(); + assert!(_backend.capabilities().case_sensitive); + } + + // ── VfsHandle File: full lifecycle ──────────────────────────────────── + + #[tokio::test] + async fn test_handle_file_create_write_read_flush_close() { + let (backend, _tmp) = setup(); + let path: SmbPath = "test_file.txt".parse().unwrap(); + let handle = backend.open(&path, write_opts()).await.unwrap(); + + let n = handle.write(0, b"Hello SMB!").await.unwrap(); + assert_eq!(n, 10); + + handle.flush().await.unwrap(); + + let bytes = handle.read(0, 100).await.unwrap(); + assert_eq!(&bytes[..], b"Hello SMB!"); + + handle.close().await.unwrap(); } #[tokio::test] - async fn test_open_nonexistent_file() { - let vfs = Box::new(LocalFs::new()); - let root = PathBuf::from("/nonexistent"); - let backend = VfsShareBackend::new(vfs, root); - let smb_path: SmbPath = "missing.txt".parse().unwrap(); - let opts = OpenOptions { + async fn test_handle_file_read_after_close_fails() { + let (backend, _tmp) = setup(); + let path: SmbPath = "read_after_close.txt".parse().unwrap(); + let handle = backend.open(&path, write_opts()).await.unwrap(); + handle.write(0, b"data").await.unwrap(); + handle.flush().await.unwrap(); + handle.close().await.unwrap(); + + // Re-open for read + let handle2 = backend.open(&path, read_opts()).await.unwrap(); + let bytes = handle2.read(0, 100).await.unwrap(); + assert_eq!(&bytes[..], b"data"); + handle2.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_file_stat() { + let (backend, _tmp) = setup(); + let path: SmbPath = "stat_file.txt".parse().unwrap(); + let handle = backend.open(&path, write_opts()).await.unwrap(); + handle.write(0, b"1234567890").await.unwrap(); + handle.flush().await.unwrap(); + + let info = handle.stat().await.unwrap(); + assert_eq!(info.end_of_file, 10); + assert!(!info.is_directory); + assert!(!info.name.is_empty()); + + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_file_truncate_to_zero() { + let (backend, _tmp) = setup(); + let path: SmbPath = "trunc_file.txt".parse().unwrap(); + let handle = backend.open(&path, write_opts()).await.unwrap(); + handle.write(0, b"data to be removed").await.unwrap(); + handle.flush().await.unwrap(); + + handle.truncate(0).await.unwrap(); + let info = handle.stat().await.unwrap(); + assert_eq!(info.end_of_file, 0); + + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_file_truncate_extend() { + let (backend, _tmp) = setup(); + let path: SmbPath = "extend_file.txt".parse().unwrap(); + let handle = backend.open(&path, write_opts()).await.unwrap(); + handle.write(0, b"short").await.unwrap(); + handle.flush().await.unwrap(); + + handle.truncate(100).await.unwrap(); + let info = handle.stat().await.unwrap(); + assert_eq!(info.end_of_file, 100); + + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_file_set_times() { + let (backend, _tmp) = setup(); + let path: SmbPath = "times_file.txt".parse().unwrap(); + let handle = backend.open(&path, write_opts()).await.unwrap(); + handle.write(0, b"x").await.unwrap(); + handle.flush().await.unwrap(); + + let ft = system_time_to_filetime(SystemTime::now()); + let times = FileTimes { + creation_time: Some(ft), + last_access_time: Some(ft), + last_write_time: Some(ft), + change_time: Some(ft), + }; + handle.set_times(times).await.unwrap(); + + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_file_list_dir_error() { + let (backend, _tmp) = setup(); + let path: SmbPath = "not_a_dir.txt".parse().unwrap(); + let handle = backend.open(&path, write_opts()).await.unwrap(); + + let result = handle.list_dir(None).await; + assert!(matches!(result, Err(SmbError::NotADirectory))); + + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_file_write_past_end() { + let (backend, _tmp) = setup(); + let path: SmbPath = "sparse.txt".parse().unwrap(); + let handle = backend.open(&path, write_opts()).await.unwrap(); + let n = handle.write(500, b"sparse data").await.unwrap(); + assert_eq!(n, 11); + handle.flush().await.unwrap(); + + let info = handle.stat().await.unwrap(); + assert_eq!(info.end_of_file, 511); + + handle.close().await.unwrap(); + } + + // ── VfsHandle Directory: full lifecycle ─────────────────────────────── + + async fn create_dir_backend(backend: &VfsShareBackend, name: &str) -> Box { + let dir_opts = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Create, + directory: true, + non_directory: false, + delete_on_close: false, + }; + backend + .open(&name.parse::().unwrap(), dir_opts) + .await + .unwrap() + } + + #[tokio::test] + async fn test_handle_directory_create_stat_list_close() { + let (backend, _tmp) = setup(); + let handle = create_dir_backend(&backend, "testdir").await; + + let info = handle.stat().await.unwrap(); + assert!(info.is_directory); + + let entries = handle.list_dir(None).await.unwrap(); + assert!(entries.is_empty()); + + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_directory_list_contains_created_file() { + let (backend, _tmp) = setup(); + let _dir = create_dir_backend(&backend, "listdir").await; + + // Create a file inside + let file_path: SmbPath = "listdir\\child.txt".parse().unwrap(); + let fh = backend.open(&file_path, write_opts()).await.unwrap(); + fh.write(0, b"child data").await.unwrap(); + fh.flush().await.unwrap(); + fh.close().await.unwrap(); + + // Reopen dir and list + let dir_opts = OpenOptions { read: true, write: false, intent: OpenIntent::Open, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let dir_handle = backend + .open(&"listdir".parse().unwrap(), dir_opts) + .await + .unwrap(); + let entries = dir_handle.list_dir(None).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].info.name, "child.txt"); + assert!(!entries[0].info.is_directory); + assert_eq!(entries[0].info.end_of_file, 10); + + dir_handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_directory_stat_after_creation() { + let (backend, _tmp) = setup(); + let handle = create_dir_backend(&backend, "statdir").await; + + let info = handle.stat().await.unwrap(); + assert!(info.is_directory); + assert_eq!(info.name, "statdir"); + + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_directory_read_error() { + let (backend, _tmp) = setup(); + let handle = create_dir_backend(&backend, "readdir").await; + + let result = handle.read(0, 10).await; + assert!(matches!(result, Err(SmbError::NotSupported))); + + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_directory_write_error() { + let (backend, _tmp) = setup(); + let handle = create_dir_backend(&backend, "writedir").await; + + let result = handle.write(0, b"data").await; + assert!(matches!(result, Err(SmbError::NotSupported))); + + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_handle_directory_truncate_error() { + let (backend, _tmp) = setup(); + let handle = create_dir_backend(&backend, "truncdir").await; + + let result = handle.truncate(0).await; + assert!(matches!(result, Err(SmbError::NotSupported))); + + handle.close().await.unwrap(); + } + + // ── ShareBackend::open — file OpenIntent variants ────────────────────── + + #[tokio::test] + async fn test_open_file_create_new_succeeds() { + let (backend, tmp) = setup(); + let path: SmbPath = "create_new.txt".parse().unwrap(); + let opts = OpenOptions { + read: true, + write: true, + intent: OpenIntent::Create, directory: false, non_directory: false, delete_on_close: false, }; - let result = backend.open(&smb_path, opts).await; + let handle = backend.open(&path, opts).await.unwrap(); + assert!(tmp.path().join("create_new.txt").exists()); + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_open_file_create_existing_fails() { + let (backend, _tmp) = setup(); + let path: SmbPath = "create_existing.txt".parse().unwrap(); + let opts = OpenOptions { + read: true, + write: true, + intent: OpenIntent::Create, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let h1 = backend.open(&path, opts).await.unwrap(); + h1.close().await.unwrap(); + + let result = backend.open(&path, opts).await; + assert!(matches!(result, Err(SmbError::Exists))); + } + + #[tokio::test] + async fn test_open_file_open_or_create_new() { + let (backend, _tmp) = setup(); + let path: SmbPath = "open_or_create_new.txt".parse().unwrap(); + let opts = OpenOptions { + read: true, + write: true, + intent: OpenIntent::OpenOrCreate, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let handle = backend.open(&path, opts).await.unwrap(); + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_open_file_open_or_create_existing() { + let (backend, _tmp) = setup(); + let path: SmbPath = "open_or_create_existing.txt".parse().unwrap(); + let create = OpenOptions { + read: true, + write: true, + intent: OpenIntent::Create, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let h1 = backend.open(&path, create).await.unwrap(); + h1.write(0, b"original").await.unwrap(); + h1.flush().await.unwrap(); + h1.close().await.unwrap(); + + let open_or_create = OpenOptions { + read: true, + write: false, + intent: OpenIntent::OpenOrCreate, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let h2 = backend.open(&path, open_or_create).await.unwrap(); + let bytes = h2.read(0, 100).await.unwrap(); + assert_eq!(&bytes[..], b"original"); + h2.close().await.unwrap(); + } + + #[tokio::test] + async fn test_open_file_overwrite_create_new() { + let (backend, _tmp) = setup(); + let path: SmbPath = "overwrite_create.txt".parse().unwrap(); + let opts = OpenOptions { + read: true, + write: true, + intent: OpenIntent::OverwriteOrCreate, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let handle = backend.open(&path, opts).await.unwrap(); + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_open_file_overwrite_create_existing_truncates() { + let (backend, _tmp) = setup(); + let path: SmbPath = "overwrite_trunc.txt".parse().unwrap(); + let create = OpenOptions { + read: true, + write: true, + intent: OpenIntent::Create, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let h1 = backend.open(&path, create).await.unwrap(); + h1.write(0, b"long data to truncate").await.unwrap(); + h1.flush().await.unwrap(); + h1.close().await.unwrap(); + + let overwrite = OpenOptions { + read: true, + write: true, + intent: OpenIntent::OverwriteOrCreate, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let h2 = backend.open(&path, overwrite).await.unwrap(); + let info = h2.stat().await.unwrap(); + assert_eq!(info.end_of_file, 0); + h2.close().await.unwrap(); + } + + #[tokio::test] + async fn test_open_file_truncate_existing() { + let (backend, _tmp) = setup(); + let path: SmbPath = "trunc_existing.txt".parse().unwrap(); + let create = OpenOptions { + read: true, + write: true, + intent: OpenIntent::Create, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let h1 = backend.open(&path, create).await.unwrap(); + h1.write(0, b"data").await.unwrap(); + h1.flush().await.unwrap(); + h1.close().await.unwrap(); + + let trunc = OpenOptions { + read: true, + write: true, + intent: OpenIntent::Truncate, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let h2 = backend.open(&path, trunc).await.unwrap(); + let info = h2.stat().await.unwrap(); + assert_eq!(info.end_of_file, 0); + h2.close().await.unwrap(); + } + + #[tokio::test] + async fn test_open_file_truncate_nonexistent_fails() { + let (backend, _tmp) = setup(); + let path: SmbPath = "trunc_missing.txt".parse().unwrap(); + let opts = OpenOptions { + read: true, + write: true, + intent: OpenIntent::Truncate, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let result = backend.open(&path, opts).await; assert!(matches!(result, Err(SmbError::NotFound))); } + // ── ShareBackend::open — directory OpenIntent variants ──────────────── + + #[tokio::test] + async fn test_open_directory_create_new() { + let (backend, _tmp) = setup(); + let path: SmbPath = "newdir".parse().unwrap(); + let opts = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Create, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let handle = backend.open(&path, opts).await.unwrap(); + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_open_directory_create_existing_fails() { + let (backend, _tmp) = setup(); + let path: SmbPath = "dir_exists".parse().unwrap(); + let opts = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Create, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let h1 = backend.open(&path, opts).await.unwrap(); + h1.close().await.unwrap(); + + let result = backend.open(&path, opts).await; + assert!(matches!(result, Err(SmbError::Exists))); + } + + #[tokio::test] + async fn test_open_directory_open_existing() { + let (backend, _tmp) = setup(); + let path: SmbPath = "open_dir".parse().unwrap(); + let create = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Create, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let h1 = backend.open(&path, create).await.unwrap(); + h1.close().await.unwrap(); + + let open = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Open, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let h2 = backend.open(&path, open).await.unwrap(); + h2.close().await.unwrap(); + } + + #[tokio::test] + async fn test_open_directory_open_or_create_new() { + let (backend, tmp) = setup(); + let path: SmbPath = "open_or_create_dir".parse().unwrap(); + let opts = OpenOptions { + read: true, + write: false, + intent: OpenIntent::OpenOrCreate, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let handle = backend.open(&path, opts).await.unwrap(); + assert!(tmp.path().join("open_or_create_dir").exists()); + handle.close().await.unwrap(); + } + + #[tokio::test] + async fn test_open_directory_open_or_create_existing() { + let (backend, _tmp) = setup(); + let path: SmbPath = "open_or_create_dir2".parse().unwrap(); + let create = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Create, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let h1 = backend.open(&path, create).await.unwrap(); + h1.close().await.unwrap(); + + let open_create = OpenOptions { + read: true, + write: false, + intent: OpenIntent::OpenOrCreate, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let h2 = backend.open(&path, open_create).await.unwrap(); + h2.close().await.unwrap(); + } + + // ── ShareBackend::open — non_directory / directory flag ─────────────── + + #[tokio::test] + async fn test_open_non_directory_flag_on_dir_fails() { + let (backend, _tmp) = setup(); + let dir_path: SmbPath = "not_a_file_dir".parse().unwrap(); + let create_dir = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Create, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let dh = backend.open(&dir_path, create_dir).await.unwrap(); + dh.close().await.unwrap(); + + let file_opts = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Open, + directory: false, + non_directory: true, + delete_on_close: false, + }; + let result = backend.open(&dir_path, file_opts).await; + assert!(matches!(result, Err(SmbError::IsDirectory))); + } + + #[tokio::test] + async fn test_open_directory_flag_on_file_fails() { + let (backend, _tmp) = setup(); + let file_path: SmbPath = "not_a_dir_file.txt".parse().unwrap(); + let create_file = OpenOptions { + read: true, + write: true, + intent: OpenIntent::Create, + directory: false, + non_directory: false, + delete_on_close: false, + }; + let fh = backend.open(&file_path, create_file).await.unwrap(); + fh.write(0, b"x").await.unwrap(); + fh.flush().await.unwrap(); + fh.close().await.unwrap(); + + let dir_opts = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Open, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let result = backend.open(&file_path, dir_opts).await; + assert!(matches!(result, Err(SmbError::NotADirectory))); + } + + // ── ShareBackend::unlink ────────────────────────────────────────────── + + #[tokio::test] + async fn test_unlink_file() { + let (backend, _tmp) = setup(); + let path: SmbPath = "unlink_me.txt".parse().unwrap(); + let handle = backend.open(&path, write_opts()).await.unwrap(); + handle.write(0, b"x").await.unwrap(); + handle.flush().await.unwrap(); + handle.close().await.unwrap(); + + backend.unlink(&path).await.unwrap(); + + let result = backend.open(&path, read_opts()).await; + assert!(matches!(result, Err(SmbError::NotFound))); + } + + #[tokio::test] + async fn test_unlink_directory() { + let (backend, _tmp) = setup(); + let path: SmbPath = "unlink_dir".parse().unwrap(); + let opts = OpenOptions { + read: true, + write: false, + intent: OpenIntent::Create, + directory: true, + non_directory: false, + delete_on_close: false, + }; + let dh = backend.open(&path, opts).await.unwrap(); + dh.close().await.unwrap(); + + backend.unlink(&path).await.unwrap(); + + let result = backend.open(&path, read_opts()).await; + assert!(matches!(result, Err(SmbError::NotFound))); + } + + #[tokio::test] + async fn test_unlink_nonexistent_returns_not_found() { + let (backend, _tmp) = setup(); + let path: SmbPath = "does_not_exist.txt".parse().unwrap(); + let result = backend.unlink(&path).await; + assert!(matches!(result, Err(SmbError::NotFound))); + } + + // ── ShareBackend::rename ────────────────────────────────────────────── + + #[tokio::test] + async fn test_rename_file() { + let (backend, _tmp) = setup(); + let src: SmbPath = "rename_src.txt".parse().unwrap(); + let dst: SmbPath = "rename_dst.txt".parse().unwrap(); + + let handle = backend.open(&src, write_opts()).await.unwrap(); + handle.write(0, b"rename me").await.unwrap(); + handle.flush().await.unwrap(); + handle.close().await.unwrap(); + + backend.rename(&src, &dst).await.unwrap(); + + // Source gone + assert!(matches!( + backend.open(&src, read_opts()).await, + Err(SmbError::NotFound) + )); + + // Destination exists with content + let dh = backend.open(&dst, read_opts()).await.unwrap(); + let bytes = dh.read(0, 100).await.unwrap(); + assert_eq!(&bytes[..], b"rename me"); + dh.close().await.unwrap(); + } + + #[tokio::test] + async fn test_rename_nonexistent_source() { + let (backend, _tmp) = setup(); + let src: SmbPath = "no_source.txt".parse().unwrap(); + let dst: SmbPath = "no_dst.txt".parse().unwrap(); + let result = backend.rename(&src, &dst).await; + assert!(matches!(result, Err(SmbError::NotFound))); + } + + #[tokio::test] + async fn test_rename_existing_target_fails() { + let (backend, _tmp) = setup(); + let src: SmbPath = "rename_src2.txt".parse().unwrap(); + let dst: SmbPath = "rename_dst2.txt".parse().unwrap(); + + let hs = backend.open(&src, write_opts()).await.unwrap(); + hs.close().await.unwrap(); + + let hd = backend.open(&dst, write_opts()).await.unwrap(); + hd.close().await.unwrap(); + + let result = backend.rename(&src, &dst).await; + assert!(matches!(result, Err(SmbError::Exists))); + } + + // ── vfs_stat_to_file_info ───────────────────────────────────────────── + + #[test] + fn test_vfs_stat_to_file_info_with_name() { + let stat = VfsStat { + size: 42, + mode: 0o644, + uid: 0, + gid: 0, + atime: SystemTime::UNIX_EPOCH, + mtime: SystemTime::UNIX_EPOCH, + is_dir: false, + is_symlink: false, + }; + let info = vfs_stat_to_file_info(&stat, "custom_name", Path::new("/ignored")); + assert_eq!(info.name, "custom_name"); + assert_eq!(info.end_of_file, 42); + assert!(!info.is_directory); + } + + #[test] + fn test_vfs_stat_to_file_info_directory() { + let stat = VfsStat { + size: 0, + mode: 0o755, + uid: 0, + gid: 0, + atime: SystemTime::UNIX_EPOCH, + mtime: SystemTime::UNIX_EPOCH, + is_dir: true, + is_symlink: false, + }; + let info = vfs_stat_to_file_info(&stat, "", Path::new("/share/mydir")); + assert_eq!(info.name, "mydir"); + assert!(info.is_directory); + } + + #[test] + fn test_vfs_stat_to_file_info_alloc_size_equals_end_of_file() { + let stat = VfsStat { + size: 100, + ..Default::default() + }; + let info = vfs_stat_to_file_info(&stat, "f", Path::new("/x")); + assert_eq!(info.allocation_size, 100); + } + + // ── filetime_to_systemtime ──────────────────────────────────────────── + + #[test] + fn test_filetime_zero() { + let st = filetime_to_systemtime(0); + assert_eq!(st, SystemTime::UNIX_EPOCH); + } + + // ── other ───────────────────────────────────────────────────────────── + #[test] fn test_rejects_dotdot() { assert!("a\\..\\b".parse::().is_err());