//! WebDAV v2 sync protocol layer with DB compatibility subdirectories. //! //! 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 DB_COMPAT_VERSION: u32 = 6; const LEGACY_DB_COMPAT_VERSION: u32 = 5; 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, #[serde(default, skip_serializing_if = "Option::is_none")] db_compat_version: Option, 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, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RemoteLayout { Current, Legacy, } impl RemoteLayout { fn as_str(self) -> &'static str { match self { Self::Current => "current", Self::Legacy => "legacy", } } } struct RemoteSnapshot { layout: RemoteLayout, manifest: SyncManifest, manifest_bytes: Vec, manifest_etag: Option, } // ─── 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, RemoteLayout::Current); 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, RemoteLayout::Current); 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, RemoteLayout::Current, REMOTE_DB_SQL)?; put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?; let skills_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_SKILLS_ZIP)?; put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?; let manifest_url = remote_file_url(settings, RemoteLayout::Current, 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 snapshot = find_remote_snapshot(settings, &auth) .await? .ok_or_else(|| { localized( "webdav.sync.remote_empty", "远端没有可下载的同步数据", "No downloadable sync data found on the remote.", ) })?; validate_manifest_compat(&snapshot.manifest, snapshot.layout)?; // Download and verify artifacts let db_sql = download_and_verify( settings, &auth, snapshot.layout, REMOTE_DB_SQL, &snapshot.manifest.artifacts, ) .await?; let skills_zip = download_and_verify( settings, &auth, snapshot.layout, REMOTE_SKILLS_ZIP, &snapshot.manifest.artifacts, ) .await?; // Apply snapshot apply_snapshot(db, &db_sql, &skills_zip)?; let manifest_hash = sha256_hex(&snapshot.manifest_bytes); let _persisted = persist_sync_success_best_effort( settings, manifest_hash, snapshot.manifest_etag, persist_sync_success, ); Ok(serde_json::json!({ "status": "downloaded", "sourceLayout": snapshot.layout.as_str(), "sourcePath": remote_dir_display(settings, snapshot.layout), })) } /// 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 Some(snapshot) = find_remote_snapshot(settings, &auth).await? else { return Ok(None); }; let compatible = validate_manifest_compat(&snapshot.manifest, snapshot.layout).is_ok(); let db_compat_version = effective_db_compat_version(&snapshot.manifest, snapshot.layout); let payload = serde_json::json!({ "deviceName": snapshot.manifest.device_name, "createdAt": snapshot.manifest.created_at, "snapshotId": snapshot.manifest.snapshot_id, "version": snapshot.manifest.version, "protocolVersion": snapshot.manifest.version, "dbCompatVersion": db_compat_version, "compatible": compatible, "artifacts": snapshot.manifest.artifacts.keys().collect::>(), "layout": snapshot.layout.as_str(), "remotePath": remote_dir_display(settings, snapshot.layout), }); 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_for_sync()?; 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, db_compat_version: Some(DB_COMPAT_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 effective_db_compat_version(manifest: &SyncManifest, layout: RemoteLayout) -> Option { manifest .db_compat_version .or_else(|| (layout == RemoteLayout::Legacy).then_some(LEGACY_DB_COMPAT_VERSION)) } fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> 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 ), )); } let Some(db_compat_version) = effective_db_compat_version(manifest, layout) else { return Err(localized( "webdav.sync.manifest_db_version_missing", "远端 manifest 缺少数据库兼容版本", "Remote manifest is missing the database compatibility version.", )); }; match layout { RemoteLayout::Current if db_compat_version != DB_COMPAT_VERSION => { return Err(localized( "webdav.sync.manifest_db_version_incompatible", format!( "远端数据库快照版本不兼容: db-v{db_compat_version} (本地 db-v{DB_COMPAT_VERSION})" ), format!( "Remote database snapshot version is incompatible: db-v{db_compat_version} (local db-v{DB_COMPAT_VERSION})" ), )); } RemoteLayout::Legacy if db_compat_version > DB_COMPAT_VERSION => { return Err(localized( "webdav.sync.manifest_db_version_incompatible", format!( "远端数据库快照版本不兼容: db-v{db_compat_version} (本地最高支持 db-v{DB_COMPAT_VERSION})" ), format!( "Remote database snapshot version is incompatible: db-v{db_compat_version} (local supports up to db-v{DB_COMPAT_VERSION})" ), )); } _ => {} } Ok(()) } async fn find_remote_snapshot( settings: &WebDavSyncSettings, auth: &WebDavAuth, ) -> Result, AppError> { if let Some(snapshot) = fetch_remote_snapshot(settings, auth, RemoteLayout::Current).await? { return Ok(Some(snapshot)); } fetch_remote_snapshot(settings, auth, RemoteLayout::Legacy).await } async fn fetch_remote_snapshot( settings: &WebDavSyncSettings, auth: &WebDavAuth, layout: RemoteLayout, ) -> Result, AppError> { let manifest_url = remote_file_url(settings, layout, REMOTE_MANIFEST)?; let Some((manifest_bytes, manifest_etag)) = get_bytes(&manifest_url, auth, MAX_MANIFEST_BYTES).await? else { return Ok(None); }; let manifest: SyncManifest = serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json { path: REMOTE_MANIFEST.to_string(), source: e, })?; Ok(Some(RemoteSnapshot { layout, manifest, manifest_bytes, manifest_etag, })) } // ─── Download & verify ─────────────────────────────────────── async fn download_and_verify( settings: &WebDavSyncSettings, auth: &WebDavAuth, layout: RemoteLayout, 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, layout, 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_for_sync(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, layout: RemoteLayout) -> Vec { let mut segs = Vec::new(); segs.extend(path_segments(&settings.remote_root).map(str::to_string)); segs.push(format!("v{PROTOCOL_VERSION}")); if layout == RemoteLayout::Current { segs.push(format!("db-v{DB_COMPAT_VERSION}")); } segs.extend(path_segments(&settings.profile).map(str::to_string)); segs } fn remote_file_url( settings: &WebDavSyncSettings, layout: RemoteLayout, file_name: &str, ) -> Result { let mut segs = remote_dir_segments(settings, layout); segs.extend(path_segments(file_name).map(str::to_string)); build_remote_url(&settings.base_url, &segs) } fn remote_dir_display(settings: &WebDavSyncSettings, layout: RemoteLayout) -> String { let segs = remote_dir_segments(settings, layout); format!("/{}", segs.join("/")) } 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} 超过下载上限({max_mb} MB)"), format!("Artifact {artifact_name} exceeds download limit ({max_mb} 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_current_layout() { let settings = WebDavSyncSettings { remote_root: "cc-switch-sync".to_string(), profile: "default".to_string(), ..WebDavSyncSettings::default() }; let segs = remote_dir_segments(&settings, RemoteLayout::Current); assert_eq!(segs, vec!["cc-switch-sync", "v2", "db-v6", "default"]); } #[test] fn remote_dir_segments_uses_legacy_layout() { let settings = WebDavSyncSettings { remote_root: "cc-switch-sync".to_string(), profile: "default".to_string(), ..WebDavSyncSettings::default() }; let segs = remote_dir_segments(&settings, RemoteLayout::Legacy); 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, db_compat_version: Option) -> 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, db_compat_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, Some(DB_COMPAT_VERSION)); assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok()); } #[test] fn validate_manifest_compat_rejects_wrong_format() { let manifest = manifest_with("other-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION)); assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err()); } #[test] fn validate_manifest_compat_rejects_wrong_version() { let manifest = manifest_with( PROTOCOL_FORMAT, PROTOCOL_VERSION + 1, Some(DB_COMPAT_VERSION), ); assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err()); } #[test] fn validate_manifest_compat_accepts_legacy_manifest_without_db_compat() { let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None); assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_ok()); } #[test] fn validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat() { let manifest = manifest_with( PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(LEGACY_DB_COMPAT_VERSION), ); assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err()); } #[test] fn validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation() { let manifest = manifest_with( PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION + 1), ); assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_err()); } #[test] fn effective_db_compat_version_defaults_legacy_layout_to_v5() { let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None); assert_eq!( effective_db_compat_version(&manifest, RemoteLayout::Legacy), Some(LEGACY_DB_COMPAT_VERSION) ); assert_eq!( effective_db_compat_version(&manifest, RemoteLayout::Current), None ); } #[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, Some(DB_COMPAT_VERSION)); let value = serde_json::to_value(&manifest).expect("serialize manifest"); assert!( value.get("deviceName").is_some(), "manifest should contain deviceName" ); assert_eq!( value.get("dbCompatVersion").and_then(|v| v.as_u64()), Some(DB_COMPAT_VERSION as u64) ); 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()); } }