Files
markbase/markbase-core/src/webdav.rs
Warren 4003864d28
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled
Fix WebDAV: add Clone to WebdavCredentials
2026-06-22 07:26:54 +08:00

1659 lines
58 KiB
Rust

use crate::vfs::open_flags::OpenFlags;
use crate::vfs::{VfsBackend, VfsError, VfsFile, VfsStat};
use crate::ssh_server::upload_hook::UploadHook;
use crate::webdav_version::WebDavVersioning;
use bytes::{Buf, Bytes};
use dav_server::davpath::DavPath;
use dav_server::fs::{
DavDirEntry, DavFile, DavFileSystem, DavMetaData, DavProp, FsError, FsFuture, FsStream,
OpenOptions,
};
use dav_server::ls::DavLockSystem;
use http::StatusCode;
use dav_server::memls::MemLs;
use futures_util::stream;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;
use crate::webdav_locks::PersistedLs;
fn map_vfs_error(e: VfsError) -> FsError {
match e {
VfsError::NotFound(_) => FsError::NotFound,
VfsError::PermissionDenied(_) => FsError::Forbidden,
VfsError::Unsupported(_) => FsError::NotImplemented,
VfsError::AlreadyExists(_) => FsError::Exists,
VfsError::NotEmpty(_) => FsError::Forbidden,
VfsError::NotADirectory(_) => FsError::Forbidden,
VfsError::IsADirectory(_) => FsError::Forbidden,
VfsError::Io(_) | VfsError::UnexpectedEof => FsError::GeneralFailure,
}
}
/// Expected credentials for WebDAV Basic Auth validation
#[derive(Clone)]
#[derive(Clone)]
pub struct WebdavCredentials {
pub username: String,
pub password: Option<String>,
}
/// Serializable dead property for disk persistence
#[derive(Serialize, Deserialize)]
struct DeadPropEntry {
name: String,
prefix: Option<String>,
namespace: Option<String>,
xml: Option<Vec<u8>>,
}
impl From<&DavProp> for DeadPropEntry {
fn from(p: &DavProp) -> Self {
Self {
name: p.name.clone(),
prefix: p.prefix.clone(),
namespace: p.namespace.clone(),
xml: p.xml.clone(),
}
}
}
impl From<DeadPropEntry> for DavProp {
fn from(e: DeadPropEntry) -> Self {
Self {
name: e.name,
prefix: e.prefix,
namespace: e.namespace,
xml: e.xml,
}
}
}
pub struct VfsDavFs {
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
versioning: Option<Arc<WebDavVersioning>>,
props_data: Arc<RwLock<HashMap<String, Vec<DavProp>>>>,
props_path: PathBuf,
enable_acl: bool,
}
impl Clone for VfsDavFs {
fn clone(&self) -> Self {
Self {
vfs: self.vfs.clone_boxed(),
root: self.root.clone(),
upload_hook: self.upload_hook.clone(),
user_uuid: self.user_uuid.clone(),
versioning: self.versioning.clone(),
props_data: self.props_data.clone(),
props_path: self.props_path.clone(),
enable_acl: self.enable_acl,
}
}
}
impl VfsDavFs {
fn dead_props_path(root: &Path) -> PathBuf {
root.join(".webdav_props.json")
}
fn load_props(vfs: &dyn VfsBackend, root: &Path) -> HashMap<String, Vec<DavProp>> {
let path = Self::dead_props_path(root);
let flags = OpenFlags::new().read();
let json = match vfs.open_file(&path, &flags) {
Ok(mut file) => {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
loop {
match file.read(&mut chunk) {
Ok(0) => break,
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(_) => return HashMap::new(),
}
}
String::from_utf8_lossy(&buf).to_string()
}
Err(_) => return HashMap::new(),
};
let entries: HashMap<String, Vec<DeadPropEntry>> = match serde_json::from_str(&json) {
Ok(m) => m,
Err(_) => HashMap::new(),
};
entries
.into_iter()
.map(|(k, v)| (k, v.into_iter().map(DavProp::from).collect()))
.collect()
}
fn save_props(&self) {
let data = match self.props_data.read() {
Ok(guard) => guard,
Err(e) => {
log::warn!("props_data RwLock poisoned in save_props, recovering");
e.into_inner()
}
};
let entries: HashMap<String, Vec<DeadPropEntry>> = data
.iter()
.filter(|(_, v)| !v.is_empty())
.map(|(k, v)| (k.clone(), v.iter().map(DeadPropEntry::from).collect()))
.collect();
if let Ok(json) = serde_json::to_string(&entries) {
let path = self.root.join(".webdav_props.json");
let flags = OpenFlags::new().write().create().truncate().mode(0o644);
match self.vfs.open_file(&path, &flags) {
Ok(mut file) => {
if let Err(e) = file.write_all(json.as_bytes()) {
log::warn!("save_props write_all failed: {:?}", e);
}
if let Err(e) = file.flush() {
log::warn!("save_props flush failed: {:?}", e);
}
}
Err(e) => {
log::warn!("save_props open_file failed: {:?}", e);
}
}
}
}
pub fn new(
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
) -> Box<Self> {
let props_path = Self::dead_props_path(&root);
let props_data = Arc::new(RwLock::new(Self::load_props(vfs.as_ref(), &root)));
Box::new(Self {
vfs,
root,
upload_hook,
user_uuid,
versioning: None,
props_data,
props_path,
enable_acl: true,
})
}
pub fn with_versioning(
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
versioning: Arc<WebDavVersioning>,
) -> Box<Self> {
let props_path = Self::dead_props_path(&root);
let props_data = Arc::new(RwLock::new(Self::load_props(vfs.as_ref(), &root)));
Box::new(Self {
vfs,
root,
upload_hook,
user_uuid,
versioning: Some(versioning),
props_data,
props_path,
enable_acl: true,
})
}
pub fn set_enable_acl(&mut self, enable: bool) {
self.enable_acl = enable;
}
fn rel_key(&self, path: &DavPath) -> String {
let rel = path.as_pathbuf();
rel.to_string_lossy().to_string()
}
fn resolve_path(&self, path: &DavPath) -> Result<PathBuf, FsError> {
let relative = path.as_rel_ospath();
let full = self.root.join(relative);
// Path traversal protection: canonicalize root once
let root_canonical = self.root.canonicalize().map_err(|_| FsError::NotFound)?;
// If path exists, use canonicalized version
if let Ok(canonical) = full.canonicalize() {
if !canonical.starts_with(&root_canonical) {
return Err(FsError::NotFound);
}
return Ok(canonical);
}
// Path doesn't exist yet (e.g., new file/dir being created).
// Validate parent directory is within root and no .. traversal.
let parent = full.parent().ok_or(FsError::NotFound)?;
let parent_canonical = parent.canonicalize().map_err(|_| FsError::NotFound)?;
if !parent_canonical.starts_with(&root_canonical) {
return Err(FsError::NotFound);
}
// Sanity check: ensure relative path doesn't contain ".."
if relative.components().any(|c| c == std::path::Component::ParentDir) {
return Err(FsError::NotFound);
}
Ok(full)
}
}
#[derive(Debug, Clone)]
pub struct VfsDavMetaData {
len: u64,
is_dir: bool,
modified: SystemTime,
accessed: SystemTime,
mode: u32,
}
impl VfsDavMetaData {
pub fn new(len: u64, is_dir: bool) -> Self {
let now = SystemTime::now();
Self {
len,
is_dir,
modified: now,
accessed: now,
mode: 0o644,
}
}
pub fn from_stat(stat: &VfsStat) -> Self {
Self {
len: stat.size,
is_dir: stat.is_dir,
modified: stat.mtime,
accessed: stat.atime,
mode: stat.mode,
}
}
}
impl DavMetaData for VfsDavMetaData {
fn len(&self) -> u64 {
self.len
}
fn modified(&self) -> Result<SystemTime, FsError> {
Ok(self.modified)
}
fn is_dir(&self) -> bool {
self.is_dir
}
fn accessed(&self) -> Result<SystemTime, FsError> {
Ok(self.accessed)
}
fn executable(&self) -> Result<bool, FsError> {
Ok(!self.is_dir && (self.mode & 0o111 != 0))
}
}
#[derive(Debug, Clone)]
pub struct VfsDavDirEntry {
name: String,
meta: VfsDavMetaData,
}
impl VfsDavDirEntry {
pub fn new(name: String, meta: VfsDavMetaData) -> Self {
Self { name, meta }
}
}
impl DavDirEntry for VfsDavDirEntry {
fn name(&self) -> Vec<u8> {
self.name.as_bytes().to_vec()
}
fn metadata(&self) -> FsFuture<'_, Box<dyn DavMetaData>> {
Box::pin(std::future::ready(Ok(Box::new(self.meta.clone()) as Box<dyn DavMetaData>)))
}
}
pub struct VfsDavFile {
data: Vec<u8>,
position: u64,
vfs_file: Option<Mutex<Box<dyn VfsFile>>>,
vfs: Option<Box<dyn VfsBackend>>,
path: Option<PathBuf>,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
is_write: bool,
versioning: Option<Arc<WebDavVersioning>>,
flushed: bool,
read_cache: Vec<u8>,
read_cache_offset: u64,
}
impl std::fmt::Debug for VfsDavFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VfsDavFile")
.field("is_write", &self.is_write)
.field("path", &self.path)
.field("data_len", &self.data.len())
.field("position", &self.position)
.finish()
}
}
impl VfsDavFile {
pub fn new_read(vfs_file: Box<dyn VfsFile>) -> Self {
Self {
data: Vec::new(),
position: 0,
vfs_file: Some(std::sync::Mutex::new(vfs_file)),
vfs: None,
path: None,
upload_hook: None,
user_uuid: String::new(),
is_write: false,
versioning: None,
flushed: true,
read_cache: Vec::new(),
read_cache_offset: 0,
}
}
pub fn new_write(
path: PathBuf,
vfs: Box<dyn VfsBackend>,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
versioning: Option<Arc<WebDavVersioning>>,
) -> Self {
if let Some(parent) = path.parent() {
let _ = vfs.create_dir_all(parent, 0o755);
}
let flags = OpenFlags::new().write().create().truncate().mode(0o644);
let vfs_file = vfs.open_file(&path, &flags).ok()
.map(std::sync::Mutex::new);
Self {
data: Vec::new(),
position: 0,
vfs_file,
vfs: Some(vfs),
path: Some(path),
upload_hook,
user_uuid,
is_write: true,
versioning,
flushed: false,
read_cache: Vec::new(),
read_cache_offset: 0,
}
}
}
impl Drop for VfsDavFile {
fn drop(&mut self) {
if self.is_write && !self.flushed && !self.data.is_empty() {
if let Some(path) = &self.path {
log::error!(
"VfsDavFile dropped with {} bytes of unwritten data for {:?} - DATA LOSS!",
self.data.len(),
path
);
}
}
}
}
impl DavFile for VfsDavFile {
fn metadata(&'_ mut self) -> FsFuture<'_, Box<dyn DavMetaData>> {
if let Some(vfs_file_mutex) = &self.vfs_file {
if let Ok(mut vfs_file) = vfs_file_mutex.lock() {
if let Ok(stat) = vfs_file.stat() {
return Box::pin(std::future::ready(Ok(
Box::new(VfsDavMetaData::from_stat(&stat)) as Box<dyn DavMetaData>,
)));
}
}
}
let len = self.data.len() as u64;
Box::pin(std::future::ready(Ok(
Box::new(VfsDavMetaData::new(len, false)) as Box<dyn DavMetaData>,
)))
}
fn write_buf(&'_ mut self, mut buf: Box<dyn Buf + Send>) -> FsFuture<'_, ()> {
if let Some(vfs_file_mutex) = &self.vfs_file {
if let Ok(mut vfs_file) = vfs_file_mutex.lock() {
while buf.has_remaining() {
let chunk = buf.chunk();
let _ = vfs_file.write_all(chunk);
self.data.extend_from_slice(chunk);
buf.advance(chunk.len());
}
return Box::pin(std::future::ready(Ok(())));
}
}
while buf.has_remaining() {
let chunk = buf.chunk();
self.data.extend_from_slice(chunk);
buf.advance(chunk.len());
}
Box::pin(std::future::ready(Ok(())))
}
fn write_bytes(&'_ mut self, buf: Bytes) -> FsFuture<'_, ()> {
if let Some(vfs_file_mutex) = &self.vfs_file {
if let Ok(mut vfs_file) = vfs_file_mutex.lock() {
let _ = vfs_file.write_all(&buf);
}
}
self.data.extend_from_slice(&buf);
Box::pin(std::future::ready(Ok(())))
}
fn read_bytes(&'_ mut self, count: usize) -> FsFuture<'_, Bytes> {
const CHUNK_SIZE: usize = 64 * 1024; // 64KB read-ahead
if let Some(vfs_file_mutex) = &self.vfs_file {
// Check if requested data is in cache
let cache_start = self.read_cache_offset as usize;
let cache_end = cache_start + self.read_cache.len();
let req_start = self.position as usize;
let req_end = req_start + count;
if req_start >= cache_start && req_end <= cache_end {
// Data in cache - return directly
let offset_in_cache = req_start - cache_start;
let bytes = Bytes::copy_from_slice(
&self.read_cache[offset_in_cache..offset_in_cache + count.min(self.read_cache.len() - offset_in_cache)]
);
self.position += bytes.len() as u64;
return Box::pin(std::future::ready(Ok(bytes)));
}
// Need to read new chunk
if let Ok(mut vfs_file) = vfs_file_mutex.lock() {
// Seek to current position if needed
let current_pos = vfs_file.seek(std::io::SeekFrom::Current(0)).unwrap_or(self.position);
if current_pos != self.position
&& vfs_file.seek(std::io::SeekFrom::Start(self.position)).is_err() {
return Box::pin(std::future::ready(Err(FsError::GeneralFailure)));
}
// Read larger chunk
let read_size = std::cmp::max(count, CHUNK_SIZE);
self.read_cache.resize(read_size, 0);
self.read_cache_offset = self.position;
match vfs_file.read(&mut self.read_cache) {
Ok(0) => {
self.read_cache.clear();
return Box::pin(std::future::ready(Ok(Bytes::new())));
}
Ok(n) => {
self.read_cache.truncate(n);
// Return requested portion
let result_len = count.min(n);
let bytes = Bytes::copy_from_slice(&self.read_cache[..result_len]);
self.position += result_len as u64;
return Box::pin(std::future::ready(Ok(bytes)));
}
Err(_) => {}
}
}
Box::pin(std::future::ready(Err(FsError::NotImplemented)))
} else {
// Write mode - read from self.data buffer
let start = self.position as usize;
let end = std::cmp::min(start + count, self.data.len());
if start >= self.data.len() {
Box::pin(std::future::ready(Ok(Bytes::new())))
} else {
let bytes = Bytes::copy_from_slice(&self.data[start..end]);
self.position = end as u64;
Box::pin(std::future::ready(Ok(bytes)))
}
}
}
fn seek(&'_ mut self, pos: std::io::SeekFrom) -> FsFuture<'_, u64> {
if let Some(vfs_file_mutex) = &self.vfs_file {
if let Ok(mut vfs_file) = vfs_file_mutex.lock() {
if let Ok(new_pos) = vfs_file.seek(pos) {
self.position = new_pos;
self.read_cache.clear(); // Invalidate cache on seek
self.read_cache_offset = 0;
return Box::pin(std::future::ready(Ok(new_pos)));
}
}
Box::pin(std::future::ready(Err(FsError::NotImplemented)))
} else {
let new_pos = match pos {
std::io::SeekFrom::Start(offset) => offset,
std::io::SeekFrom::Current(offset) => {
(self.position as i64 + offset) as u64
}
std::io::SeekFrom::End(offset) => {
(self.data.len() as i64 + offset) as u64
}
};
self.position = new_pos;
Box::pin(std::future::ready(Ok(new_pos)))
}
}
fn flush(&'_ mut self) -> FsFuture<'_, ()> {
if !self.is_write {
return Box::pin(std::future::ready(Ok(())));
}
if self.flushed {
return Box::pin(std::future::ready(Ok(())));
}
// Quota check before write
if !self.data.is_empty() {
if let Some(vfs) = &self.vfs {
if let Some(path) = &self.path {
let usage = vfs.get_quota_usage(path);
let quota = vfs.get_quota(path);
if let (Ok(usage), Ok(quota)) = (usage, quota) {
if quota.space_limit > 0 {
let new_size = usage.space_used + self.data.len() as u64;
if new_size > quota.space_limit {
log::warn!(
"Quota exceeded: current={}, adding={}, limit={}",
usage.space_used,
self.data.len(),
quota.space_limit
);
return Box::pin(std::future::ready(Err(FsError::InsufficientStorage)));
}
}
}
}
}
}
// Phase 1: Flush to storage
if let Some(vfs_file_mutex) = &self.vfs_file {
match vfs_file_mutex.lock() {
Ok(mut vfs_file) => {
if let Err(e) = vfs_file.flush() {
log::error!("VfsDavFile::flush() VFS flush failed: {:?}", e);
return Box::pin(std::future::ready(Err(FsError::GeneralFailure)));
}
}
Err(e) => {
log::error!("VfsDavFile::flush() mutex poisoned: {:?}", e);
return Box::pin(std::future::ready(Err(FsError::GeneralFailure)));
}
}
} else if !self.data.is_empty() {
if let Some(path) = &self.path {
if let Some(vfs) = &mut self.vfs {
let flags = OpenFlags::new().write().create().truncate().mode(0o644);
match vfs.open_file(path, &flags) {
Ok(mut vfs_file) => {
if let Err(e) = vfs_file.write_all(&self.data) {
log::error!("VfsDavFile::flush() write_all failed for {:?}: {:?}", path, e);
return Box::pin(std::future::ready(Err(FsError::GeneralFailure)));
}
if let Err(e) = vfs_file.flush() {
log::error!("VfsDavFile::flush() VFS flush failed for {:?}: {:?}", path, e);
return Box::pin(std::future::ready(Err(FsError::GeneralFailure)));
}
}
Err(e) => {
log::error!("VfsDavFile::flush() open_file failed for {:?}: {:?}", path, e);
return Box::pin(std::future::ready(Err(FsError::GeneralFailure)));
}
}
}
}
}
self.flushed = true;
// Phase 2: Create version from buffered data (no disk re-read)
if let (Some(versioning), Some(path)) = (&self.versioning, &self.path) {
if !self.data.is_empty() {
let path_str = path.to_string_lossy().to_string();
if let Err(e) = versioning.create_version(&path_str, &self.data, None, None) {
log::warn!("VfsDavFile::flush() create_version failed for {:?}: {:?}", path, e);
}
}
}
// Phase 3: Clear buffered data
self.data.clear();
// Phase 4: Upload hook
if let Some(path) = &self.path {
if let Some(hook) = &self.upload_hook {
if let Err(e) = hook.trigger(path, &self.user_uuid) {
log::warn!("Upload hook failed for {:?}: {}", path, e);
}
}
}
Box::pin(std::future::ready(Ok(())))
}
}
impl VfsDavFs {
fn empty_acl_xml() -> Vec<u8> {
b"<D:acl xmlns:D=\"DAV:\"><D:ace><D:principal><D:all/></D:principal><D:grant><D:privilege><D:all/></D:privilege></D:grant></D:ace></D:acl>".to_vec()
}
fn is_acl_prop(prop: &DavProp) -> bool {
prop.name == "acl" && prop.namespace.as_deref() == Some("DAV:")
}
fn acl_prop() -> DavProp {
DavProp {
name: "acl".to_string(),
prefix: Some("D".to_string()),
namespace: Some("DAV:".to_string()),
xml: Some(Self::empty_acl_xml()),
}
}
fn check_acl(&self, path: &Path, mask: crate::vfs::VfsAceMask) -> Result<(), FsError> {
if !self.enable_acl {
return Ok(());
}
match self.vfs.check_acl(path, &self.user_uuid, mask) {
Ok(true) => Ok(()),
Ok(false) => Err(FsError::Forbidden),
Err(crate::vfs::VfsError::Unsupported(_)) => Ok(()),
Err(_) => Err(FsError::Forbidden),
}
}
}
impl DavFileSystem for VfsDavFs {
fn open<'a>(
&'a self,
path: &'a DavPath,
options: OpenOptions,
) -> FsFuture<'a, Box<dyn DavFile>> {
let full_path = match self.resolve_path(path) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
if options.write {
if let Err(e) = self.check_acl(&full_path, crate::vfs::VfsAceMask::WriteData) {
return Box::pin(std::future::ready(Err(e)));
}
let file = VfsDavFile::new_write(
full_path,
self.vfs.clone_boxed(),
self.upload_hook.clone(),
self.user_uuid.clone(),
self.versioning.clone(),
);
Box::pin(std::future::ready(Ok(Box::new(file) as Box<dyn DavFile>)))
} else {
if let Err(e) = self.check_acl(&full_path, crate::vfs::VfsAceMask::ReadData) {
return Box::pin(std::future::ready(Err(e)));
}
let flags = OpenFlags::new().read();
match self.vfs.open_file(&full_path, &flags) {
Ok(vfs_file) => {
let file = VfsDavFile::new_read(vfs_file);
Box::pin(std::future::ready(Ok(Box::new(file) as Box<dyn DavFile>)))
}
Err(_) => Box::pin(std::future::ready(Err(FsError::NotFound))),
}
}
}
fn read_dir<'a>(
&'a self,
path: &'a DavPath,
_meta: dav_server::fs::ReadDirMeta,
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
let full_path = match self.resolve_path(path) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
if let Err(e) = self.check_acl(&full_path, crate::vfs::VfsAceMask::ListDirectory) {
return Box::pin(std::future::ready(Err(e)));
}
match self.vfs.read_dir(&full_path) {
Ok(entries) => {
let results: Vec<Box<dyn DavDirEntry>> = entries
.into_iter()
.map(|e| {
let meta = VfsDavMetaData::from_stat(&e.stat);
Box::new(VfsDavDirEntry::new(e.name, meta)) as Box<dyn DavDirEntry>
})
.collect();
let stream = stream::iter(results.into_iter().map(Ok));
Box::pin(std::future::ready(Ok(Box::pin(stream) as FsStream<Box<dyn DavDirEntry>>)))
}
Err(_) => Box::pin(std::future::ready(Err(FsError::NotFound))),
}
}
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
let full_path = match self.resolve_path(path) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
match self.vfs.stat(&full_path) {
Ok(stat) => {
let meta = VfsDavMetaData::from_stat(&stat);
Box::pin(std::future::ready(Ok(Box::new(meta) as Box<dyn DavMetaData>)))
}
Err(_) => Box::pin(std::future::ready(Err(FsError::NotFound))),
}
}
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
let full_path = match self.resolve_path(path) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
if let Err(e) = self.check_acl(&full_path, crate::vfs::VfsAceMask::AddSubdirectory) {
return Box::pin(std::future::ready(Err(e)));
}
if self.vfs.exists(&full_path) {
return Box::pin(std::future::ready(Err(FsError::Exists)));
}
match self.vfs.create_dir_all(&full_path, 0o755) {
Ok(_) => Box::pin(std::future::ready(Ok(()))),
Err(e) => Box::pin(std::future::ready(Err(map_vfs_error(e)))),
}
}
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
let full_path = match self.resolve_path(path) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
if let Err(e) = self.check_acl(&full_path, crate::vfs::VfsAceMask::DeleteChild) {
return Box::pin(std::future::ready(Err(e)));
}
match self.vfs.remove_dir_all(&full_path) {
Ok(_) => Box::pin(std::future::ready(Ok(()))),
Err(e) => Box::pin(std::future::ready(Err(map_vfs_error(e)))),
}
}
fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
let full_path = match self.resolve_path(path) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
if let Err(e) = self.check_acl(&full_path, crate::vfs::VfsAceMask::Delete) {
return Box::pin(std::future::ready(Err(e)));
}
match self.vfs.remove_file(&full_path) {
Ok(_) => Box::pin(std::future::ready(Ok(()))),
Err(e) => Box::pin(std::future::ready(Err(map_vfs_error(e)))),
}
}
fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
let from_path = match self.resolve_path(from) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
let to_path = match self.resolve_path(to) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
if let Err(e) = self.check_acl(&from_path, crate::vfs::VfsAceMask::Delete) {
return Box::pin(std::future::ready(Err(e)));
}
if let Err(e) = self.check_acl(&to_path, crate::vfs::VfsAceMask::AddFile) {
return Box::pin(std::future::ready(Err(e)));
}
let from_key = self.rel_key(from);
let to_key = self.rel_key(to);
let props_data = self.props_data.clone();
match self.vfs.rename(&from_path, &to_path) {
Ok(_) => {
if let Ok(mut map) = props_data.write() {
if let Some(props) = map.remove(&from_key) {
map.insert(to_key, props);
}
}
Box::pin(std::future::ready(Ok(())))
}
Err(e) => Box::pin(std::future::ready(Err(map_vfs_error(e)))),
}
}
fn set_accessed<'a>(&'a self, path: &'a DavPath, tm: SystemTime) -> FsFuture<'a, ()> {
let resolved = match self.resolve_path(path) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
match self.vfs.set_atime(&resolved, tm) {
Ok(_) => Box::pin(std::future::ready(Ok(()))),
Err(e) => Box::pin(std::future::ready(Err(map_vfs_error(e)))),
}
}
fn set_modified<'a>(&'a self, path: &'a DavPath, tm: SystemTime) -> FsFuture<'a, ()> {
let resolved = match self.resolve_path(path) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
match self.vfs.set_mtime(&resolved, tm) {
Ok(_) => Box::pin(std::future::ready(Ok(()))),
Err(e) => Box::pin(std::future::ready(Err(map_vfs_error(e)))),
}
}
fn get_props<'a>(&'a self, path: &'a DavPath, _do_content: bool) -> FsFuture<'a, Vec<DavProp>> {
let key = self.rel_key(path);
let data = self.props_data.clone();
Box::pin(async move {
let map = match data.read() {
Ok(guard) => guard,
Err(e) => {
log::warn!("props_data RwLock poisoned in get_props, recovering");
e.into_inner()
}
};
let mut props = map.get(&key).cloned().unwrap_or_default();
if !props.iter().any(Self::is_acl_prop) {
props.push(Self::acl_prop());
}
Ok(props)
})
}
fn get_prop<'a>(&'a self, path: &'a DavPath, prop: DavProp) -> FsFuture<'a, Vec<u8>> {
let key = self.rel_key(path);
let data = self.props_data.clone();
Box::pin(async move {
if Self::is_acl_prop(&prop) {
return Ok(Self::empty_acl_xml());
}
let map = match data.read() {
Ok(guard) => guard,
Err(e) => {
log::warn!("props_data RwLock poisoned in get_prop, recovering");
e.into_inner()
}
};
if let Some(props) = map.get(&key) {
for p in props {
if p.name == prop.name
&& p.namespace == prop.namespace
{
return Ok(p.xml.clone().unwrap_or_default());
}
}
}
Err(FsError::NotFound)
})
}
fn patch_props<'a>(
&'a self,
path: &'a DavPath,
patch: Vec<(bool, DavProp)>,
) -> FsFuture<'a, Vec<(StatusCode, DavProp)>> {
let key = self.rel_key(path);
let data = self.props_data.clone();
let vfs = self.vfs.clone_boxed();
let root = self.root.clone();
Box::pin(async move {
let results: Vec<(StatusCode, DavProp)> = {
let mut map = match data.write() {
Ok(guard) => guard,
Err(e) => {
log::warn!("props_data RwLock poisoned in patch_props, recovering");
e.into_inner()
}
};
patch
.into_iter()
.map(|(set, prop)| {
let code = if set {
map.entry(key.clone()).or_default().push(prop.clone());
StatusCode::OK
} else {
if let Some(props) = map.get_mut(&key) {
props.retain(|p| {
p.name != prop.name || p.namespace != prop.namespace
});
}
StatusCode::NO_CONTENT
};
(code, prop)
})
.collect()
};
let entries: HashMap<String, Vec<DeadPropEntry>> = {
let map = match data.read() {
Ok(guard) => guard,
Err(e) => {
log::warn!("props_data RwLock poisoned in patch_props persistence, recovering");
e.into_inner()
}
};
map.iter()
.filter(|(_, v)| !v.is_empty())
.map(|(k, v)| (k.clone(), v.iter().map(DeadPropEntry::from).collect()))
.collect()
};
if let Ok(json) = serde_json::to_string(&entries) {
let path = root.join(".webdav_props.json");
let _ = tokio::task::spawn_blocking(move || {
let flags = OpenFlags::new().write().create().truncate().mode(0o644);
match vfs.open_file(&path, &flags) {
Ok(mut file) => {
if let Err(e) = file.write_all(json.as_bytes()) {
log::warn!("patch_props write_all failed: {:?}", e);
}
if let Err(e) = file.flush() {
log::warn!("patch_props flush failed: {:?}", e);
}
}
Err(e) => {
log::warn!("patch_props open_file failed: {:?}", e);
}
}
}).await;
}
Ok(results)
})
}
fn have_props<'a>(&'a self, _path: &'a DavPath) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
Box::pin(std::future::ready(true))
}
fn get_quota(&'_ self) -> FsFuture<'_, (u64, Option<u64>)> {
let root = self.root.clone();
let vfs = self.vfs.clone_boxed();
Box::pin(async move {
let used = vfs.get_quota_usage(&root);
let total = vfs.get_quota(&root);
match (used, total) {
(Ok(usage), Ok(quota)) => {
let limit = if quota.space_limit > 0 { Some(quota.space_limit) } else { None };
Ok((usage.space_used, limit))
}
(Err(VfsError::NotFound(_)), _) | (_, Err(VfsError::NotFound(_))) => {
Err(FsError::NotFound)
}
(Err(VfsError::Unsupported(_)), _) | (_, Err(VfsError::Unsupported(_))) => {
Err(FsError::NotImplemented)
}
_ => Err(FsError::GeneralFailure),
}
})
}
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
let from_path = match self.resolve_path(from) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
let to_path = match self.resolve_path(to) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(Err(e))),
};
let from_stat = match self.vfs.stat(&from_path) {
Ok(s) => s,
Err(_) => return Box::pin(std::future::ready(Err(FsError::NotFound))),
};
let from_key = self.rel_key(from);
let to_key = self.rel_key(to);
let props_data = self.props_data.clone();
if from_stat.is_dir {
let vfs_arc = self.vfs.clone_boxed();
Box::pin(async move {
if let Err(e) = copy_dir_recursive_impl(&*vfs_arc, &from_path, &to_path) {
return Err(match e {
VfsError::NotFound(_) => FsError::NotFound,
VfsError::PermissionDenied(_) => FsError::Forbidden,
_ => FsError::NotImplemented,
});
}
if let Ok(map) = props_data.read() {
if let Some(props) = map.get(&from_key) {
if let Ok(mut map) = props_data.write() {
map.insert(to_key, props.clone());
}
}
}
Ok(())
})
} else {
match self.vfs.copy(&from_path, &to_path) {
Ok(_) => {
if let Ok(map) = props_data.read() {
if let Some(props) = map.get(&from_key) {
if let Ok(mut map) = props_data.write() {
map.insert(to_key, props.clone());
}
}
}
Box::pin(std::future::ready(Ok(())))
}
Err(VfsError::NotFound(_)) => Box::pin(std::future::ready(Err(FsError::NotFound))),
Err(_) => Box::pin(std::future::ready(Err(FsError::NotImplemented))),
}
}
}
}
/// Recursive directory copy via VfsBackend
fn copy_dir_recursive_impl(
vfs: &dyn VfsBackend,
src: &Path,
dst: &Path,
) -> Result<(), VfsError> {
vfs.create_dir_all(dst, 0o755)?;
let entries = vfs.read_dir(src)?;
for entry in entries {
let src_entry = src.join(&entry.name);
let dst_entry = dst.join(&entry.name);
if entry.stat.is_dir {
copy_dir_recursive_impl(vfs, &src_entry, &dst_entry)?;
} else {
vfs.copy(&src_entry, &dst_entry)?;
}
}
Ok(())
}
pub fn create_webdav_handler(
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
) -> dav_server::DavHandler {
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, None, None)
}
pub fn create_webdav_handler_with_versioning(
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
versioning: Arc<WebDavVersioning>,
) -> dav_server::DavHandler {
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, Some(versioning), None)
}
pub fn create_webdav_handler_persisted(
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
versioning: Option<Arc<WebDavVersioning>>,
locks_file: PathBuf,
) -> dav_server::DavHandler {
create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, versioning, Some(locks_file))
}
fn create_webdav_handler_inner(
vfs: Box<dyn VfsBackend>,
root: PathBuf,
upload_hook: Option<Arc<UploadHook>>,
user_uuid: String,
versioning: Option<Arc<WebDavVersioning>>,
locks_file: Option<PathBuf>,
) -> dav_server::DavHandler {
let dav_fs = match versioning {
Some(v) => VfsDavFs::with_versioning(vfs, root, upload_hook, user_uuid, v),
None => VfsDavFs::new(vfs, root, upload_hook, user_uuid),
};
let locksystem: Box<dyn DavLockSystem> = match locks_file {
Some(path) => PersistedLs::new(path),
None => MemLs::new(),
};
dav_server::DavHandler::builder()
.filesystem(dav_fs)
.locksystem(locksystem)
.strip_prefix("")
.build_handler()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::local_fs::LocalFs;
use dav_server::davpath::DavPath;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_dead_prop_entry_roundtrip() {
let prop = DavProp {
name: "displayname".to_string(),
prefix: Some("D".to_string()),
namespace: Some("DAV:".to_string()),
xml: Some(b"<D:displayname>test.txt</D:displayname>".to_vec()),
};
let entry = DeadPropEntry::from(&prop);
let restored = DavProp::from(entry);
assert_eq!(restored.name, "displayname");
assert_eq!(restored.prefix, Some("D".to_string()));
assert_eq!(restored.namespace, Some("DAV:".to_string()));
assert!(restored.xml.is_some());
}
#[test]
fn test_dead_prop_entry_serde() {
let prop = DavProp {
name: "getcontenttype".to_string(),
prefix: Some("D".to_string()),
namespace: Some("DAV:".to_string()),
xml: Some(b"text/plain".to_vec()),
};
let entry = DeadPropEntry::from(&prop);
let json = serde_json::to_string(&entry).unwrap();
let deserialized: DeadPropEntry = serde_json::from_str(&json).unwrap();
let restored = DavProp::from(deserialized);
assert_eq!(restored.name, "getcontenttype");
assert_eq!(restored.xml.unwrap(), b"text/plain".to_vec());
}
#[test]
fn test_dead_prop_entry_no_xml() {
let prop = DavProp {
name: "resourcetype".to_string(),
prefix: None,
namespace: None,
xml: None,
};
let entry = DeadPropEntry::from(&prop);
let json = serde_json::to_string(&entry).unwrap();
let deserialized: DeadPropEntry = serde_json::from_str(&json).unwrap();
assert!(deserialized.xml.is_none());
}
#[test]
fn test_vfs_dav_meta_data_from_stat() {
let stat = crate::vfs::VfsStat {
size: 1024,
mode: 0o644,
uid: 501,
gid: 20,
atime: std::time::UNIX_EPOCH,
mtime: std::time::UNIX_EPOCH,
is_dir: false,
is_symlink: false,
};
let meta = VfsDavMetaData::from_stat(&stat);
assert_eq!(meta.len(), 1024);
assert!(!meta.is_dir());
}
#[test]
fn test_vfs_dav_meta_data_dir() {
let stat = crate::vfs::VfsStat {
size: 0,
mode: 0o755,
uid: 501,
gid: 20,
atime: std::time::UNIX_EPOCH,
mtime: std::time::UNIX_EPOCH,
is_dir: true,
is_symlink: false,
};
let meta = VfsDavMetaData::from_stat(&stat);
assert_eq!(meta.len(), 0);
assert!(meta.is_dir());
}
#[test]
fn test_resolve_path_traversal_rejected() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let vfs = LocalFs::new();
let dav_fs = VfsDavFs::new(Box::new(vfs), root.clone(), None, "test".to_string());
// DavPath rejects paths containing ".." outright (ForbiddenPath),
// so the earliest our resolve_path can catch is absolute-path-like
// paths that escape root via canonicalize mismatch.
// Create a file outside root and try to access it.
let outside = TempDir::new().unwrap();
let secret = outside.path().join("secret.txt");
fs::write(&secret, "data").unwrap();
// An absolute path that doesn't start with root should be rejected
// by the canonicalize check inside resolve_path.
// We skip DavPath and call the internal method directly:
let bad_relative = std::path::Path::new("/etc/passwd");
// resolve_path accepts DavPath, so test the internal logic:
let full = root.join(bad_relative);
let root_canonical = root.canonicalize().unwrap();
if let Ok(canonical) = full.canonicalize() {
assert!(!canonical.starts_with(&root_canonical));
} else {
// Path doesn't exist, which means it'll be caught by the
// parent validation (or fail to canonicalize), which is also correct
}
}
#[test]
fn test_resolve_path_normal() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
fs::write(root.join("test.txt"), "hello").unwrap();
let vfs = LocalFs::new();
let dav_fs = VfsDavFs::new(Box::new(vfs), root.clone(), None, "test".to_string());
// DavPath is always absolute (starts with /) — it's a URL path convention.
// resolve_path uses as_rel_ospath() to strip the leading / before joining with root.
let dp = DavPath::new("/test.txt").unwrap();
let resolved = dav_fs.resolve_path(&dp).unwrap();
assert!(resolved.exists());
assert_eq!(resolved.file_name().unwrap().to_str(), Some("test.txt"));
assert!(resolved.starts_with(root.canonicalize().unwrap()));
}
#[test]
fn test_resolve_path_nonexistent_parent_ok() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let vfs = LocalFs::new();
let dav_fs = VfsDavFs::new(Box::new(vfs), root.clone(), None, "test".to_string());
// Non-existent file in existing root — should succeed (parent=root is valid)
let dp = DavPath::new("/new_file.txt").unwrap();
let result = dav_fs.resolve_path(&dp);
assert!(result.is_ok());
// Non-existent file in non-existent subdir — should fail (parent doesn't exist)
let dp2 = DavPath::new("/nonexistent_dir/file.txt").unwrap();
let result2 = dav_fs.resolve_path(&dp2);
assert!(result2.is_err());
}
#[test]
fn test_copy_dir_recursive() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("src");
let dst = tmp.path().join("dst");
fs::create_dir_all(root.join("subdir")).unwrap();
fs::write(root.join("a.txt"), "aaa").unwrap();
fs::write(root.join("subdir").join("b.txt"), "bbb").unwrap();
let vfs = LocalFs::new();
copy_dir_recursive_impl(&vfs, &root, &dst).unwrap();
assert!(dst.join("a.txt").exists());
assert!(dst.join("subdir").join("b.txt").exists());
assert_eq!(fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
}
#[test]
fn test_remove_dir_all() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("toremove");
fs::create_dir_all(dir.join("sub").join("deep")).unwrap();
fs::write(dir.join("f1.txt"), "data").unwrap();
fs::write(dir.join("sub").join("f2.txt"), "data").unwrap();
let vfs = LocalFs::new();
vfs.remove_dir_all(&dir).unwrap();
assert!(!dir.exists());
}
#[test]
fn test_create_dir_all_nested() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("base");
let nested = root.join("a").join("b").join("c");
let vfs = LocalFs::new();
vfs.create_dir_all(&nested, 0o755).unwrap();
assert!(nested.exists());
assert!(nested.is_dir());
}
#[test]
fn test_set_times() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("time_test.txt");
fs::write(&file, "data").unwrap();
let vfs = LocalFs::new();
let atime = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1000000);
let mtime = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(2000000);
vfs.set_times(&file, atime, mtime).unwrap();
let stat = vfs.stat(&file).unwrap();
assert_eq!(stat.atime, atime);
assert_eq!(stat.mtime, mtime);
}
#[test]
fn test_dead_props_store_and_load() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let vfs = LocalFs::new();
let dav_fs = VfsDavFs::new(Box::new(vfs), root.clone(), None, "test".to_string());
let prop = DavProp {
name: "custom".to_string(),
prefix: None,
namespace: None,
xml: Some(b"value".to_vec()),
};
// Simulate patch_props by directly inserting
{
let mut map = dav_fs.props_data.write().unwrap();
map.entry("/test".to_string()).or_default().push(prop.clone());
}
dav_fs.save_props();
// Load from file into a new VfsDavFs
let vfs2 = LocalFs::new();
let dav_fs2 = VfsDavFs::new(Box::new(vfs2), root.clone(), None, "test".to_string());
let map = dav_fs2.props_data.read().unwrap();
let props = map.get("/test");
assert!(props.is_some());
assert_eq!(props.unwrap().len(), 1);
assert_eq!(props.unwrap()[0].name, "custom");
}
}
#[cfg(test)]
mod integration_tests {
use axum::body::Body as ReqBody;
use dav_server::body::Body as ResBody;
use dav_server::DavHandler;
use dav_server::memls::MemLs;
use futures_util::TryStreamExt;
use http::{Method, StatusCode};
use tempfile::TempDir;
use super::VfsDavFs;
use crate::vfs::local_fs::LocalFs;
fn make_handler(tmp: &TempDir) -> DavHandler {
let root = tmp.path().to_path_buf();
let vfs = Box::new(LocalFs::new());
let dav_fs = VfsDavFs::new(vfs, root, None, "test".to_string());
DavHandler::builder()
.filesystem(dav_fs)
.locksystem(MemLs::new())
.build_handler()
}
async fn collect_body(res: http::Response<ResBody>) -> (StatusCode, Vec<u8>) {
let (parts, body) = res.into_parts();
let bytes = body
.try_fold(Vec::new(), |mut acc, chunk| async move {
acc.extend_from_slice(&chunk);
Ok(acc)
})
.await
.unwrap();
(parts.status, bytes)
}
#[tokio::test]
async fn test_put_get_roundtrip() {
let tmp = TempDir::new().unwrap();
let handler = make_handler(&tmp);
// PUT
let req = http::Request::builder()
.method(Method::PUT)
.uri("/hello.txt")
.body(ReqBody::from("Hello, World!"))
.unwrap();
let res = handler.handle(req).await;
let status = res.status();
assert!(
status == StatusCode::CREATED || status == StatusCode::NO_CONTENT,
"Expected CREATED (201) or NO_CONTENT (204), got {}",
status
);
// GET
let req = http::Request::builder()
.method(Method::GET)
.uri("/hello.txt")
.body(ReqBody::from(""))
.unwrap();
let res = handler.handle(req).await;
let (status, body) = collect_body(res).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, b"Hello, World!");
}
#[tokio::test]
async fn test_propfind() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.txt"), "aaa").unwrap();
std::fs::write(tmp.path().join("b.txt"), "bbb").unwrap();
std::fs::create_dir(tmp.path().join("subdir")).unwrap();
let handler = make_handler(&tmp);
let req = http::Request::builder()
.method(Method::from_bytes(b"PROPFIND").unwrap())
.uri("/")
.header("Depth", "1")
.body(ReqBody::from(""))
.unwrap();
let res = handler.handle(req).await;
assert_eq!(res.status(), StatusCode::MULTI_STATUS);
let (_, body) = collect_body(res).await;
let xml = String::from_utf8(body).unwrap();
assert!(xml.contains("a.txt"), "PROPFIND response should contain a.txt");
assert!(xml.contains("b.txt"), "PROPFIND response should contain b.txt");
assert!(xml.contains("subdir"), "PROPFIND response should contain subdir");
}
#[tokio::test]
async fn test_mkcol_delete() {
let tmp = TempDir::new().unwrap();
let handler = make_handler(&tmp);
// MKCOL
let req = http::Request::builder()
.method(Method::from_bytes(b"MKCOL").unwrap())
.uri("/mydir")
.body(ReqBody::from(""))
.unwrap();
let res = handler.handle(req).await;
assert!(res.status().is_success(), "MKCOL failed: {}", res.status());
assert!(tmp.path().join("mydir").is_dir(), "Directory should exist");
// DELETE
let req = http::Request::builder()
.method(Method::DELETE)
.uri("/mydir")
.body(ReqBody::from(""))
.unwrap();
let res = handler.handle(req).await;
assert!(res.status().is_success(), "DELETE failed: {}", res.status());
assert!(!tmp.path().join("mydir").exists(), "Directory should be removed");
}
#[tokio::test]
async fn test_copy_move() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("src.txt"), "data").unwrap();
let handler = make_handler(&tmp);
// COPY
let req = http::Request::builder()
.method(Method::from_bytes(b"COPY").unwrap())
.uri("/src.txt")
.header("Destination", "/dst.txt")
.body(ReqBody::from(""))
.unwrap();
let res = handler.handle(req).await;
assert!(res.status().is_success(), "COPY failed: {}", res.status());
assert_eq!(
std::fs::read_to_string(tmp.path().join("dst.txt")).unwrap(),
"data",
"Copied file content should match"
);
// MOVE
let req = http::Request::builder()
.method(Method::from_bytes(b"MOVE").unwrap())
.uri("/src.txt")
.header("Destination", "/moved.txt")
.body(ReqBody::from(""))
.unwrap();
let res = handler.handle(req).await;
assert!(res.status().is_success(), "MOVE failed: {}", res.status());
assert!(!tmp.path().join("src.txt").exists(), "Source should not exist after MOVE");
assert_eq!(
std::fs::read_to_string(tmp.path().join("moved.txt")).unwrap(),
"data",
"Moved file content should match"
);
}
#[tokio::test]
async fn test_lock_unlock() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("locked.txt"), "content").unwrap();
let handler = make_handler(&tmp);
// LOCK
let lock_body = r#"<?xml version="1.0" encoding="utf-8"?>
<D:lockinfo xmlns:D="DAV:">
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:owner><D:href>testuser</D:href></D:owner>
</D:lockinfo>"#;
let req = http::Request::builder()
.method(Method::from_bytes(b"LOCK").unwrap())
.uri("/locked.txt")
.header("Content-Type", "text/xml; charset=utf-8")
.body(ReqBody::from(lock_body))
.unwrap();
let res = handler.handle(req).await;
assert_eq!(res.status(), StatusCode::OK, "LOCK should return 200 OK");
let lock_token = res
.headers()
.get("Lock-Token")
.and_then(|v| v.to_str().ok())
.map(String::from);
assert!(
lock_token.is_some(),
"LOCK response should have Lock-Token header"
);
// UNLOCK
let req = http::Request::builder()
.method(Method::from_bytes(b"UNLOCK").unwrap())
.uri("/locked.txt")
.header("Lock-Token", lock_token.as_ref().unwrap())
.body(ReqBody::from(""))
.unwrap();
let res = handler.handle(req).await;
assert_eq!(
res.status(),
StatusCode::NO_CONTENT,
"UNLOCK should return 204 NO_CONTENT"
);
}
#[tokio::test]
async fn test_etag_header() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("etagtest.txt"), "etag content").unwrap();
let handler = make_handler(&tmp);
let req = http::Request::builder()
.method(Method::GET)
.uri("/etagtest.txt")
.body(ReqBody::from(""))
.unwrap();
let res = handler.handle(req).await;
assert_eq!(res.status(), StatusCode::OK);
assert!(
res.headers().contains_key("ETag"),
"GET response should include ETag header"
);
}
#[tokio::test]
async fn test_acl_property() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("acl_test.txt"), "acl content").unwrap();
let handler = make_handler(&tmp);
// PROPFIND with DAV:acl property requested
let req_body = r#"<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:acl/>
<D:resourcetype/>
<D:displayname/>
</D:prop>
</D:propfind>"#;
let req = http::Request::builder()
.method(Method::from_bytes(b"PROPFIND").unwrap())
.uri("/acl_test.txt")
.header("Depth", "0")
.header("Content-Type", "text/xml; charset=utf-8")
.body(ReqBody::from(req_body))
.unwrap();
let res = handler.handle(req).await;
assert_eq!(res.status(), StatusCode::MULTI_STATUS);
let (_, body) = collect_body(res).await;
let xml = String::from_utf8(body).unwrap();
assert!(xml.contains("D:acl"), "PROPFIND response should contain D:acl element");
assert!(xml.contains("D:all"), "ACL should grant all privileges to all principals");
}
#[tokio::test]
async fn test_range_request() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("range_test.txt"), "0123456789abcdefghij").unwrap();
let handler = make_handler(&tmp);
// Range request: bytes=5-10
let req = http::Request::builder()
.method(Method::GET)
.uri("/range_test.txt")
.header("Range", "bytes=5-10")
.body(ReqBody::from(""))
.unwrap();
let res = handler.handle(req).await;
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT, "Range request should return 206");
assert!(
res.headers().contains_key("Content-Range"),
"Range response should include Content-Range header"
);
let (_, body) = collect_body(res).await;
let content = String::from_utf8(body).unwrap();
assert_eq!(content, "56789a", "Range bytes=5-10 should return '56789a' (6 bytes)");
}
}