- VfsFile trait: add read_at()/write_at() with seek+read default impl - LocalFs: override with real pread/pwrite (FileExt::read_at/write_at) — 1 syscall vs 2 - smb_server_backend: use read_at/write_at + tokio::sync::Mutex (non-blocking async) - read handler: build response directly, avoid Bytes→Vec<u8> copy + intermediate struct - oplock break: fast-path skip when ≤1 open entry (single-user scenario)
1268 lines
41 KiB
Rust
1268 lines
41 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
use tokio::sync::Mutex;
|
|
|
|
use async_trait::async_trait;
|
|
use bytes::Bytes;
|
|
use smb_server::{
|
|
BackendCapabilities, DirEntry, FileInfo, FileTimes, Handle, OpenIntent, OpenOptions,
|
|
ShareBackend, SmbError, SmbPath,
|
|
};
|
|
|
|
use super::open_flags::OpenFlags;
|
|
use super::{VfsBackend, VfsError, VfsStat};
|
|
|
|
const FILETIME_OFFSET: u64 = 116_444_736_000_000_000;
|
|
|
|
pub struct VfsShareBackend {
|
|
vfs: Arc<dyn VfsBackend>,
|
|
root: PathBuf,
|
|
read_only: bool,
|
|
}
|
|
|
|
impl VfsShareBackend {
|
|
pub fn new(vfs: Box<dyn VfsBackend>, root: PathBuf) -> Self {
|
|
Self {
|
|
vfs: Arc::from(vfs),
|
|
root,
|
|
read_only: false,
|
|
}
|
|
}
|
|
|
|
pub fn read_only(mut self, yes: bool) -> Self {
|
|
self.read_only = yes;
|
|
self
|
|
}
|
|
}
|
|
|
|
fn resolve_path(root: &Path, smb_path: &SmbPath) -> PathBuf {
|
|
if smb_path.is_root() {
|
|
return root.to_path_buf();
|
|
}
|
|
let mut result = root.to_path_buf();
|
|
for component in smb_path.components() {
|
|
result.push(component);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn map_error(e: VfsError) -> SmbError {
|
|
match e {
|
|
VfsError::NotFound(_) => SmbError::NotFound,
|
|
VfsError::PermissionDenied(_) => SmbError::AccessDenied,
|
|
VfsError::AlreadyExists(_) => SmbError::Exists,
|
|
VfsError::NotEmpty(_) => SmbError::NotEmpty,
|
|
VfsError::NotADirectory(_) => SmbError::NotADirectory,
|
|
VfsError::IsADirectory(_) => SmbError::IsDirectory,
|
|
VfsError::Unsupported(_) => SmbError::NotSupported,
|
|
VfsError::Io(msg) => SmbError::Io(std::io::Error::other(msg)),
|
|
VfsError::UnexpectedEof => SmbError::Io(std::io::Error::other("unexpected eof")),
|
|
}
|
|
}
|
|
|
|
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),
|
|
Err(_) => 0,
|
|
}
|
|
}
|
|
|
|
fn vfs_stat_to_file_info(stat: &VfsStat, name: &str, path: &Path) -> FileInfo {
|
|
let name = if name.is_empty() {
|
|
path.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("")
|
|
.to_string()
|
|
} else {
|
|
name.to_string()
|
|
};
|
|
FileInfo {
|
|
name,
|
|
end_of_file: stat.size,
|
|
allocation_size: stat.size,
|
|
creation_time: system_time_to_filetime(stat.mtime),
|
|
last_access_time: system_time_to_filetime(stat.atime),
|
|
last_write_time: system_time_to_filetime(stat.mtime),
|
|
change_time: system_time_to_filetime(stat.mtime),
|
|
is_directory: stat.is_dir,
|
|
file_index: 0,
|
|
}
|
|
}
|
|
|
|
fn vfs_error_to_io(e: VfsError) -> std::io::Error {
|
|
std::io::Error::other(e.to_string())
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ShareBackend for VfsShareBackend {
|
|
async fn open(&self, path: &SmbPath, opts: OpenOptions) -> Result<Box<dyn Handle>, SmbError> {
|
|
let full_path = resolve_path(&self.root, path);
|
|
|
|
if opts.directory {
|
|
match opts.intent {
|
|
OpenIntent::Create => {
|
|
if self.vfs.exists(&full_path) {
|
|
return Err(SmbError::Exists);
|
|
}
|
|
self.vfs.create_dir(&full_path, 0o755).map_err(map_error)?;
|
|
}
|
|
OpenIntent::OpenOrCreate | OpenIntent::OverwriteOrCreate => {
|
|
if !self.vfs.exists(&full_path) {
|
|
self.vfs.create_dir(&full_path, 0o755).map_err(map_error)?;
|
|
}
|
|
}
|
|
_ => {
|
|
if !self.vfs.exists(&full_path) {
|
|
return Err(SmbError::NotFound);
|
|
}
|
|
}
|
|
}
|
|
let stat = self.vfs.stat(&full_path).map_err(map_error)?;
|
|
if !stat.is_dir {
|
|
return Err(SmbError::NotADirectory);
|
|
}
|
|
return Ok(Box::new(VfsHandle::Directory {
|
|
vfs: self.vfs.clone(),
|
|
path: full_path,
|
|
}));
|
|
}
|
|
|
|
let mut flags = OpenFlags::new();
|
|
if opts.read {
|
|
flags = flags.read();
|
|
}
|
|
if opts.write {
|
|
flags = flags.write();
|
|
}
|
|
match opts.intent {
|
|
OpenIntent::Open => {}
|
|
OpenIntent::Create => {
|
|
flags = flags.create().exclusive();
|
|
}
|
|
OpenIntent::OpenOrCreate => {
|
|
flags = flags.create();
|
|
}
|
|
OpenIntent::OverwriteOrCreate => {
|
|
flags = flags.create().truncate();
|
|
}
|
|
OpenIntent::Truncate => {
|
|
flags = flags.truncate();
|
|
}
|
|
}
|
|
|
|
if opts.non_directory && self.vfs.exists(&full_path) {
|
|
let stat = self.vfs.stat(&full_path).map_err(map_error)?;
|
|
if stat.is_dir {
|
|
return Err(SmbError::IsDirectory);
|
|
}
|
|
}
|
|
|
|
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,
|
|
vfs: self.vfs.clone(),
|
|
}))
|
|
}
|
|
|
|
async fn unlink(&self, path: &SmbPath) -> Result<(), SmbError> {
|
|
let full_path = resolve_path(&self.root, path);
|
|
if self.vfs.exists(&full_path) {
|
|
let stat = self.vfs.stat(&full_path).map_err(map_error)?;
|
|
if stat.is_dir {
|
|
return self.vfs.remove_dir(&full_path).map_err(map_error);
|
|
}
|
|
}
|
|
self.vfs.remove_file(&full_path).map_err(map_error)
|
|
}
|
|
|
|
async fn rename(&self, from: &SmbPath, to: &SmbPath) -> Result<(), SmbError> {
|
|
let from_path = resolve_path(&self.root, from);
|
|
let to_path = resolve_path(&self.root, to);
|
|
if self.vfs.exists(&to_path) {
|
|
return Err(SmbError::Exists);
|
|
}
|
|
self.vfs.rename(&from_path, &to_path).map_err(map_error)
|
|
}
|
|
|
|
fn capabilities(&self) -> BackendCapabilities {
|
|
BackendCapabilities {
|
|
is_read_only: self.read_only,
|
|
case_sensitive: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
enum VfsHandle {
|
|
File {
|
|
file: Mutex<Box<dyn super::VfsFile + Send>>,
|
|
path: PathBuf,
|
|
vfs: Arc<dyn VfsBackend>,
|
|
},
|
|
Directory {
|
|
vfs: Arc<dyn VfsBackend>,
|
|
path: PathBuf,
|
|
},
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Handle for VfsHandle {
|
|
async fn read(&self, offset: u64, len: u32) -> Result<Bytes, SmbError> {
|
|
match self {
|
|
Self::File { file, .. } => {
|
|
let mut file = file.lock().await;
|
|
let mut buf = vec![0u8; len as usize];
|
|
let n = file.read_at(&mut buf, offset).map_err(map_error)?;
|
|
buf.truncate(n);
|
|
Ok(Bytes::from(buf))
|
|
}
|
|
Self::Directory { .. } => Err(SmbError::NotSupported),
|
|
}
|
|
}
|
|
|
|
async fn write(&self, offset: u64, data: &[u8]) -> Result<u32, SmbError> {
|
|
match self {
|
|
Self::File { file, .. } => {
|
|
let mut file = file.lock().await;
|
|
let n = file.write_at(data, offset).map_err(map_error)?;
|
|
Ok(n as u32)
|
|
}
|
|
Self::Directory { .. } => Err(SmbError::NotSupported),
|
|
}
|
|
}
|
|
|
|
async fn flush(&self) -> Result<(), SmbError> {
|
|
match self {
|
|
Self::File { file, .. } => {
|
|
let mut file = file.lock().await;
|
|
file.flush().map_err(map_error)
|
|
}
|
|
Self::Directory { .. } => Ok(()),
|
|
}
|
|
}
|
|
|
|
async fn stat(&self) -> Result<FileInfo, SmbError> {
|
|
match self {
|
|
Self::File { file, path, .. } => {
|
|
let mut f = file.lock().await;
|
|
let vfs_stat = f.stat().map_err(map_error)?;
|
|
Ok(vfs_stat_to_file_info(&vfs_stat, "", path))
|
|
}
|
|
Self::Directory { vfs, path } => {
|
|
let vfs_stat = vfs.stat(path).map_err(map_error)?;
|
|
Ok(vfs_stat_to_file_info(&vfs_stat, "", path))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn set_times(&self, times: FileTimes) -> Result<(), SmbError> {
|
|
let (vfs, path) = match self {
|
|
Self::File { path, vfs, .. } => (vfs, path),
|
|
Self::Directory { vfs, path } => (vfs, path),
|
|
};
|
|
let mut stat = VfsStat::new();
|
|
if let Some(t) = times.last_write_time {
|
|
stat.mtime = filetime_to_systemtime(t);
|
|
}
|
|
if let Some(t) = times.last_access_time {
|
|
stat.atime = filetime_to_systemtime(t);
|
|
}
|
|
vfs.set_stat(path, &stat).map_err(map_error)
|
|
}
|
|
|
|
async fn truncate(&self, len: u64) -> Result<(), SmbError> {
|
|
match self {
|
|
Self::File { file, .. } => {
|
|
let mut file = file.lock().await;
|
|
file.set_len(len).map_err(map_error)
|
|
}
|
|
Self::Directory { .. } => Err(SmbError::NotSupported),
|
|
}
|
|
}
|
|
|
|
async fn list_dir(&self, pattern: Option<&str>) -> Result<Vec<DirEntry>, SmbError> {
|
|
match self {
|
|
Self::File { .. } => Err(SmbError::NotADirectory),
|
|
Self::Directory { vfs, path } => {
|
|
let entries = vfs.read_dir(path).map_err(map_error)?;
|
|
let mut result: Vec<DirEntry> = entries
|
|
.into_iter()
|
|
.filter(|entry| {
|
|
let p = match pattern {
|
|
None => return true,
|
|
Some(p) => p,
|
|
};
|
|
if p == "*" || p == "*.*" || p.is_empty() {
|
|
return true;
|
|
}
|
|
smb_match(&entry.name, p)
|
|
})
|
|
.map(|entry| {
|
|
let info = vfs_stat_to_file_info(&entry.stat, &entry.name, path);
|
|
DirEntry { info }
|
|
})
|
|
.collect();
|
|
for (i, entry) in result.iter_mut().enumerate() {
|
|
entry.info.file_index = (i + 1) as u64;
|
|
}
|
|
Ok(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn close(self: Box<Self>) -> Result<(), SmbError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Simple SMB wildcard match: `*` matches any sequence, `?` matches one char.
|
|
fn smb_match(name: &str, pattern: &str) -> bool {
|
|
let name = name.as_bytes();
|
|
let pat = pattern.as_bytes();
|
|
let mut ni = 0;
|
|
let mut pi = 0;
|
|
let mut star_idx: Option<usize> = None;
|
|
let mut match_idx = 0;
|
|
while ni < name.len() {
|
|
if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == name[ni]) {
|
|
ni += 1;
|
|
pi += 1;
|
|
} else if pi < pat.len() && pat[pi] == b'*' {
|
|
star_idx = Some(pi);
|
|
match_idx = ni;
|
|
pi += 1;
|
|
} else if let Some(si) = star_idx {
|
|
pi = si + 1;
|
|
match_idx += 1;
|
|
ni = match_idx;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
while pi < pat.len() && pat[pi] == b'*' {
|
|
pi += 1;
|
|
}
|
|
pi == pat.len()
|
|
}
|
|
|
|
fn filetime_to_systemtime(ft: u64) -> SystemTime {
|
|
if ft < FILETIME_OFFSET {
|
|
return SystemTime::UNIX_EPOCH;
|
|
}
|
|
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)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::PathBuf;
|
|
|
|
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");
|
|
let smb = SmbPath::root();
|
|
assert_eq!(resolve_path(&root, &smb), root);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_path_components() {
|
|
let root = PathBuf::from("/srv/share");
|
|
let smb: SmbPath = "dir\\sub\\file.txt".parse().unwrap();
|
|
let expected = PathBuf::from("/srv/share/dir/sub/file.txt");
|
|
assert_eq!(resolve_path(&root, &smb), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_trailing_backslash() {
|
|
assert!("dir\\".parse::<SmbPath>().is_err());
|
|
}
|
|
|
|
// ── FILETIME conversion ───────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_system_time_to_filetime() {
|
|
let epoch = SystemTime::UNIX_EPOCH;
|
|
let ft = system_time_to_filetime(epoch);
|
|
assert_eq!(ft, FILETIME_OFFSET);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filetime_roundtrip() {
|
|
let now = SystemTime::now();
|
|
let ft = system_time_to_filetime(now);
|
|
let back = filetime_to_systemtime(ft);
|
|
let diff = if now > back {
|
|
now.duration_since(back).unwrap()
|
|
} else {
|
|
back.duration_since(now).unwrap()
|
|
};
|
|
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!(
|
|
map_error(VfsError::NotFound("x".into())),
|
|
SmbError::NotFound
|
|
));
|
|
assert!(matches!(
|
|
map_error(VfsError::AlreadyExists("x".into())),
|
|
SmbError::Exists
|
|
));
|
|
assert!(matches!(
|
|
map_error(VfsError::PermissionDenied("x".into())),
|
|
SmbError::AccessDenied
|
|
));
|
|
assert!(matches!(
|
|
map_error(VfsError::NotEmpty("x".into())),
|
|
SmbError::NotEmpty
|
|
));
|
|
assert!(matches!(
|
|
map_error(VfsError::NotADirectory("x".into())),
|
|
SmbError::NotADirectory
|
|
));
|
|
assert!(matches!(
|
|
map_error(VfsError::IsADirectory("x".into())),
|
|
SmbError::IsDirectory
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
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 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_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<dyn Handle> {
|
|
let dir_opts = OpenOptions {
|
|
read: true,
|
|
write: false,
|
|
intent: OpenIntent::Create,
|
|
directory: true,
|
|
non_directory: false,
|
|
delete_on_close: false,
|
|
};
|
|
backend
|
|
.open(&name.parse::<SmbPath>().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 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::<SmbPath>().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_rejects_forbidden_chars() {
|
|
for bad in ["a<b", "a>b", "a:b", "a\"b", "a|b", "a?b", "a*b"] {
|
|
assert!(bad.parse::<SmbPath>().is_err());
|
|
}
|
|
}
|
|
}
|