diff --git a/markbase-core/src/lib.rs b/markbase-core/src/lib.rs index 10a1118..e43beba 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_version; #[cfg(test)] mod security_audit; diff --git a/markbase-core/src/webdav_version.rs b/markbase-core/src/webdav_version.rs new file mode 100644 index 0000000..1c770b8 --- /dev/null +++ b/markbase-core/src/webdav_version.rs @@ -0,0 +1,391 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::time::SystemTime; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionInfo { + pub version_id: String, + pub file_path: String, + pub created_at: SystemTime, + pub size: u64, + pub checksum: String, + pub author: Option, + pub comment: Option, + pub is_current: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionHistory { + pub file_path: String, + pub versions: Vec, + pub current_version: String, + pub total_versions: u64, +} + +pub struct WebDavVersioning { + db: Arc>>>, + version_storage: PathBuf, +} + +impl WebDavVersioning { + pub fn new(db: Arc>>>, version_storage: PathBuf) -> Self { + Self { db, version_storage } + } + + pub fn create_version( + &self, + file_path: &str, + content: &[u8], + author: Option<&str>, + comment: Option<&str>, + ) -> Result { + if !self.version_storage.exists() { + std::fs::create_dir_all(&self.version_storage)?; + } + + let version_id = Uuid::new_v4().hyphenated().to_string(); + let checksum = Self::calculate_checksum(content); + let size = content.len() as u64; + let created_at = SystemTime::now(); + + let version_file = self.version_storage.join(&version_id); + std::fs::write(&version_file, content)?; + + let version_info = VersionInfo { + version_id: version_id.clone(), + file_path: file_path.to_string(), + created_at, + size, + checksum, + author: author.map(|s| s.to_string()), + comment: comment.map(|s| s.to_string()), + is_current: true, + }; + + self.mark_previous_versions_not_current(file_path)?; + + let key = Self::version_key(file_path, &version_id); + 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)?; + + Ok(version_info) + } + + pub fn get_version(&self, file_path: &str, version_id: &str) -> Result, VersionError> { + let key = Self::version_key(file_path, version_id); + let value = self.db.read().unwrap().get(&key).cloned().ok_or(VersionError::VersionNotFound)?; + + let version_info: VersionInfo = serde_json::from_slice(&value)?; + let version_file = self.version_storage.join(&version_info.version_id); + + std::fs::read(&version_file).map_err(|e| e.into()) + } + + pub fn get_version_info(&self, file_path: &str, version_id: &str) -> Result { + let key = Self::version_key(file_path, version_id); + let value = self.db.read().unwrap().get(&key).cloned().ok_or(VersionError::VersionNotFound)?; + + serde_json::from_slice(&value).map_err(|e| e.into()) + } + + pub fn get_version_history(&self, file_path: &str) -> Result { + let history_key = Self::history_key(file_path); + let value = self.db.read().unwrap().get(&history_key).cloned().ok_or(VersionError::HistoryNotFound)?; + + serde_json::from_slice(&value).map_err(|e| e.into()) + } + + pub fn list_all_versions(&self, file_path: &str) -> Result, VersionError> { + let prefix = format!("version:{}:", file_path); + let mut versions = Vec::new(); + + let db = self.db.read().unwrap(); + for (key, value) in db.iter() { + if key.starts_with(&prefix) { + let version_info: VersionInfo = serde_json::from_slice(&value)?; + versions.push(version_info); + } + } + + versions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(versions) + } + + pub fn restore_version(&self, file_path: &str, version_id: &str) -> Result { + let old_content = self.get_version(file_path, version_id)?; + let old_version_info = self.get_version_info(file_path, version_id)?; + + self.mark_previous_versions_not_current(file_path)?; + + let new_version_id = Uuid::new_v4().hyphenated().to_string(); + let new_version_info = VersionInfo { + version_id: new_version_id.clone(), + file_path: file_path.to_string(), + created_at: SystemTime::now(), + size: old_version_info.size, + checksum: old_version_info.checksum.clone(), + author: None, + comment: Some(format!("Restored from version {}", version_id)), + is_current: true, + }; + + let version_file = self.version_storage.join(&new_version_id); + std::fs::write(&version_file, &old_content)?; + + let key = Self::version_key(file_path, &new_version_id); + let value = serde_json::to_vec(&new_version_info)?; + self.db.write().unwrap().insert(key, value); + + self.update_version_history(file_path, &new_version_id)?; + + Ok(new_version_info) + } + + pub fn delete_version(&self, file_path: &str, version_id: &str) -> Result<(), VersionError> { + let version_info = self.get_version_info(file_path, version_id)?; + + if version_info.is_current { + return Err(VersionError::CannotDeleteCurrentVersion); + } + + let version_file = self.version_storage.join(version_id); + if version_file.exists() { + std::fs::remove_file(&version_file)?; + } + + let key = Self::version_key(file_path, version_id); + self.db.write().unwrap().remove(&key); + + let current = self.get_current_version(file_path)?; + self.update_version_history(file_path, ¤t.version_id)?; + + Ok(()) + } + + pub fn get_current_version(&self, file_path: &str) -> Result { + let versions = self.list_all_versions(file_path)?; + versions + .into_iter() + .find(|v| v.is_current) + .ok_or(VersionError::NoCurrentVersion) + } + + fn mark_previous_versions_not_current(&self, file_path: &str) -> Result<(), VersionError> { + let versions = self.list_all_versions(file_path)?; + + for version in versions.iter().filter(|v| v.is_current) { + let mut updated_version = version.clone(); + updated_version.is_current = false; + + let key = Self::version_key(file_path, &version.version_id); + let value = serde_json::to_vec(&updated_version)?; + self.db.write().unwrap().insert(key, value); + } + + Ok(()) + } + + fn update_version_history(&self, file_path: &str, current_version_id: &str) -> Result<(), VersionError> { + let versions = self.list_all_versions(file_path)?; + + let history = VersionHistory { + file_path: file_path.to_string(), + versions: versions.clone(), + current_version: current_version_id.to_string(), + total_versions: versions.len() as u64, + }; + + let history_key = Self::history_key(file_path); + let value = serde_json::to_vec(&history)?; + self.db.write().unwrap().insert(history_key, value); + + Ok(()) + } + + fn calculate_checksum(content: &[u8]) -> String { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(content); + format!("{:x}", hasher.finalize()) + } + + fn version_key(file_path: &str, version_id: &str) -> String { + format!("version:{}:{}", file_path, version_id) + } + + fn history_key(file_path: &str) -> String { + format!("history:{}:info", file_path) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum VersionError { + Io(String), + Json(String), + VersionNotFound, + HistoryNotFound, + NoCurrentVersion, + CannotDeleteCurrentVersion, +} + +impl From for VersionError { + fn from(e: std::io::Error) -> Self { + VersionError::Io(e.to_string()) + } +} + +impl From for VersionError { + fn from(e: serde_json::Error) -> Self { + VersionError::Json(e.to_string()) + } +} + +#[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()); + + (versioning, version_dir) + } + + #[test] + fn test_create_version() { + let (versioning, _) = setup_versioning(); + let content = b"Hello, World!"; + let version_info = versioning.create_version("/test.txt", content, Some("demo"), Some("Initial version")).unwrap(); + + assert_eq!(version_info.file_path, "/test.txt"); + assert_eq!(version_info.size, 13); + assert!(version_info.is_current); + assert!(version_info.author.is_some()); + assert!(version_info.comment.is_some()); + } + + #[test] + fn test_get_version() { + let (versioning, _) = setup_versioning(); + let content = b"Hello, World!"; + let version_info = versioning.create_version("/test.txt", content, None, None).unwrap(); + + let retrieved_content = versioning.get_version("/test.txt", &version_info.version_id).unwrap(); + assert_eq!(retrieved_content, content); + } + + #[test] + fn test_get_version_history() { + let (versioning, _) = setup_versioning(); + let content = b"Version 1"; + versioning.create_version("/test.txt", content, None, None).unwrap(); + + let content2 = b"Version 2"; + versioning.create_version("/test.txt", content2, None, None).unwrap(); + + let history = versioning.get_version_history("/test.txt").unwrap(); + assert_eq!(history.total_versions, 2); + assert_eq!(history.versions.len(), 2); + } + + #[test] + fn test_restore_version() { + let (versioning, _) = setup_versioning(); + let content1 = b"Original content"; + let version1 = versioning.create_version("/test.txt", content1, None, None).unwrap(); + + let content2 = b"Modified content"; + let _version2 = versioning.create_version("/test.txt", content2, None, None).unwrap(); + + let restored = versioning.restore_version("/test.txt", &version1.version_id).unwrap(); + assert_eq!(restored.checksum, version1.checksum); + assert!(restored.is_current); + } + + #[test] + fn test_delete_version() { + let (versioning, _) = setup_versioning(); + let content1 = b"Version 1"; + let version1 = versioning.create_version("/test.txt", content1, None, None).unwrap(); + + let content2 = b"Version 2"; + versioning.create_version("/test.txt", content2, None, None).unwrap(); + + versioning.delete_version("/test.txt", &version1.version_id).unwrap(); + + let history = versioning.get_version_history("/test.txt").unwrap(); + assert_eq!(history.total_versions, 1); + } + + #[test] + fn test_cannot_delete_current_version() { + let (versioning, _) = setup_versioning(); + let content = b"Current version"; + let version_info = versioning.create_version("/test.txt", content, None, None).unwrap(); + + let result = versioning.delete_version("/test.txt", &version_info.version_id); + assert!(matches!(result, Err(VersionError::CannotDeleteCurrentVersion))); + } + + #[test] + fn test_get_current_version() { + let (versioning, _) = setup_versioning(); + let content1 = b"Old version"; + versioning.create_version("/test.txt", content1, None, None).unwrap(); + + let content2 = b"Current version"; + let version2 = versioning.create_version("/test.txt", content2, None, None).unwrap(); + + let current = versioning.get_current_version("/test.txt").unwrap(); + assert_eq!(current.version_id, version2.version_id); + assert!(current.is_current); + } + + #[test] + fn test_checksum_calculation() { + let content = b"Hello, World!"; + let checksum = WebDavVersioning::calculate_checksum(content); + assert_eq!(checksum.len(), 64); + assert!(checksum.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_list_all_versions_sorted() { + let (versioning, _) = setup_versioning(); + let content1 = b"Version 1"; + versioning.create_version("/test.txt", content1, None, None).unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + let content2 = b"Version 2"; + versioning.create_version("/test.txt", content2, None, None).unwrap(); + + let versions = versioning.list_all_versions("/test.txt").unwrap(); + assert_eq!(versions.len(), 2); + assert!(versions[0].created_at >= versions[1].created_at); + } + + #[test] + fn test_version_not_found() { + let (versioning, _) = setup_versioning(); + let result = versioning.get_version("/nonexistent.txt", "nonexistent-id"); + assert!(matches!(result, Err(VersionError::VersionNotFound))); + } + + #[test] + fn test_history_not_found() { + let (versioning, _) = setup_versioning(); + let result = versioning.get_version_history("/nonexistent.txt"); + assert!(matches!(result, Err(VersionError::HistoryNotFound))); + } +} \ No newline at end of file