//! WebDAV v2 sync protocol layer. //! //! Implements manifest-based synchronization on top of the HTTP transport //! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`. use std::collections::BTreeMap; use std::fs; use std::future::Future; use std::process::Command; use std::sync::OnceLock; use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use tempfile::tempdir; use crate::error::AppError; use crate::services::webdav::{ auth_from_credentials, build_remote_url, ensure_remote_directories, get_bytes, head_etag, path_segments, put_bytes, test_connection, WebDavAuth, }; use crate::settings::{update_webdav_sync_status, WebDavSyncSettings, WebDavSyncStatus}; mod archive; use archive::{ backup_current_skills, restore_skills_from_backup, restore_skills_zip, zip_skills_ssot, }; // ─── Protocol constants ────────────────────────────────────── const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync"; const PROTOCOL_VERSION: u32 = 2; const REMOTE_DB_SQL: &str = "db.sql"; const REMOTE_SKILLS_ZIP: &str = "skills.zip"; const REMOTE_MANIFEST: &str = "manifest.json"; const MAX_DEVICE_NAME_LEN: usize = 64; const MAX_MANIFEST_BYTES: usize = 1024 * 1024; pub(super) const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024; pub fn sync_mutex() -> &'static tokio::sync::Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| tokio::sync::Mutex::new(())) } pub async fn run_with_sync_lock(operation: Fut) -> Result where Fut: Future>, { let _guard = sync_mutex().lock().await; operation.await } fn localized(key: &'static str, zh: impl Into, en: impl Into) -> AppError { AppError::localized(key, zh, en) } fn io_context_localized( _key: &'static str, zh: impl Into, en: impl Into, source: std::io::Error, ) -> AppError { let zh_msg = zh.into(); let en_msg = en.into(); AppError::IoContext { context: format!("{zh_msg} ({en_msg})"), source, } } // ─── Types ─────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct SyncManifest { format: String, version: u32, device_name: String, created_at: String, artifacts: BTreeMap, snapshot_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ArtifactMeta { sha256: String, size: u64, } struct LocalSnapshot { db_sql: Vec, skills_zip: Vec, manifest_bytes: Vec, manifest_hash: String, } // ─── Public API ────────────────────────────────────────────── /// Check WebDAV connectivity and ensure remote directory structure. pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppError> { settings.validate()?; let auth = auth_for(settings); test_connection(&settings.base_url, &auth).await?; let dir_segs = remote_dir_segments(settings); ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?; Ok(()) } /// Upload local snapshot (db + skills) to remote. pub async fn upload( db: &crate::database::Database, settings: &mut WebDavSyncSettings, ) -> Result { settings.validate()?; let auth = auth_for(settings); let dir_segs = remote_dir_segments(settings); ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?; let snapshot = build_local_snapshot(db, settings)?; // Upload order: artifacts first, manifest last (best-effort consistency) let db_url = remote_file_url(settings, REMOTE_DB_SQL)?; put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?; let skills_url = remote_file_url(settings, REMOTE_SKILLS_ZIP)?; put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?; let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?; put_bytes( &manifest_url, &auth, snapshot.manifest_bytes, "application/json", ) .await?; // Fetch etag (best-effort, don't fail the upload) let etag = match head_etag(&manifest_url, &auth).await { Ok(e) => e, Err(e) => { log::debug!("[WebDAV] Failed to fetch ETag after upload: {e}"); None } }; let _persisted = persist_sync_success_best_effort( settings, snapshot.manifest_hash, etag, persist_sync_success, ); Ok(serde_json::json!({ "status": "uploaded" })) } /// Download remote snapshot and apply to local database + skills. pub async fn download( db: &crate::database::Database, settings: &mut WebDavSyncSettings, ) -> Result { settings.validate()?; let auth = auth_for(settings); let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?; let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES) .await? .ok_or_else(|| { localized( "webdav.sync.remote_empty", "远端没有可下载的同步数据", "No downloadable sync data found on the remote.", ) })?; let manifest: SyncManifest = serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json { path: REMOTE_MANIFEST.to_string(), source: e, })?; validate_manifest_compat(&manifest)?; // Download and verify artifacts let db_sql = download_and_verify(settings, &auth, REMOTE_DB_SQL, &manifest.artifacts).await?; let skills_zip = download_and_verify(settings, &auth, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?; // Apply snapshot apply_snapshot(db, &db_sql, &skills_zip)?; let manifest_hash = sha256_hex(&manifest_bytes); let _persisted = persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success); Ok(serde_json::json!({ "status": "downloaded" })) } /// Fetch remote manifest info without downloading artifacts. pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result, AppError> { settings.validate()?; let auth = auth_for(settings); let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?; let Some((bytes, _)) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES).await? else { return Ok(None); }; let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json { path: REMOTE_MANIFEST.to_string(), source: e, })?; let compatible = validate_manifest_compat(&manifest).is_ok(); let payload = serde_json::json!({ "deviceName": manifest.device_name, "createdAt": manifest.created_at, "snapshotId": manifest.snapshot_id, "version": manifest.version, "compatible": compatible, "artifacts": manifest.artifacts.keys().collect::>(), }); Ok(Some(payload)) } // ─── Sync status persistence (I3: deduplicated) ───────────── fn persist_sync_success( settings: &mut WebDavSyncSettings, manifest_hash: String, etag: Option, ) -> Result<(), AppError> { let status = WebDavSyncStatus { last_sync_at: Some(Utc::now().timestamp()), last_error: None, last_error_source: None, last_local_manifest_hash: Some(manifest_hash.clone()), last_remote_manifest_hash: Some(manifest_hash), last_remote_etag: etag, }; settings.status = status.clone(); update_webdav_sync_status(status) } fn persist_sync_success_best_effort( settings: &mut WebDavSyncSettings, manifest_hash: String, etag: Option, persist_fn: F, ) -> bool where F: FnOnce(&mut WebDavSyncSettings, String, Option) -> Result<(), AppError>, { match persist_fn(settings, manifest_hash, etag) { Ok(()) => true, Err(err) => { log::warn!("[WebDAV] Persist sync status failed, keep operation success: {err}"); false } } } // ─── Snapshot building ─────────────────────────────────────── fn build_local_snapshot( db: &crate::database::Database, _settings: &WebDavSyncSettings, ) -> Result { // Export database to SQL string let sql_string = db.export_sql_string()?; let db_sql = sql_string.into_bytes(); // Pack skills into deterministic ZIP let tmp = tempdir().map_err(|e| { io_context_localized( "webdav.sync.snapshot_tmpdir_failed", "创建 WebDAV 快照临时目录失败", "Failed to create temporary directory for WebDAV snapshot", e, ) })?; let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP); zip_skills_ssot(&skills_zip_path)?; let skills_zip = fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?; // Build artifact map and compute hashes let mut artifacts = BTreeMap::new(); artifacts.insert( REMOTE_DB_SQL.to_string(), ArtifactMeta { sha256: sha256_hex(&db_sql), size: db_sql.len() as u64, }, ); artifacts.insert( REMOTE_SKILLS_ZIP.to_string(), ArtifactMeta { sha256: sha256_hex(&skills_zip), size: skills_zip.len() as u64, }, ); let snapshot_id = compute_snapshot_id(&artifacts); let manifest = SyncManifest { format: PROTOCOL_FORMAT.to_string(), version: PROTOCOL_VERSION, device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()), created_at: Utc::now().to_rfc3339(), artifacts, snapshot_id, }; let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?; let manifest_hash = sha256_hex(&manifest_bytes); Ok(LocalSnapshot { db_sql, skills_zip, manifest_bytes, manifest_hash, }) } /// Compute a deterministic snapshot identity from artifact hashes. /// /// BTreeMap iteration order is sorted by key, ensuring stability. fn compute_snapshot_id(artifacts: &BTreeMap) -> String { let parts: Vec = artifacts .iter() .map(|(name, meta)| format!("{}:{}", name, meta.sha256)) .collect(); sha256_hex(parts.join("|").as_bytes()) } fn sha256_hex(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); format!("{:x}", hasher.finalize()) } fn detect_system_device_name() -> Option { let env_name = ["CC_SWITCH_DEVICE_NAME", "COMPUTERNAME", "HOSTNAME"] .iter() .filter_map(|key| std::env::var(key).ok()) .find_map(|value| normalize_device_name(&value)); if env_name.is_some() { return env_name; } let output = Command::new("hostname").output().ok()?; if !output.status.success() { return None; } let hostname = String::from_utf8(output.stdout).ok()?; normalize_device_name(&hostname) } fn normalize_device_name(raw: &str) -> Option { let compact = raw .chars() .fold(String::with_capacity(raw.len()), |mut acc, ch| { if ch.is_whitespace() { acc.push(' '); } else if !ch.is_control() { acc.push(ch); } acc }); let normalized = compact.split_whitespace().collect::>().join(" "); let trimmed = normalized.trim(); if trimmed.is_empty() { return None; } let limited = trimmed .chars() .take(MAX_DEVICE_NAME_LEN) .collect::(); if limited.is_empty() { None } else { Some(limited) } } fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> { if manifest.format != PROTOCOL_FORMAT { return Err(localized( "webdav.sync.manifest_format_incompatible", format!("远端 manifest 格式不兼容: {}", manifest.format), format!( "Remote manifest format is incompatible: {}", manifest.format ), )); } if manifest.version != PROTOCOL_VERSION { return Err(localized( "webdav.sync.manifest_version_incompatible", format!( "远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})", manifest.version ), format!( "Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})", manifest.version ), )); } Ok(()) } // ─── Download & verify ─────────────────────────────────────── async fn download_and_verify( settings: &WebDavSyncSettings, auth: &WebDavAuth, artifact_name: &str, artifacts: &BTreeMap, ) -> Result, AppError> { let meta = artifacts.get(artifact_name).ok_or_else(|| { localized( "webdav.sync.manifest_missing_artifact", format!("manifest 中缺少 artifact: {artifact_name}"), format!("Manifest missing artifact: {artifact_name}"), ) })?; validate_artifact_size_limit(artifact_name, meta.size)?; let url = remote_file_url(settings, artifact_name)?; let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize) .await? .ok_or_else(|| { localized( "webdav.sync.remote_missing_artifact", format!("远端缺少 artifact 文件: {artifact_name}"), format!("Remote artifact file missing: {artifact_name}"), ) })?; // Quick size check before expensive hash if bytes.len() as u64 != meta.size { return Err(localized( "webdav.sync.artifact_size_mismatch", format!( "artifact {artifact_name} 大小不匹配 (expected: {}, got: {})", meta.size, bytes.len(), ), format!( "Artifact {artifact_name} size mismatch (expected: {}, got: {})", meta.size, bytes.len(), ), )); } let actual_hash = sha256_hex(&bytes); if actual_hash != meta.sha256 { return Err(localized( "webdav.sync.artifact_hash_mismatch", format!( "artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)", meta.sha256.get(..8).unwrap_or(&meta.sha256), actual_hash.get(..8).unwrap_or(&actual_hash), ), format!( "Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)", meta.sha256.get(..8).unwrap_or(&meta.sha256), actual_hash.get(..8).unwrap_or(&actual_hash), ), )); } Ok(bytes) } fn apply_snapshot( db: &crate::database::Database, db_sql: &[u8], skills_zip: &[u8], ) -> Result<(), AppError> { let sql_str = std::str::from_utf8(db_sql).map_err(|e| { localized( "webdav.sync.sql_not_utf8", format!("SQL 非 UTF-8: {e}"), format!("SQL is not valid UTF-8: {e}"), ) })?; let skills_backup = backup_current_skills()?; // 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免“半恢复”。 restore_skills_zip(skills_zip)?; if let Err(db_err) = db.import_sql_string(sql_str) { if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) { return Err(localized( "webdav.sync.db_import_and_rollback_failed", format!("导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}"), format!( "Database import failed: {db_err}; skills rollback also failed: {rollback_err}" ), )); } return Err(db_err); } Ok(()) } // ─── Remote path helpers ───────────────────────────────────── fn remote_dir_segments(settings: &WebDavSyncSettings) -> Vec { let mut segs = Vec::new(); segs.extend(path_segments(&settings.remote_root).map(str::to_string)); segs.push(format!("v{PROTOCOL_VERSION}")); segs.extend(path_segments(&settings.profile).map(str::to_string)); segs } fn remote_file_url(settings: &WebDavSyncSettings, file_name: &str) -> Result { let mut segs = remote_dir_segments(settings); segs.extend(path_segments(file_name).map(str::to_string)); build_remote_url(&settings.base_url, &segs) } fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth { auth_from_credentials(&settings.username, &settings.password) } fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), AppError> { if size > MAX_SYNC_ARTIFACT_BYTES { let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024; return Err(localized( "webdav.sync.artifact_too_large", format!("artifact {artifact_name} 超过下载上限({} MB)", max_mb), format!( "Artifact {artifact_name} exceeds download limit ({} MB)", max_mb ), )); } Ok(()) } // ─── Tests ─────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; fn artifact(sha256: &str, size: u64) -> ArtifactMeta { ArtifactMeta { sha256: sha256.to_string(), size, } } #[test] fn snapshot_id_is_stable() { let mut artifacts = BTreeMap::new(); artifacts.insert("db.sql".to_string(), artifact("abc123", 100)); artifacts.insert("skills.zip".to_string(), artifact("def456", 200)); let id1 = compute_snapshot_id(&artifacts); let id2 = compute_snapshot_id(&artifacts); assert_eq!(id1, id2); } #[test] fn snapshot_id_changes_with_artifacts() { let mut a1 = BTreeMap::new(); a1.insert("db.sql".to_string(), artifact("hash-a", 1)); let mut a2 = BTreeMap::new(); a2.insert("db.sql".to_string(), artifact("hash-b", 1)); assert_ne!(compute_snapshot_id(&a1), compute_snapshot_id(&a2)); } #[test] fn remote_dir_segments_uses_v2() { let settings = WebDavSyncSettings { remote_root: "cc-switch-sync".to_string(), profile: "default".to_string(), ..WebDavSyncSettings::default() }; let segs = remote_dir_segments(&settings); assert_eq!(segs, vec!["cc-switch-sync", "v2", "default"]); } #[test] fn sha256_hex_is_correct() { let hash = sha256_hex(b"hello"); assert_eq!( hash, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" ); } #[test] fn persist_best_effort_returns_true_on_success() { let mut settings = WebDavSyncSettings::default(); let ok = persist_sync_success_best_effort( &mut settings, "hash".to_string(), Some("etag".to_string()), |_settings, _hash, _etag| Ok(()), ); assert!(ok); } #[test] fn persist_best_effort_returns_false_on_error() { let mut settings = WebDavSyncSettings::default(); let ok = persist_sync_success_best_effort( &mut settings, "hash".to_string(), None, |_settings, _hash, _etag| Err(AppError::Config("boom".to_string())), ); assert!(!ok); } fn manifest_with(format: &str, version: u32) -> SyncManifest { let mut artifacts = BTreeMap::new(); artifacts.insert("db.sql".to_string(), artifact("abc", 1)); artifacts.insert("skills.zip".to_string(), artifact("def", 2)); SyncManifest { format: format.to_string(), version, device_name: "My MacBook".to_string(), created_at: "2026-02-12T00:00:00Z".to_string(), artifacts, snapshot_id: "snap-1".to_string(), } } #[test] fn validate_manifest_compat_accepts_supported_manifest() { let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION); assert!(validate_manifest_compat(&manifest).is_ok()); } #[test] fn validate_manifest_compat_rejects_wrong_format() { let manifest = manifest_with("other-format", PROTOCOL_VERSION); assert!(validate_manifest_compat(&manifest).is_err()); } #[test] fn validate_manifest_compat_rejects_wrong_version() { let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1); assert!(validate_manifest_compat(&manifest).is_err()); } #[test] fn normalize_device_name_returns_none_for_blank_input() { assert_eq!(normalize_device_name(" \n\t "), None); } #[test] fn normalize_device_name_collapses_whitespace_and_drops_control_chars() { assert_eq!( normalize_device_name(" Mac\tBook \n Pro\u{0007} "), Some("Mac Book Pro".to_string()) ); } #[test] fn normalize_device_name_truncates_to_max_len() { let long = "a".repeat(80); assert_eq!(normalize_device_name(&long).map(|s| s.len()), Some(64)); } #[test] fn manifest_serialization_uses_device_name_only() { let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION); let value = serde_json::to_value(&manifest).expect("serialize manifest"); assert!( value.get("deviceName").is_some(), "manifest should contain deviceName" ); assert!( value.get("deviceId").is_none(), "manifest should not contain deviceId" ); } #[test] fn validate_artifact_size_limit_rejects_oversized_artifacts() { let err = validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES + 1) .expect_err("artifact larger than limit should be rejected"); assert!( err.to_string().contains("too large") || err.to_string().contains("超过"), "unexpected error: {err}" ); } #[test] fn validate_artifact_size_limit_accepts_limit_boundary() { assert!(validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES).is_ok()); } }