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
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2890,6 +2890,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
|
"http",
|
||||||
"log",
|
"log",
|
||||||
"md5 0.8.0",
|
"md5 0.8.0",
|
||||||
"nix 0.29.0",
|
"nix 0.29.0",
|
||||||
@@ -2926,6 +2927,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
|
"xmltree",
|
||||||
"xz2",
|
"xz2",
|
||||||
"zip",
|
"zip",
|
||||||
"zstd 0.13.3",
|
"zstd 0.13.3",
|
||||||
|
|||||||
@@ -46,11 +46,13 @@ ssh2 = "0.9.4"
|
|||||||
ssh-key = "0.7.0-rc.10"
|
ssh-key = "0.7.0-rc.10"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
axum-extra = { version = "0.9", features = ["multipart"] }
|
axum-extra = { version = "0.9", features = ["multipart"] }
|
||||||
|
http = "1"
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
xmltree = "0.12"
|
||||||
dashmap = "6.1"
|
dashmap = "6.1"
|
||||||
md5 = "0.8"
|
md5 = "0.8"
|
||||||
adler = "1.0"
|
adler = "1.0"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub mod ssh_server;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod vfs;
|
pub mod vfs;
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
|
pub mod webdav_locks;
|
||||||
pub mod webdav_version;
|
pub mod webdav_version;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
body::Body,
|
||||||
extract::DefaultBodyLimit,
|
extract::DefaultBodyLimit,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, HeaderValue, StatusCode},
|
||||||
response::{Html, IntoResponse, Json},
|
response::{Html, IntoResponse, Json},
|
||||||
routing::{any, delete, get, patch, post, put},
|
routing::{any, delete, get, patch, post, put},
|
||||||
Extension,
|
Extension,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use dav_server::{fakels::FakeLs, DavHandler};
|
use base64::Engine as _;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, LazyLock, Mutex, RwLock};
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
|
||||||
use crate::archive::{
|
use crate::archive::{
|
||||||
ArchiveConfig, ArchiveFormat, ArchiveProcessor, FormatDetector, ProcessorRegistry,
|
ArchiveConfig, ArchiveFormat, ArchiveProcessor, FormatDetector, ProcessorRegistry,
|
||||||
@@ -134,25 +137,41 @@ pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// WebDAV handler creation (Phase 20)
|
// ===== WebDAV multi-user configuration (Phase 20 + P1) =====
|
||||||
let webdav_user = "demo";
|
let webdav_parent = std::path::PathBuf::from(
|
||||||
let webdav_home = std::path::PathBuf::from("/Users/accusys/momentry/var/sftpgo/data").join(webdav_user);
|
std::env::var("MB_WEBDAV_PARENT")
|
||||||
|
.unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data".to_string()),
|
||||||
let webdav_vfs = Box::new(crate::vfs::local_fs::LocalFs::new());
|
);
|
||||||
let webdav_fs = crate::webdav::VfsDavFs::new(
|
|
||||||
webdav_vfs,
|
// WebDAV versioning storage
|
||||||
webdav_home,
|
let version_storage = std::path::PathBuf::from("data/webdav_versions");
|
||||||
None, // upload_hook
|
std::fs::create_dir_all(&version_storage).ok();
|
||||||
webdav_user.to_string(),
|
|
||||||
|
// 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()
|
let app = Router::new()
|
||||||
.route("/", get(root_handler))
|
.route("/", get(root_handler))
|
||||||
@@ -256,11 +275,15 @@ pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
|
|||||||
.route("/files", get(|| async { Html(include_str!("file_list.html")) }))
|
.route("/files", get(|| async { Html(include_str!("file_list.html")) }))
|
||||||
.route("/products", get(|| async { Html(include_str!("product_manager.html")) }))
|
.route("/products", get(|| async { Html(include_str!("product_manager.html")) }))
|
||||||
.route("/downloads", get(|| async { Html(include_str!("category_view.html")) }))
|
.route("/downloads", get(|| async { Html(include_str!("category_view.html")) }))
|
||||||
// WebDAV API endpoints (Phase 20)
|
// WebDAV API endpoints (Phase 20, multi-user P1)
|
||||||
.route("/webdav", any(handle_webdav))
|
.route("/webdav", any(handle_webdav_multi))
|
||||||
.route("/webdav/", any(handle_webdav))
|
.route("/webdav/", any(handle_webdav_multi))
|
||||||
.route("/webdav/*path", any(handle_webdav))
|
.route("/webdav/*path", any(handle_webdav_multi))
|
||||||
.layer(Extension(webdav_handler))
|
.layer(Extension(webdav_parent))
|
||||||
|
.layer(Extension(upload_hook))
|
||||||
|
.layer(Extension(webdav_versioning))
|
||||||
|
.layer(Extension(use_s3))
|
||||||
|
.layer(Extension(s3_cfg))
|
||||||
.layer(DefaultBodyLimit::disable())
|
.layer(DefaultBodyLimit::disable())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
@@ -2445,11 +2468,102 @@ async fn search_files_handler(Query(query): Query<SearchQuery>) -> impl IntoResp
|
|||||||
.into_response(),
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ===== WebDAV multi-user handler (Phase 20 + P1 multi-user) =====
|
||||||
|
|
||||||
// WebDAV handler (Phase 20)
|
static WEBDAV_HANDLER_CACHE: LazyLock<DashMap<String, dav_server::DavHandler>> =
|
||||||
async fn handle_webdav(
|
LazyLock::new(|| DashMap::new());
|
||||||
Extension(dav): Extension<DavHandler>,
|
|
||||||
|
async fn handle_webdav_multi(
|
||||||
|
Extension(parent): Extension<std::path::PathBuf>,
|
||||||
|
Extension(upload_hook): Extension<Arc<crate::ssh_server::upload_hook::UploadHook>>,
|
||||||
|
Extension(versioning): Extension<Arc<crate::webdav_version::WebDavVersioning>>,
|
||||||
|
Extension(use_s3): Extension<bool>,
|
||||||
|
Extension(s3_cfg): Extension<crate::s3_config::S3Config>,
|
||||||
req: axum::extract::Request,
|
req: axum::extract::Request,
|
||||||
) -> impl IntoResponse {
|
) -> axum::response::Response {
|
||||||
dav.handle(req).await
|
// 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<dyn crate::vfs::VfsBackend> = 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ impl VfsBackend for LocalFs {
|
|||||||
fs::remove_dir(path).map_err(|e| util::map_io_error(path, e))
|
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> {
|
fn remove_file(&self, path: &Path) -> Result<(), VfsError> {
|
||||||
fs::remove_file(path).map_err(|e| util::map_io_error(path, e))
|
fs::remove_file(path).map_err(|e| util::map_io_error(path, e))
|
||||||
}
|
}
|
||||||
@@ -185,6 +189,39 @@ impl VfsBackend for LocalFs {
|
|||||||
Ok(())
|
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<PathBuf, VfsError> {
|
fn read_link(&self, path: &Path) -> Result<PathBuf, VfsError> {
|
||||||
let target = fs::read_link(path).map_err(|e| util::map_io_error(path, e))?;
|
let target = fs::read_link(path).map_err(|e| util::map_io_error(path, e))?;
|
||||||
Ok(target)
|
Ok(target)
|
||||||
@@ -232,6 +269,15 @@ impl VfsBackend for LocalFs {
|
|||||||
Ok(())
|
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 =====
|
// ===== Snapshot support =====
|
||||||
|
|
||||||
fn create_snapshot(&self, path: &Path, name: &str) -> Result<(), VfsError> {
|
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);
|
let snapshot_path = snapshot_dir.join(name);
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
self.copy_dir_recursive(path, &snapshot_path)?;
|
copy_dir_recursive_impl(path, &snapshot_path)?;
|
||||||
} else {
|
} else {
|
||||||
fs::copy(path, &snapshot_path).map_err(|e| util::map_io_error(path, e))?;
|
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() {
|
if snapshot_path.is_dir() {
|
||||||
self.copy_dir_recursive(&snapshot_path, path)?;
|
copy_dir_recursive_impl(&snapshot_path, path)?;
|
||||||
} else {
|
} else {
|
||||||
fs::copy(&snapshot_path, path).map_err(|e| util::map_io_error(&snapshot_path, e))?;
|
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 {
|
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<u64, VfsError> {
|
fn calculate_size(&self, path: &Path) -> Result<u64, VfsError> {
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
let mut total = 0;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -148,6 +148,21 @@ pub trait VfsBackend: Send + Sync {
|
|||||||
/// 删除空目录
|
/// 删除空目录
|
||||||
fn remove_dir(&self, path: &Path) -> Result<(), VfsError>;
|
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>;
|
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>;
|
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<PathBuf, VfsError>;
|
fn read_link(&self, path: &Path) -> Result<PathBuf, VfsError>;
|
||||||
|
|
||||||
@@ -172,6 +209,24 @@ pub trait VfsBackend: Send + Sync {
|
|||||||
/// 创建硬链接
|
/// 创建硬链接
|
||||||
fn hard_link(&self, original: &Path, link: &Path) -> Result<(), VfsError>;
|
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) =====
|
// ===== Snapshot support (ZFS-style) =====
|
||||||
|
|
||||||
/// 创建快照
|
/// 创建快照
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
417
markbase-core/src/webdav_locks.rs
Normal file
417
markbase-core/src/webdav_locks.rs
Normal file
@@ -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<String>,
|
||||||
|
owner_xml: Option<String>,
|
||||||
|
timeout_at_epoch: Option<u64>,
|
||||||
|
timeout_secs: Option<u64>,
|
||||||
|
shared: bool,
|
||||||
|
deep: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistedLock {
|
||||||
|
fn into_lock(self) -> DavLock {
|
||||||
|
let path = DavPath::from_uri(
|
||||||
|
&self.path.parse::<http::Uri>().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<DavLock>, locks_file: &PathBuf) {
|
||||||
|
let before = locks.len();
|
||||||
|
locks.retain(|l| !is_expired(l));
|
||||||
|
if locks.len() < before {
|
||||||
|
let persisted: Vec<PersistedLock> = 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<Mutex<Vec<DavLock>>>,
|
||||||
|
locks_file: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistedLs {
|
||||||
|
pub fn new(locks_file: PathBuf) -> Box<Self> {
|
||||||
|
let locks = if locks_file.exists() {
|
||||||
|
std::fs::read_to_string(&locks_file)
|
||||||
|
.ok()
|
||||||
|
.and_then(|json| serde_json::from_str::<Vec<PersistedLock>>(&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<Duration>,
|
||||||
|
shared: bool,
|
||||||
|
deep: bool,
|
||||||
|
) -> LsFuture<'_, Result<DavLock, DavLock>> {
|
||||||
|
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<PersistedLock> = 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<PersistedLock> = 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<Duration>,
|
||||||
|
) -> LsFuture<'_, Result<DavLock, ()>> {
|
||||||
|
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<PersistedLock> = 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<DavLock>> {
|
||||||
|
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<DavLock> = 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<PersistedLock> = 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<DavPath> {
|
||||||
|
Box::new(
|
||||||
|
DavPath::from_uri(&p.parse::<http::Uri>().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<T>(fut: LsFuture<'_, T>) -> T {
|
||||||
|
tokio::runtime::Runtime::new().unwrap().block_on(fut)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,31 @@ pub struct VersionHistory {
|
|||||||
pub struct WebDavVersioning {
|
pub struct WebDavVersioning {
|
||||||
db: Arc<RwLock<HashMap<String, Vec<u8>>>>,
|
db: Arc<RwLock<HashMap<String, Vec<u8>>>>,
|
||||||
version_storage: PathBuf,
|
version_storage: PathBuf,
|
||||||
|
index_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebDavVersioning {
|
impl WebDavVersioning {
|
||||||
pub fn new(db: Arc<RwLock<HashMap<String, Vec<u8>>>>, version_storage: PathBuf) -> Self {
|
pub fn new(version_storage: PathBuf) -> Self {
|
||||||
Self { db, version_storage }
|
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::<HashMap<String, Vec<u8>>>(&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(
|
pub fn create_version(
|
||||||
@@ -71,9 +91,10 @@ impl WebDavVersioning {
|
|||||||
let value = serde_json::to_vec(&version_info)?;
|
let value = serde_json::to_vec(&version_info)?;
|
||||||
self.db.write().unwrap().insert(key, value);
|
self.db.write().unwrap().insert(key, value);
|
||||||
|
|
||||||
let history_key = Self::history_key(file_path);
|
|
||||||
self.update_version_history(file_path, &version_id)?;
|
self.update_version_history(file_path, &version_id)?;
|
||||||
|
|
||||||
|
self.save_index()?;
|
||||||
|
|
||||||
Ok(version_info)
|
Ok(version_info)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +165,8 @@ impl WebDavVersioning {
|
|||||||
|
|
||||||
self.update_version_history(file_path, &new_version_id)?;
|
self.update_version_history(file_path, &new_version_id)?;
|
||||||
|
|
||||||
|
self.save_index()?;
|
||||||
|
|
||||||
Ok(new_version_info)
|
Ok(new_version_info)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +188,8 @@ impl WebDavVersioning {
|
|||||||
let current = self.get_current_version(file_path)?;
|
let current = self.get_current_version(file_path)?;
|
||||||
self.update_version_history(file_path, ¤t.version_id)?;
|
self.update_version_history(file_path, ¤t.version_id)?;
|
||||||
|
|
||||||
|
self.save_index()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,14 +274,12 @@ impl From<serde_json::Error> for VersionError {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Arc;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup_versioning() -> (WebDavVersioning, TempDir) {
|
fn setup_versioning() -> (WebDavVersioning, TempDir) {
|
||||||
let version_dir = TempDir::new().unwrap();
|
let version_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
let db = Arc::new(RwLock::new(HashMap::new()));
|
let versioning = WebDavVersioning::new(version_dir.path().to_path_buf());
|
||||||
let versioning = WebDavVersioning::new(db, version_dir.path().to_path_buf());
|
|
||||||
|
|
||||||
(versioning, version_dir)
|
(versioning, version_dir)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user