From 9acd174388fc51ec07dc0f9ab04264dbdb179a9a Mon Sep 17 00:00:00 2001 From: Warren Date: Sun, 21 Jun 2026 16:07:12 +0800 Subject: [PATCH] WebDAV improvements: flush fix, RwLock recovery, expired lock cleanup, atomic set_times P0 fixes: - flush(): add flushed flag, proper error logging, Drop warning for data loss - props_data RwLock: replace unwrap() with try_read/try_write recovery - PersistedLs: add is_expired() + cleanup_expired_locks() helper P1 improvements: - Props persistence via VFS (load_props/save_props/patch_props) - COPY/MOVE sync dead props (copy on COPY, move key on rename) - Atomic set_atime/set_mtime via filetime crate (no race condition) New files: - webdav_locks.rs: PersistedLs with lock persistence + expiry cleanup Tests: 288 passed, 0 failed --- Cargo.lock | 2 + markbase-core/Cargo.toml | 2 + markbase-core/src/lib.rs | 1 + markbase-core/src/server.rs | 176 +++- markbase-core/src/vfs/local_fs.rs | 84 +- markbase-core/src/vfs/mod.rs | 55 ++ markbase-core/src/webdav.rs | 1280 +++++++++++++++++++++++++-- markbase-core/src/webdav_locks.rs | 417 +++++++++ markbase-core/src/webdav_version.rs | 35 +- 9 files changed, 1940 insertions(+), 112 deletions(-) create mode 100644 markbase-core/src/webdav_locks.rs diff --git a/Cargo.lock b/Cargo.lock index c21c811..a2b8104 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2890,6 +2890,7 @@ dependencies = [ "futures-util", "hex", "hmac 0.12.1", + "http", "log", "md5 0.8.0", "nix 0.29.0", @@ -2926,6 +2927,7 @@ dependencies = [ "url", "uuid", "x25519-dalek", + "xmltree", "xz2", "zip", "zstd 0.13.3", diff --git a/markbase-core/Cargo.toml b/markbase-core/Cargo.toml index 593e045..32934cf 100644 --- a/markbase-core/Cargo.toml +++ b/markbase-core/Cargo.toml @@ -46,11 +46,13 @@ ssh2 = "0.9.4" ssh-key = "0.7.0-rc.10" rand = "0.8" axum-extra = { version = "0.9", features = ["multipart"] } +http = "1" tokio-util = { version = "0.7", features = ["io"] } zstd = "0.13" hex = "0.4" toml = "0.8" uuid = { version = "1", features = ["v4"] } +xmltree = "0.12" dashmap = "6.1" md5 = "0.8" adler = "1.0" diff --git a/markbase-core/src/lib.rs b/markbase-core/src/lib.rs index e43beba..5434bbf 100644 --- a/markbase-core/src/lib.rs +++ b/markbase-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod ssh_server; pub mod sync; pub mod vfs; pub mod webdav; +pub mod webdav_locks; pub mod webdav_version; #[cfg(test)] diff --git a/markbase-core/src/server.rs b/markbase-core/src/server.rs index fd948bf..99ca4a0 100644 --- a/markbase-core/src/server.rs +++ b/markbase-core/src/server.rs @@ -1,17 +1,20 @@ use anyhow::Context; use axum::{ + body::Body, extract::DefaultBodyLimit, extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse, Json}, routing::{any, delete, get, patch, post, put}, Extension, Router, }; -use dav_server::{fakels::FakeLs, DavHandler}; +use base64::Engine as _; use serde::Deserialize; use std::str::FromStr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, LazyLock, Mutex, RwLock}; + +use dashmap::DashMap; use crate::archive::{ ArchiveConfig, ArchiveFormat, ArchiveProcessor, FormatDetector, ProcessorRegistry, @@ -134,25 +137,41 @@ pub async fn run(port: u16, file: Option) -> anyhow::Result<()> { } }); - // WebDAV handler creation (Phase 20) - let webdav_user = "demo"; - let webdav_home = std::path::PathBuf::from("/Users/accusys/momentry/var/sftpgo/data").join(webdav_user); - - let webdav_vfs = Box::new(crate::vfs::local_fs::LocalFs::new()); - let webdav_fs = crate::webdav::VfsDavFs::new( - webdav_vfs, - webdav_home, - None, // upload_hook - webdav_user.to_string(), + // ===== WebDAV multi-user configuration (Phase 20 + P1) ===== + let webdav_parent = std::path::PathBuf::from( + std::env::var("MB_WEBDAV_PARENT") + .unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data".to_string()), + ); + + // WebDAV versioning storage + let version_storage = std::path::PathBuf::from("data/webdav_versions"); + std::fs::create_dir_all(&version_storage).ok(); + + // Upload hook (disabled by default) + let upload_hook = Arc::new(crate::ssh_server::upload_hook::UploadHook::new( + false, + std::path::PathBuf::from("/usr/local/bin/ffprobe"), + std::path::PathBuf::from("/usr/local/bin/video-register"), + std::path::PathBuf::from("/Users/accusys/momentry/var/video-register"), + vec!["mp4".to_string(), "mov".to_string(), "avi".to_string(), "mkv".to_string(), "webm".to_string()], + )); + + // VFS proto for per-request DavHandler construction + let s3_cfg = crate::s3_config::S3Config::load_default().unwrap_or_default(); + let use_s3 = s3_cfg.s3.enabled; + + let webdav_versioning = { + let vs = version_storage.clone(); + Arc::new(crate::webdav_version::WebDavVersioning::new(vs)) + }; + + log::info!( + "WebDAV configured: parent={}, versioning={}, upload_hook={}, s3={}", + webdav_parent.display(), + true, + false, + use_s3, ); - - let webdav_handler = DavHandler::builder() - .filesystem(webdav_fs) - .locksystem(FakeLs::new()) - .strip_prefix("/webdav") - .build_handler(); - - log::info!("WebDAV handler created for user: {}", webdav_user); let app = Router::new() .route("/", get(root_handler)) @@ -256,11 +275,15 @@ pub async fn run(port: u16, file: Option) -> anyhow::Result<()> { .route("/files", get(|| async { Html(include_str!("file_list.html")) })) .route("/products", get(|| async { Html(include_str!("product_manager.html")) })) .route("/downloads", get(|| async { Html(include_str!("category_view.html")) })) - // WebDAV API endpoints (Phase 20) - .route("/webdav", any(handle_webdav)) - .route("/webdav/", any(handle_webdav)) - .route("/webdav/*path", any(handle_webdav)) - .layer(Extension(webdav_handler)) + // WebDAV API endpoints (Phase 20, multi-user P1) + .route("/webdav", any(handle_webdav_multi)) + .route("/webdav/", any(handle_webdav_multi)) + .route("/webdav/*path", any(handle_webdav_multi)) + .layer(Extension(webdav_parent)) + .layer(Extension(upload_hook)) + .layer(Extension(webdav_versioning)) + .layer(Extension(use_s3)) + .layer(Extension(s3_cfg)) .layer(DefaultBodyLimit::disable()) .with_state(state); @@ -2445,11 +2468,102 @@ async fn search_files_handler(Query(query): Query) -> impl IntoResp .into_response(), } } +// ===== WebDAV multi-user handler (Phase 20 + P1 multi-user) ===== -// WebDAV handler (Phase 20) -async fn handle_webdav( - Extension(dav): Extension, +static WEBDAV_HANDLER_CACHE: LazyLock> = + LazyLock::new(|| DashMap::new()); + +async fn handle_webdav_multi( + Extension(parent): Extension, + Extension(upload_hook): Extension>, + Extension(versioning): Extension>, + Extension(use_s3): Extension, + Extension(s3_cfg): Extension, req: axum::extract::Request, -) -> impl IntoResponse { - dav.handle(req).await +) -> axum::response::Response { + // 1. Extract Basic Auth + let auth = req + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .filter(|v| v.starts_with("Basic ")) + .and_then(|v| { + let encoded = &v[6..]; + let decoded = base64::engine::general_purpose::STANDARD.decode(encoded).ok()?; + let creds = String::from_utf8(decoded).ok()?; + let colon = creds.find(':')?; + Some((creds[..colon].to_string(), creds[colon + 1..].to_string())) + }); + + // 2. Validate against credential list from env + let (username, _password) = match auth { + Some(creds) => { + let users_str = std::env::var("MB_WEBDAV_USERS") + .unwrap_or_else(|_| "demo:demo123".to_string()); + let valid = users_str.split(',') + .filter_map(|entry| { + let mut parts = entry.splitn(2, ':'); + let u = parts.next()?.to_string(); + let p = parts.next().unwrap_or("").to_string(); + Some((u, p)) + }) + .any(|(u, p)| u == creds.0 && p == creds.1); + + if !valid { + return unauthorized_response(); + } + creds + } + None => return unauthorized_response(), + }; + + // 3. Get or create cached DavHandler for this user + let handler = WEBDAV_HANDLER_CACHE + .entry(username.clone()) + .or_insert_with(|| { + let user_root = parent.join(&username); + let vfs: Box = if use_s3 { + match crate::vfs::s3_fs::S3Vfs::new( + &s3_cfg.s3.endpoint, + &s3_cfg.s3.region, + &format!("webdav-{}", username), + &s3_cfg.keys.default_access_key, + &s3_cfg.keys.default_secret_key, + ) { + Ok(s3) => Box::new(s3), + Err(_) => Box::new(crate::vfs::local_fs::LocalFs::new()), + } + } else { + Box::new(crate::vfs::local_fs::LocalFs::new()) + }; + + let locks_dir = parent.join(".webdav_locks"); + let _ = std::fs::create_dir_all(&locks_dir); + let locks_file = locks_dir.join(format!("{}.json", username)); + crate::webdav::create_webdav_handler_persisted( + vfs, + user_root, + Some(upload_hook), + username, + Some(versioning), + locks_file, + ) + }) + .clone(); + + let dav_resp = handler.handle(req).await; + + // Convert dav-server response to axum response + let (parts, body) = dav_resp.into_parts(); + let axum_body = axum::body::Body::from_stream(body); + axum::response::Response::from_parts(parts, axum_body) +} + +fn unauthorized_response() -> axum::response::Response { + use axum::http::HeaderValue; + ( + StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", HeaderValue::from_static("Basic realm=\"MarkBase WebDAV\""))], + axum::body::Body::from("Unauthorized"), + ).into_response() } diff --git a/markbase-core/src/vfs/local_fs.rs b/markbase-core/src/vfs/local_fs.rs index 651ea99..94a1864 100644 --- a/markbase-core/src/vfs/local_fs.rs +++ b/markbase-core/src/vfs/local_fs.rs @@ -153,6 +153,10 @@ impl VfsBackend for LocalFs { fs::remove_dir(path).map_err(|e| util::map_io_error(path, e)) } + fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError> { + fs::remove_dir_all(path).map_err(|e| util::map_io_error(path, e)) + } + fn remove_file(&self, path: &Path) -> Result<(), VfsError> { fs::remove_file(path).map_err(|e| util::map_io_error(path, e)) } @@ -185,6 +189,39 @@ impl VfsBackend for LocalFs { Ok(()) } + fn set_times(&self, path: &Path, atime: SystemTime, mtime: SystemTime) -> Result<(), VfsError> { + let at = atime.duration_since(std::time::UNIX_EPOCH) + .map_err(|_| VfsError::Io("atime before UNIX_EPOCH".to_string()))?; + let mt = mtime.duration_since(std::time::UNIX_EPOCH) + .map_err(|_| VfsError::Io("mtime before UNIX_EPOCH".to_string()))?; + filetime::set_file_times( + path, + filetime::FileTime::from_unix_time(at.as_secs() as i64, at.subsec_nanos() as u32), + filetime::FileTime::from_unix_time(mt.as_secs() as i64, mt.subsec_nanos() as u32), + ) + .map_err(|e| util::map_io_error(path, e)) + } + + fn set_atime(&self, path: &Path, atime: SystemTime) -> Result<(), VfsError> { + let at = atime.duration_since(std::time::UNIX_EPOCH) + .map_err(|_| VfsError::Io("atime before UNIX_EPOCH".to_string()))?; + filetime::set_file_atime( + path, + filetime::FileTime::from_unix_time(at.as_secs() as i64, at.subsec_nanos() as u32), + ) + .map_err(|e| util::map_io_error(path, e)) + } + + fn set_mtime(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError> { + let mt = mtime.duration_since(std::time::UNIX_EPOCH) + .map_err(|_| VfsError::Io("mtime before UNIX_EPOCH".to_string()))?; + filetime::set_file_mtime( + path, + filetime::FileTime::from_unix_time(mt.as_secs() as i64, mt.subsec_nanos() as u32), + ) + .map_err(|e| util::map_io_error(path, e)) + } + fn read_link(&self, path: &Path) -> Result { let target = fs::read_link(path).map_err(|e| util::map_io_error(path, e))?; Ok(target) @@ -232,6 +269,15 @@ impl VfsBackend for LocalFs { Ok(()) } + fn copy(&self, from: &Path, to: &Path) -> Result<(), VfsError> { + // Check if source is a directory + if from.is_dir() { + return copy_dir_recursive_impl(from, to); + } + fs::copy(from, to).map_err(|e| util::map_io_error(from, e))?; + Ok(()) + } + // ===== Snapshot support ===== fn create_snapshot(&self, path: &Path, name: &str) -> Result<(), VfsError> { @@ -240,7 +286,7 @@ impl VfsBackend for LocalFs { let snapshot_path = snapshot_dir.join(name); if path.is_dir() { - self.copy_dir_recursive(path, &snapshot_path)?; + copy_dir_recursive_impl(path, &snapshot_path)?; } else { fs::copy(path, &snapshot_path).map_err(|e| util::map_io_error(path, e))?; } @@ -311,7 +357,7 @@ impl VfsBackend for LocalFs { } if snapshot_path.is_dir() { - self.copy_dir_recursive(&snapshot_path, path)?; + copy_dir_recursive_impl(&snapshot_path, path)?; } else { fs::copy(&snapshot_path, path).map_err(|e| util::map_io_error(&snapshot_path, e))?; } @@ -540,24 +586,6 @@ impl VfsBackend for LocalFs { } impl LocalFs { - fn copy_dir_recursive(&self, src: &Path, dst: &Path) -> Result<(), VfsError> { - fs::create_dir_all(dst).map_err(|e| util::map_io_error(dst, e))?; - - for entry in fs::read_dir(src).map_err(|e| util::map_io_error(src, e))? { - let entry = entry.map_err(|e| VfsError::Io(e.to_string()))?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - if src_path.is_dir() { - self.copy_dir_recursive(&src_path, &dst_path)?; - } else { - fs::copy(&src_path, &dst_path).map_err(|e| util::map_io_error(&src_path, e))?; - } - } - - Ok(()) - } - fn calculate_size(&self, path: &Path) -> Result { if path.is_dir() { let mut total = 0; @@ -772,6 +800,22 @@ impl VfsAclMeta { } } +/// Recursive directory copy helper (used by VfsBackend::copy) +fn copy_dir_recursive_impl(src: &Path, dst: &Path) -> Result<(), VfsError> { + fs::create_dir_all(dst).map_err(|e| util::map_io_error(dst, e))?; + for entry in fs::read_dir(src).map_err(|e| util::map_io_error(src, e))? { + let entry = entry.map_err(|e| util::map_io_error(src, e))?; + let src_entry = entry.path(); + let dst_entry = dst.join(entry.file_name()); + if src_entry.is_dir() { + copy_dir_recursive_impl(&src_entry, &dst_entry)?; + } else { + fs::copy(&src_entry, &dst_entry).map_err(|e| util::map_io_error(&src_entry, e))?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/markbase-core/src/vfs/mod.rs b/markbase-core/src/vfs/mod.rs index 912c484..2a1da23 100644 --- a/markbase-core/src/vfs/mod.rs +++ b/markbase-core/src/vfs/mod.rs @@ -148,6 +148,21 @@ pub trait VfsBackend: Send + Sync { /// 删除空目录 fn remove_dir(&self, path: &Path) -> Result<(), VfsError>; + /// 递归删除目录及其所有内容 + fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError> { + // Default: read entries and remove one by one + let entries = self.read_dir(path)?; + for entry in entries { + let child = path.join(&entry.name); + if entry.stat.is_dir { + self.remove_dir_all(&child)?; + } else { + self.remove_file(&child)?; + } + } + self.remove_dir(path) + } + /// 删除文件 fn remove_file(&self, path: &Path) -> Result<(), VfsError>; @@ -157,6 +172,28 @@ pub trait VfsBackend: Send + Sync { /// 设置文件属性 fn set_stat(&self, path: &Path, stat: &VfsStat) -> Result<(), VfsError>; + /// 原子性设置 atime 和 mtime(默认实现调用 stat + set_stat,有 race condition) + fn set_times(&self, path: &Path, atime: SystemTime, mtime: SystemTime) -> Result<(), VfsError> { + let mut stat = self.stat(path)?; + stat.atime = atime; + stat.mtime = mtime; + self.set_stat(path, &stat) + } + + /// 原子性设置 atime(默认实现调用 stat + set_stat,有 race condition) + fn set_atime(&self, path: &Path, atime: SystemTime) -> Result<(), VfsError> { + let mut stat = self.stat(path)?; + stat.atime = atime; + self.set_stat(path, &stat) + } + + /// 原子性设置 mtime(默认实现调用 stat + set_stat,有 race condition) + fn set_mtime(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError> { + let mut stat = self.stat(path)?; + stat.mtime = mtime; + self.set_stat(path, &stat) + } + /// 读取符号链接目标 fn read_link(&self, path: &Path) -> Result; @@ -172,6 +209,24 @@ pub trait VfsBackend: Send + Sync { /// 创建硬链接 fn hard_link(&self, original: &Path, link: &Path) -> Result<(), VfsError>; + /// 复制文件(高效实现,fallback 到 read+write) + fn copy(&self, from: &Path, to: &Path) -> Result<(), VfsError> { + let flags = open_flags::OpenFlags::new().read(); + let mut src = self.open_file(from, &flags)?; + let write_flags = open_flags::OpenFlags::new().write().create().truncate().mode(0o644); + let mut dst = self.open_file(to, &write_flags)?; + let mut buf = vec![0u8; 65536]; + loop { + match src.read(&mut buf) { + Ok(0) => break, + Ok(n) => dst.write_all(&buf[..n])?, + Err(e) => return Err(e), + } + } + dst.flush()?; + Ok(()) + } + // ===== Snapshot support (ZFS-style) ===== /// 创建快照 diff --git a/markbase-core/src/webdav.rs b/markbase-core/src/webdav.rs index d9aa779..bb1a937 100644 --- a/markbase-core/src/webdav.rs +++ b/markbase-core/src/webdav.rs @@ -1,22 +1,73 @@ use crate::vfs::open_flags::OpenFlags; -use crate::vfs::{VfsBackend, VfsStat}; +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, FsError, FsFuture, FsStream, OpenOptions, + DavDirEntry, DavFile, DavFileSystem, DavMetaData, DavProp, FsError, FsFuture, FsStream, + OpenOptions, }; -use dav_server::fakels::FakeLs; +use dav_server::ls::DavLockSystem; +use http::StatusCode; +use dav_server::memls::MemLs; use futures_util::stream; -use std::path::PathBuf; -use std::sync::Arc; +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; + +/// Expected credentials for WebDAV Basic Auth validation +#[derive(Clone)] +pub struct WebdavCredentials { + pub username: String, + pub password: Option, +} + +/// Serializable dead property for disk persistence +#[derive(Serialize, Deserialize)] +struct DeadPropEntry { + name: String, + prefix: Option, + namespace: Option, + xml: Option>, +} + +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 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, root: PathBuf, upload_hook: Option>, user_uuid: String, + versioning: Option>, + props_data: Arc>>>, + props_path: PathBuf, } impl Clone for VfsDavFs { @@ -26,28 +77,150 @@ impl Clone for VfsDavFs { 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(), } } } impl VfsDavFs { + fn dead_props_path(root: &Path) -> PathBuf { + root.join(".webdav_props.json") + } + + fn load_props(vfs: &dyn VfsBackend, root: &Path) -> HashMap> { + 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> = 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> = data + .iter() + .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, root: PathBuf, upload_hook: Option>, user_uuid: String, ) -> Box { + 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, }) } - fn resolve_path(&self, path: &DavPath) -> PathBuf { - let relative = path.as_pathbuf(); - self.root.join(relative) + pub fn with_versioning( + vfs: Box, + root: PathBuf, + upload_hook: Option>, + user_uuid: String, + versioning: Arc, + ) -> Box { + 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, + }) + } + + 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 { + 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) } } @@ -115,10 +288,14 @@ impl DavDirEntry for VfsDavDirEntry { pub struct VfsDavFile { data: Vec, position: u64, + vfs_file: Option>>, + vfs: Option>, path: Option, upload_hook: Option>, user_uuid: String, is_write: bool, + versioning: Option>, + flushed: bool, } impl std::fmt::Debug for VfsDavFile { @@ -126,40 +303,84 @@ impl std::fmt::Debug for VfsDavFile { 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(data: Vec) -> Self { + pub fn new_read(vfs_file: Box) -> Self { Self { - data, + 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, } } pub fn new_write( path: PathBuf, + vfs: Box, upload_hook: Option>, user_uuid: String, + versioning: Option>, ) -> 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(|f| std::sync::Mutex::new(f)); Self { data: Vec::new(), position: 0, + vfs_file, + vfs: Some(vfs), path: Some(path), upload_hook, user_uuid, is_write: true, + versioning, + flushed: false, + } + } +} + +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> { + if let Some(vfs_file_mutex) = &self.vfs_file { + if let Ok(mut vfs_file) = vfs_file_mutex.lock() { + match vfs_file.stat() { + Ok(stat) => { + return Box::pin(std::future::ready(Ok( + Box::new(VfsDavMetaData::from_stat(&stat)) as Box, + ))); + } + Err(_) => {} + } + } + } let len = self.data.len() as u64; Box::pin(std::future::ready(Ok( Box::new(VfsDavMetaData::new(len, false)) as Box, @@ -167,6 +388,17 @@ impl DavFile for VfsDavFile { } fn write_buf(&'_ mut self, mut buf: Box) -> 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); @@ -176,81 +408,189 @@ impl DavFile for VfsDavFile { } 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> { - 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()))) + if let Some(vfs_file_mutex) = &self.vfs_file { + if let Ok(mut vfs_file) = vfs_file_mutex.lock() { + let mut buf = vec![0u8; count]; + match vfs_file.read(&mut buf) { + Ok(0) => return Box::pin(std::future::ready(Ok(Bytes::new()))), + Ok(n) => { + buf.truncate(n); + self.position += n as u64; + return Box::pin(std::future::ready(Ok(Bytes::from(buf)))); + } + Err(_) => {} + } + } + Box::pin(std::future::ready(Err(FsError::NotImplemented))) } else { - let bytes = Bytes::copy_from_slice(&self.data[start..end]); - self.position = end as u64; - Box::pin(std::future::ready(Ok(bytes))) + 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> { - let new_pos = match pos { - std::io::SeekFrom::Start(offset) => offset, - std::io::SeekFrom::Current(offset) => { - let current = self.position as i64; - (current + offset) as u64 + if let Some(vfs_file_mutex) = &self.vfs_file { + if let Ok(mut vfs_file) = vfs_file_mutex.lock() { + match vfs_file.seek(pos) { + Ok(new_pos) => { + self.position = new_pos; + return Box::pin(std::future::ready(Ok(new_pos))); + } + Err(_) => {} + } } - std::io::SeekFrom::End(offset) => { - let end = self.data.len() as i64; - (end + offset) as u64 - } - }; - self.position = new_pos; - 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 { - if let Some(path) = &self.path { - if let Err(_e) = std::fs::write(path, &self.data) { - return Box::pin(std::future::ready(Err(FsError::NotImplemented))); + if !self.is_write { + return Box::pin(std::future::ready(Ok(()))); + } + if self.flushed { + return Box::pin(std::future::ready(Ok(()))); + } + + // 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))); + } } - 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); + 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 { + b"".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()), + } + } +} + impl DavFileSystem for VfsDavFs { - fn open<'a>(&'a self, path: &'a DavPath, options: OpenOptions) -> FsFuture<'a, Box> { - let full_path = self.resolve_path(path); + fn open<'a>( + &'a self, + path: &'a DavPath, + options: OpenOptions, + ) -> FsFuture<'a, Box> { + let full_path = match self.resolve_path(path) { + Ok(p) => p, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; if options.write { 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))) } else { let flags = OpenFlags::new().read(); match self.vfs.open_file(&full_path, &flags) { - Ok(mut vfs_file) => { - let mut data = Vec::new(); - loop { - let mut buf = vec![0u8; 8192]; - match vfs_file.read(&mut buf) { - Ok(0) => break, - Ok(n) => data.extend_from_slice(&buf[..n]), - Err(_) => return Box::pin(std::future::ready(Err(FsError::NotFound))), - } - } - let file = VfsDavFile::new_read(data); + Ok(vfs_file) => { + let file = VfsDavFile::new_read(vfs_file); Box::pin(std::future::ready(Ok(Box::new(file) as Box))) } Err(_) => Box::pin(std::future::ready(Err(FsError::NotFound))), @@ -263,7 +603,10 @@ impl DavFileSystem for VfsDavFs { path: &'a DavPath, _meta: dav_server::fs::ReadDirMeta, ) -> FsFuture<'a, FsStream>> { - let full_path = self.resolve_path(path); + let full_path = match self.resolve_path(path) { + Ok(p) => p, + Err(e) => return Box::pin(std::future::ready(Err(e))), + }; match self.vfs.read_dir(&full_path) { Ok(entries) => { @@ -283,7 +626,10 @@ impl DavFileSystem for VfsDavFs { } fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box> { - let full_path = self.resolve_path(path); + 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) => { @@ -293,6 +639,293 @@ impl DavFileSystem for VfsDavFs { 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))), + }; + match self.vfs.create_dir_all(&full_path, 0o755) { + Ok(_) => Box::pin(std::future::ready(Ok(()))), + Err(_) => Box::pin(std::future::ready(Err(FsError::NotImplemented))), + } + } + + 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))), + }; + match self.vfs.remove_dir_all(&full_path) { + Ok(_) => Box::pin(std::future::ready(Ok(()))), + Err(_) => Box::pin(std::future::ready(Err(FsError::NotImplemented))), + } + } + + 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))), + }; + match self.vfs.remove_file(&full_path) { + Ok(_) => Box::pin(std::future::ready(Ok(()))), + Err(_) => Box::pin(std::future::ready(Err(FsError::NotImplemented))), + } + } + + 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))), + }; + 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(_) => Box::pin(std::future::ready(Err(FsError::NotImplemented))), + } + } + + 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(_) => Box::pin(std::future::ready(Err(FsError::NotImplemented))), + } + } + + 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(_) => Box::pin(std::future::ready(Err(FsError::NotImplemented))), + } + } + + fn get_props<'a>(&'a self, path: &'a DavPath, _do_content: bool) -> FsFuture<'a, Vec> { + 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> { + 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 mut map = match data.write() { + Ok(guard) => guard, + Err(e) => { + log::warn!("props_data RwLock poisoned in patch_props, recovering"); + e.into_inner() + } + }; + let results: Vec<(StatusCode, DavProp)> = 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(); + drop(map); + let map = match data.read() { + Ok(guard) => guard, + Err(e) => { + log::warn!("props_data RwLock poisoned in patch_props persistence, recovering"); + e.into_inner() + } + }; + let entries: HashMap> = map + .iter() + .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 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); + } + } + } + Ok(results) + }) + } + + fn have_props<'a>(&'a self, _path: &'a DavPath) -> Pin + Send + 'a>> { + Box::pin(std::future::ready(true)) + } + + fn get_quota(&'_ self) -> FsFuture<'_, (u64, Option)> { + let used = self.vfs.get_quota_usage(&self.root); + let total = self.vfs.get_quota(&self.root); + Box::pin(async move { + 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(FsError::NotImplemented), + } + }) + } + + 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( @@ -301,10 +934,547 @@ pub fn create_webdav_handler( upload_hook: Option>, user_uuid: String, ) -> dav_server::DavHandler { - let dav_fs = VfsDavFs::new(vfs, root, upload_hook, user_uuid); + create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, None, None) +} + +pub fn create_webdav_handler_with_versioning( + vfs: Box, + root: PathBuf, + upload_hook: Option>, + user_uuid: String, + versioning: Arc, +) -> dav_server::DavHandler { + create_webdav_handler_inner(vfs, root, upload_hook, user_uuid, Some(versioning), None) +} + +pub fn create_webdav_handler_persisted( + vfs: Box, + root: PathBuf, + upload_hook: Option>, + user_uuid: String, + versioning: Option>, + 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, + root: PathBuf, + upload_hook: Option>, + user_uuid: String, + versioning: Option>, + locks_file: Option, +) -> 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 = match locks_file { + Some(path) => PersistedLs::new(path), + None => MemLs::new(), + }; dav_server::DavHandler::builder() .filesystem(dav_fs) - .locksystem(FakeLs::new()) + .locksystem(locksystem) .strip_prefix("/webdav") .build_handler() -} \ No newline at end of file +} + +#[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"test.txt".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) -> (StatusCode, Vec) { + 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#" + + + + testuser +"#; + 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#" + + + + + + +"#; + 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"); + } +} diff --git a/markbase-core/src/webdav_locks.rs b/markbase-core/src/webdav_locks.rs new file mode 100644 index 0000000..16a3ae3 --- /dev/null +++ b/markbase-core/src/webdav_locks.rs @@ -0,0 +1,417 @@ +use dav_server::davpath::DavPath; +use dav_server::ls::{DavLock, DavLockSystem, LsFuture}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use uuid::Uuid; +use xmltree::Element; + +/// Serializable lock representation for JSON persistence +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedLock { + token: String, + path: String, + principal: Option, + owner_xml: Option, + timeout_at_epoch: Option, + timeout_secs: Option, + shared: bool, + deep: bool, +} + +impl PersistedLock { + fn into_lock(self) -> DavLock { + let path = DavPath::from_uri( + &self.path.parse::().unwrap_or_else(|_| "/unknown".parse().unwrap()), + ) + .unwrap_or_else(|_| { + DavPath::from_uri(&"/unknown".parse().unwrap()).unwrap() + }); + DavLock { + token: self.token, + path: Box::new(path), + principal: self.principal, + owner: None, + timeout_at: self + .timeout_at_epoch + .map(|secs| UNIX_EPOCH + Duration::from_secs(secs)), + timeout: self.timeout_secs.map(Duration::from_secs), + shared: self.shared, + deep: self.deep, + } + } +} + +impl From<&DavLock> for PersistedLock { + fn from(l: &DavLock) -> Self { + Self { + token: l.token.clone(), + path: l.path.to_string(), + principal: l.principal.clone(), + owner_xml: l.owner.as_ref().and_then(|e| { + let mut buf = Vec::new(); + e.write(&mut buf).ok().map(|_| String::from_utf8_lossy(&buf).to_string()) + }), + timeout_at_epoch: l + .timeout_at + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()), + timeout_secs: l.timeout.map(|d| d.as_secs()), + shared: l.shared, + deep: l.deep, + } + } +} + +/// Check if two paths overlap for locking purposes. +fn paths_overlap(lock_path: &str, request_path: &str, lock_deep: bool, request_deep: bool) -> bool { + let lp = lock_path.trim_end_matches('/'); + let rp = request_path.trim_end_matches('/'); + if lock_deep && request_deep { + lp == rp + || rp.starts_with(&format!("{}/", lp)) + || lp.starts_with(&format!("{}/", rp)) + } else if lock_deep { + lp == rp || rp.starts_with(&format!("{}/", lp)) + } else if request_deep { + lp == rp || lp.starts_with(&format!("{}/", rp)) + } else { + lp == rp + } +} + +fn is_expired(lock: &DavLock) -> bool { + if let Some(timeout_at) = lock.timeout_at { + timeout_at < SystemTime::now() + } else { + false + } +} + +fn cleanup_expired_locks(locks: &mut Vec, locks_file: &PathBuf) { + let before = locks.len(); + locks.retain(|l| !is_expired(l)); + if locks.len() < before { + let persisted: Vec = locks.iter().map(PersistedLock::from).collect(); + if let Ok(json) = serde_json::to_string(&persisted) { + let _ = std::fs::write(locks_file, json); + } + } +} + +#[derive(Debug, Clone)] +pub struct PersistedLs { + locks: Arc>>, + locks_file: PathBuf, +} + +impl PersistedLs { + pub fn new(locks_file: PathBuf) -> Box { + let locks = if locks_file.exists() { + std::fs::read_to_string(&locks_file) + .ok() + .and_then(|json| serde_json::from_str::>(&json).ok()) + .map(|v| v.into_iter().map(|p| p.into_lock()).collect()) + .unwrap_or_default() + } else { + Vec::new() + }; + + Box::new(Self { + locks: Arc::new(Mutex::new(locks)), + locks_file, + }) + } + +} + +impl DavLockSystem for PersistedLs { + fn lock( + &'_ self, + path: &DavPath, + principal: Option<&str>, + owner: Option<&Element>, + timeout: Option, + shared: bool, + deep: bool, + ) -> LsFuture<'_, Result> { + let locks = self.locks.clone(); + let path2 = path.clone(); + let locks_file = self.locks_file.clone(); + let principal_owned = principal.map(|s| s.to_string()); + let owner_owned = owner.map(|o| Box::new(o.clone())); + Box::pin(async move { + let mut all = locks.lock().unwrap(); + cleanup_expired_locks(&mut all, &locks_file); + let path_str = path2.to_string(); + for existing in all.iter() { + let ep = existing.path.to_string(); + if paths_overlap(&ep, &path_str, existing.deep, deep) { + let owned = existing.principal.as_deref() == principal_owned.as_deref(); + if !owned && !existing.shared { + return Err(existing.clone()); + } + if !shared && !owned { + return Err(existing.clone()); + } + } + } + + let timeout_at = timeout.map(|d| SystemTime::now() + d); + let lock = DavLock { + token: Uuid::new_v4().urn().to_string(), + path: Box::new(path2), + principal: principal_owned, + owner: owner_owned, + timeout_at, + timeout, + shared, + deep, + }; + all.push(lock.clone()); + + let persisted: Vec = all.iter().map(PersistedLock::from).collect(); + if let Ok(json) = serde_json::to_string(&persisted) { + let _ = std::fs::write(&locks_file, json); + } + + Ok(lock) + }) + } + + fn unlock(&'_ self, path: &DavPath, token: &str) -> LsFuture<'_, Result<(), ()>> { + let locks = self.locks.clone(); + let path_str = path.to_string(); + let locks_file = self.locks_file.clone(); + let token_owned = token.to_string(); + Box::pin(async move { + let mut all = locks.lock().unwrap(); + let before = all.len(); + all.retain(|l| !(l.path.to_string() == path_str && l.token == token_owned)); + if all.len() == before { + return Err(()); + } + + let persisted: Vec = all.iter().map(PersistedLock::from).collect(); + if let Ok(json) = serde_json::to_string(&persisted) { + let _ = std::fs::write(&locks_file, json); + } + + Ok(()) + }) + } + + fn refresh( + &'_ self, + path: &DavPath, + token: &str, + timeout: Option, + ) -> LsFuture<'_, Result> { + let locks = self.locks.clone(); + let path_str = path.to_string(); + let token_owned = token.to_string(); + let locks_file = self.locks_file.clone(); + Box::pin(async move { + let mut all = locks.lock().unwrap(); + let existing = all.iter_mut().find(|l| l.path.to_string() == path_str && l.token == token_owned); + match existing { + Some(lock) => { + lock.timeout_at = timeout.map(|d| SystemTime::now() + d); + lock.timeout = timeout; + let result = lock.clone(); + + let persisted: Vec = all.iter().map(PersistedLock::from).collect(); + if let Ok(json) = serde_json::to_string(&persisted) { + let _ = std::fs::write(&locks_file, json); + } + + Ok(result) + } + None => Err(()), + } + }) + } + + fn check( + &'_ self, + path: &DavPath, + principal: Option<&str>, + ignore_principal: bool, + deep: bool, + submitted_tokens: &[String], + ) -> LsFuture<'_, Result<(), DavLock>> { + let locks = self.locks.clone(); + let path_str = path.to_string(); + let principal_owned = principal.map(|s| s.to_string()); + let submitted = submitted_tokens.to_vec(); + let locks_file = self.locks_file.clone(); + Box::pin(async move { + let mut all = locks.lock().unwrap(); + cleanup_expired_locks(&mut all, &locks_file); + for existing in all.iter() { + let ep = existing.path.to_string(); + if !paths_overlap(&ep, &path_str, existing.deep, deep) { + continue; + } + let owned = submitted.iter().any(|t| t == &existing.token) + || (ignore_principal && existing.principal.as_deref() == principal_owned.as_deref()); + if !owned && !existing.shared { + return Err(existing.clone()); + } + } + Ok(()) + }) + } + + fn discover(&'_ self, path: &DavPath) -> LsFuture<'_, Vec> { + let locks = self.locks.clone(); + let path_str = path.to_string(); + let locks_file = self.locks_file.clone(); + Box::pin(async move { + let mut all = locks.lock().unwrap(); + cleanup_expired_locks(&mut all, &locks_file); + let mut result: Vec = all + .iter() + .filter(|l| { + let lp = l.path.to_string(); + paths_overlap(&lp, &path_str, l.deep, false) + }) + .cloned() + .collect(); + result.sort_by(|a, b| a.token.cmp(&b.token)); + result + }) + } + + fn delete(&'_ self, path: &DavPath) -> LsFuture<'_, Result<(), ()>> { + let locks = self.locks.clone(); + let prefix = path.to_string().trim_end_matches('/').to_string(); + let locks_file = self.locks_file.clone(); + Box::pin(async move { + let mut all = locks.lock().unwrap(); + let before = all.len(); + all.retain(|l| { + let lp = l.path.to_string().trim_end_matches('/').to_string(); + !(lp == prefix || lp.starts_with(&format!("{}/", prefix))) + }); + if all.len() < before { + let persisted: Vec = all.iter().map(PersistedLock::from).collect(); + if let Ok(json) = serde_json::to_string(&persisted) { + let _ = std::fs::write(&locks_file, json); + } + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dav_server::davpath::DavPath; + use tempfile::TempDir; + + fn path(p: &str) -> Box { + Box::new( + DavPath::from_uri(&p.parse::().unwrap()).unwrap(), + ) + } + + #[test] + fn test_lock_and_unlock() { + let dir = TempDir::new().unwrap(); + let ls = PersistedLs::new(dir.path().join("locks.json")); + + let dpath = path("/test.txt"); + let result = rt( + ls.lock(&dpath, Some("user"), None, Some(Duration::from_secs(3600)), false, false), + ); + assert!(result.is_ok()); + let lock = result.unwrap(); + assert_eq!(lock.shared, false); + assert_eq!(lock.deep, false); + + let result = rt(ls.unlock(&dpath, &lock.token)); + assert!(result.is_ok()); + } + + #[test] + fn test_exclusive_conflict() { + let dir = TempDir::new().unwrap(); + let ls = PersistedLs::new(dir.path().join("locks.json")); + + let dpath = path("/test.txt"); + let r1 = rt( + ls.lock(&dpath, Some("alice"), None, None, false, false), + ); + assert!(r1.is_ok()); + + let r2 = rt( + ls.lock(&dpath, Some("bob"), None, None, false, false), + ); + assert!(r2.is_err()); + } + + #[test] + fn test_shared_lock_no_conflict() { + let dir = TempDir::new().unwrap(); + let ls = PersistedLs::new(dir.path().join("locks.json")); + + let dpath = path("/test.txt"); + let r1 = rt( + ls.lock(&dpath, Some("alice"), None, None, true, false), + ); + assert!(r1.is_ok()); + + let r2 = rt( + ls.lock(&dpath, Some("bob"), None, None, true, false), + ); + assert!(r2.is_ok()); + } + + #[test] + fn test_persistence() { + let dir = TempDir::new().unwrap(); + let locks_file = dir.path().join("locks.json"); + + let lock_token; + { + let ls = PersistedLs::new(locks_file.clone()); + let dpath = path("/test.txt"); + let result = rt( + ls.lock(&dpath, Some("user"), None, Some(Duration::from_secs(3600)), false, false), + ); + assert!(result.is_ok()); + lock_token = result.unwrap().token; + } + + let ls2 = PersistedLs::new(locks_file.clone()); + let dpath = path("/test.txt"); + let discovered = rt(ls2.discover(&dpath)); + assert_eq!(discovered.len(), 1); + assert_eq!(discovered[0].token, lock_token); + } + + #[test] + fn test_deep_lock_conflict() { + let dir = TempDir::new().unwrap(); + let ls = PersistedLs::new(dir.path().join("locks.json")); + + let parent = path("/docs"); + let r1 = rt( + ls.lock(&parent, Some("alice"), None, None, true, true), + ); + assert!(r1.is_ok()); + + let child = path("/docs/sub/file.txt"); + let r2 = rt( + ls.lock(&child, Some("bob"), None, None, false, false), + ); + assert!(r2.is_err()); + } + + fn rt(fut: LsFuture<'_, T>) -> T { + tokio::runtime::Runtime::new().unwrap().block_on(fut) + } +} diff --git a/markbase-core/src/webdav_version.rs b/markbase-core/src/webdav_version.rs index 1c770b8..ef5b650 100644 --- a/markbase-core/src/webdav_version.rs +++ b/markbase-core/src/webdav_version.rs @@ -28,11 +28,31 @@ pub struct VersionHistory { pub struct WebDavVersioning { db: Arc>>>, version_storage: PathBuf, + index_path: PathBuf, } impl WebDavVersioning { - pub fn new(db: Arc>>>, version_storage: PathBuf) -> Self { - Self { db, version_storage } + pub fn new(version_storage: PathBuf) -> Self { + let index_path = version_storage.join("version_index.json"); + let db = Arc::new(RwLock::new(HashMap::new())); + + // Load persisted index from disk + if index_path.exists() { + if let Ok(json) = std::fs::read_to_string(&index_path) { + if let Ok(map) = serde_json::from_str::>>(&json) { + *db.write().unwrap() = map; + } + } + } + + Self { db, version_storage, index_path } + } + + fn save_index(&self) -> Result<(), VersionError> { + let db = self.db.read().unwrap(); + let json = serde_json::to_string(&*db)?; + std::fs::write(&self.index_path, json)?; + Ok(()) } pub fn create_version( @@ -71,9 +91,10 @@ impl WebDavVersioning { let value = serde_json::to_vec(&version_info)?; self.db.write().unwrap().insert(key, value); - let history_key = Self::history_key(file_path); self.update_version_history(file_path, &version_id)?; + self.save_index()?; + Ok(version_info) } @@ -144,6 +165,8 @@ impl WebDavVersioning { self.update_version_history(file_path, &new_version_id)?; + self.save_index()?; + Ok(new_version_info) } @@ -165,6 +188,8 @@ impl WebDavVersioning { let current = self.get_current_version(file_path)?; self.update_version_history(file_path, ¤t.version_id)?; + self.save_index()?; + Ok(()) } @@ -249,14 +274,12 @@ impl From for VersionError { #[cfg(test)] mod tests { use super::*; - use std::sync::Arc; use tempfile::TempDir; fn setup_versioning() -> (WebDavVersioning, TempDir) { let version_dir = TempDir::new().unwrap(); - let db = Arc::new(RwLock::new(HashMap::new())); - let versioning = WebDavVersioning::new(db, version_dir.path().to_path_buf()); + let versioning = WebDavVersioning::new(version_dir.path().to_path_buf()); (versioning, version_dir) }