1mod archive;
8
9use std::collections::BTreeMap;
10
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use tempfile::tempdir;
15
16use crate::database::Database;
17use crate::error::AppError;
18use crate::services::webdav;
19use crate::settings::{
20 get_settings, get_webdav_sync_settings, update_settings, update_webdav_sync_status,
21 AppSettings, CustomEndpoint, SecuritySettings, WebDavSyncSettings, WebDavSyncStatus,
22};
23
24use self::archive::{restore_skills_zip, zip_skills_ssot, SkillsBackup};
25
26fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> AppError {
31 AppError::localized(key, zh, en)
32}
33
34fn io_context_localized(
35 _key: &'static str,
36 zh: impl Into<String>,
37 en: impl Into<String>,
38 source: std::io::Error,
39) -> AppError {
40 let zh_msg = zh.into();
41 let en_msg = en.into();
42 AppError::IoContext {
43 context: format!("{zh_msg} ({en_msg})"),
44 source,
45 }
46}
47
48const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync";
53const PROTOCOL_VERSION: u32 = 2;
54const DB_COMPAT_VERSION: u32 = 6;
55const LEGACY_DB_COMPAT_VERSION: u32 = 5;
56const REMOTE_DB_SQL: &str = "db.sql";
57const REMOTE_SKILLS_ZIP: &str = "skills.zip";
58const REMOTE_SETTINGS_JSON: &str = "settings.json";
59const REMOTE_MANIFEST: &str = "manifest.json";
60const REMOTE_V1_SETTINGS_SYNC: &str = "settings.sync.json";
61
62const MAX_DEVICE_NAME_LEN: usize = 64;
63const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum SyncDecision {
72 Upload,
73 Download,
74 V1MigrationNeeded,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct WebDavSyncSummary {
80 pub decision: SyncDecision,
81 pub message: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90struct SyncManifest {
91 format: String,
92 version: u32,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 db_compat_version: Option<u32>,
95 device_name: String,
96 created_at: String,
97 artifacts: BTreeMap<String, ArtifactMeta>,
98 snapshot_id: String,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102struct ArtifactMeta {
103 sha256: String,
104 size: u64,
105}
106
107struct LocalSnapshot {
112 db_sql: Vec<u8>,
113 skills_zip: Vec<u8>,
114 settings_json: Vec<u8>,
115 manifest_bytes: Vec<u8>,
116 manifest_hash: String,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120enum RemoteLayout {
121 Current,
122 Legacy,
123}
124
125struct RemoteSnapshot {
126 layout: RemoteLayout,
127 manifest: SyncManifest,
128 manifest_bytes: Vec<u8>,
129 manifest_etag: Option<String>,
130}
131
132pub struct WebDavSyncService;
137
138impl WebDavSyncService {
139 pub fn check_connection() -> Result<(), AppError> {
140 run_http(check_connection())
141 }
142
143 pub fn upload() -> Result<WebDavSyncSummary, AppError> {
144 run_http(upload())
145 }
146
147 pub fn download() -> Result<WebDavSyncSummary, AppError> {
148 run_http(download())
149 }
150
151 pub fn migrate_v1_to_v2() -> Result<WebDavSyncSummary, AppError> {
153 run_http(migrate_v1_to_v2())
154 }
155}
156
157async fn check_connection() -> Result<(), AppError> {
162 let settings = load_webdav_settings()?;
163 let auth = webdav::auth_from_credentials(&settings.username, &settings.password);
164 webdav::test_connection(&settings.base_url, &auth).await?;
165 let dir_segments = remote_dir_segments(&settings, RemoteLayout::Current);
166 webdav::ensure_remote_directories(&settings.base_url, &dir_segments, &auth).await?;
167 webdav::verify_round_trip_readability(&settings.base_url, &dir_segments, &auth).await?;
168 Ok(())
169}
170
171async fn upload() -> Result<WebDavSyncSummary, AppError> {
172 let mut settings = load_webdav_settings()?;
173 let auth = webdav::auth_from_credentials(&settings.username, &settings.password);
174
175 let dir_segments = remote_dir_segments(&settings, RemoteLayout::Current);
176 webdav::ensure_remote_directories(&settings.base_url, &dir_segments, &auth).await?;
177
178 let snapshot = build_local_snapshot(&settings)?;
179
180 let db_url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_DB_SQL)?;
182 webdav::put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?;
183
184 let skills_url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_SKILLS_ZIP)?;
185 webdav::put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?;
186
187 let settings_url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_SETTINGS_JSON)?;
188 webdav::put_bytes(
189 &settings_url,
190 &auth,
191 snapshot.settings_json,
192 "application/json",
193 )
194 .await?;
195
196 let manifest_url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_MANIFEST)?;
198 webdav::put_bytes(
199 &manifest_url,
200 &auth,
201 snapshot.manifest_bytes.clone(),
202 "application/json",
203 )
204 .await?;
205
206 webdav::verify_readback_matches(
207 &settings.base_url,
208 &manifest_url,
209 &auth,
210 &snapshot.manifest_bytes,
211 "manifest",
212 )
213 .await?;
214
215 let etag = match webdav::head_etag(&manifest_url, &auth).await {
217 Ok(e) => e,
218 Err(e) => {
219 log::debug!("[WebDAV] Failed to fetch ETag after upload: {e}");
220 None
221 }
222 };
223
224 persist_sync_success_best_effort(&mut settings, &snapshot.manifest_hash, etag);
225
226 cleanup_v1_remote(&settings, &auth).await;
228
229 Ok(WebDavSyncSummary {
230 decision: SyncDecision::Upload,
231 message: "WebDAV upload completed".to_string(),
232 })
233}
234
235async fn download() -> Result<WebDavSyncSummary, AppError> {
236 let mut settings = load_webdav_settings()?;
237 let auth = webdav::auth_from_credentials(&settings.username, &settings.password);
238
239 if let Some(snapshot) = find_remote_snapshot(&settings, &auth).await? {
240 validate_manifest_compat(&snapshot.manifest, snapshot.layout)?;
241
242 let manifest_hash = sha256_hex(&snapshot.manifest_bytes);
243 let db_sql = download_and_verify(
244 &settings,
245 &auth,
246 snapshot.layout,
247 REMOTE_DB_SQL,
248 &snapshot.manifest.artifacts,
249 )
250 .await?;
251 let skills_zip = download_and_verify(
252 &settings,
253 &auth,
254 snapshot.layout,
255 REMOTE_SKILLS_ZIP,
256 &snapshot.manifest.artifacts,
257 )
258 .await?;
259 let incoming_settings = if snapshot
260 .manifest
261 .artifacts
262 .contains_key(REMOTE_SETTINGS_JSON)
263 {
264 let settings_json = download_and_verify(
265 &settings,
266 &auth,
267 snapshot.layout,
268 REMOTE_SETTINGS_JSON,
269 &snapshot.manifest.artifacts,
270 )
271 .await?;
272 Some(parse_settings_json(&settings_json)?)
273 } else {
274 None
275 };
276
277 {
278 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard()
279 .await
280 .map_err(AppError::Message)?;
281 ensure_restore_allowed().await?;
282 apply_snapshot(&db_sql, &skills_zip)?;
283 if let Some(incoming_settings) = incoming_settings {
284 apply_settings_preserving_webdav(incoming_settings)?;
285 }
286 }
287 persist_sync_success_best_effort(&mut settings, &manifest_hash, snapshot.manifest_etag);
288 cleanup_v1_remote(&settings, &auth).await;
289
290 Ok(WebDavSyncSummary {
291 decision: SyncDecision::Download,
292 message: "WebDAV download completed".to_string(),
293 })
294 } else if detect_v1_manifest(&settings, &auth).await?.is_some() {
295 Ok(WebDavSyncSummary {
296 decision: SyncDecision::V1MigrationNeeded,
297 message: String::new(),
298 })
299 } else {
300 Err(localized(
301 "webdav.sync.remote_empty",
302 "远端没有可下载的同步数据",
303 "No downloadable sync data found on the remote",
304 ))
305 }
306}
307
308fn load_webdav_settings() -> Result<WebDavSyncSettings, AppError> {
313 let settings = get_webdav_sync_settings().ok_or_else(|| {
314 localized(
315 "webdav.sync.not_configured",
316 "未配置 WebDAV 同步",
317 "WebDAV sync is not configured",
318 )
319 })?;
320 if !settings.enabled {
321 return Err(localized(
322 "webdav.sync.not_enabled",
323 "WebDAV 同步未启用",
324 "WebDAV sync is not enabled",
325 ));
326 }
327 settings.validate()?;
328 Ok(settings)
329}
330
331fn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec<String> {
336 let mut segments = Vec::new();
337 segments.extend(webdav::path_segments(&settings.remote_root).map(str::to_string));
338 segments.push(format!("v{PROTOCOL_VERSION}"));
339 if layout == RemoteLayout::Current {
340 segments.push(format!("db-v{DB_COMPAT_VERSION}"));
341 }
342 segments.extend(webdav::path_segments(&settings.profile).map(str::to_string));
343 segments
344}
345
346fn build_artifact_url(
347 settings: &WebDavSyncSettings,
348 layout: RemoteLayout,
349 file_name: &str,
350) -> Result<String, AppError> {
351 let mut segments = remote_dir_segments(settings, layout);
352 segments.extend(webdav::path_segments(file_name).map(str::to_string));
353 webdav::build_remote_url(&settings.base_url, &segments)
354}
355
356fn build_local_snapshot(_settings: &WebDavSyncSettings) -> Result<LocalSnapshot, AppError> {
361 let tmp = tempdir().map_err(|e| {
362 io_context_localized(
363 "webdav.sync.snapshot_tmpdir_failed",
364 "创建 WebDAV 快照临时目录失败",
365 "Failed to create temporary directory for WebDAV snapshot",
366 e,
367 )
368 })?;
369
370 let db_sql = Database::init()?.export_sql_string_for_sync()?.into_bytes();
372
373 let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);
375 zip_skills_ssot(&skills_zip_path)?;
376 let skills_zip =
377 std::fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?;
378
379 let settings_json = serde_json::to_vec_pretty(&get_settings())
380 .map_err(|e| AppError::JsonSerialize { source: e })?;
381
382 let mut artifacts = BTreeMap::new();
384 artifacts.insert(
385 REMOTE_DB_SQL.to_string(),
386 ArtifactMeta {
387 sha256: sha256_hex(&db_sql),
388 size: db_sql.len() as u64,
389 },
390 );
391 artifacts.insert(
392 REMOTE_SKILLS_ZIP.to_string(),
393 ArtifactMeta {
394 sha256: sha256_hex(&skills_zip),
395 size: skills_zip.len() as u64,
396 },
397 );
398 artifacts.insert(
399 REMOTE_SETTINGS_JSON.to_string(),
400 ArtifactMeta {
401 sha256: sha256_hex(&settings_json),
402 size: settings_json.len() as u64,
403 },
404 );
405
406 let snapshot_id = compute_snapshot_id(&artifacts);
407 let device_name = detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string());
408
409 let manifest = SyncManifest {
410 format: PROTOCOL_FORMAT.to_string(),
411 version: PROTOCOL_VERSION,
412 db_compat_version: Some(DB_COMPAT_VERSION),
413 device_name,
414 created_at: Utc::now().to_rfc3339(),
415 artifacts,
416 snapshot_id,
417 };
418
419 let manifest_bytes =
420 serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?;
421 let manifest_hash = sha256_hex(&manifest_bytes);
422
423 Ok(LocalSnapshot {
424 db_sql,
425 skills_zip,
426 settings_json,
427 manifest_bytes,
428 manifest_hash,
429 })
430}
431
432fn effective_db_compat_version(manifest: &SyncManifest, layout: RemoteLayout) -> Option<u32> {
437 manifest
438 .db_compat_version
439 .or_else(|| (layout == RemoteLayout::Legacy).then_some(LEGACY_DB_COMPAT_VERSION))
440}
441
442fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Result<(), AppError> {
443 if manifest.format != PROTOCOL_FORMAT {
444 return Err(localized(
445 "webdav.sync.manifest_format_incompatible",
446 format!("远端 manifest 格式不兼容: {}", manifest.format),
447 format!(
448 "Remote manifest format is incompatible: {}",
449 manifest.format
450 ),
451 ));
452 }
453 if manifest.version != PROTOCOL_VERSION {
454 return Err(localized(
455 "webdav.sync.manifest_version_incompatible",
456 format!(
457 "远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})",
458 manifest.version
459 ),
460 format!(
461 "Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})",
462 manifest.version
463 ),
464 ));
465 }
466 let Some(db_compat_version) = effective_db_compat_version(manifest, layout) else {
467 return Err(localized(
468 "webdav.sync.manifest_db_version_missing",
469 "远端 manifest 缺少数据库兼容版本",
470 "Remote manifest is missing the database compatibility version.",
471 ));
472 };
473
474 match layout {
475 RemoteLayout::Current if db_compat_version != DB_COMPAT_VERSION => {
476 return Err(localized(
477 "webdav.sync.manifest_db_version_incompatible",
478 format!(
479 "远端数据库快照版本不兼容: db-v{} (本地 db-v{DB_COMPAT_VERSION})",
480 db_compat_version
481 ),
482 format!(
483 "Remote database snapshot version is incompatible: db-v{} (local db-v{DB_COMPAT_VERSION})",
484 db_compat_version
485 ),
486 ));
487 }
488 RemoteLayout::Legacy if db_compat_version > DB_COMPAT_VERSION => {
489 return Err(localized(
490 "webdav.sync.manifest_db_version_incompatible",
491 format!(
492 "远端数据库快照版本不兼容: db-v{} (本地最高支持 db-v{DB_COMPAT_VERSION})",
493 db_compat_version
494 ),
495 format!(
496 "Remote database snapshot version is incompatible: db-v{} (local supports up to db-v{DB_COMPAT_VERSION})",
497 db_compat_version
498 ),
499 ));
500 }
501 _ => {}
502 }
503 Ok(())
504}
505
506async fn find_remote_snapshot(
507 settings: &WebDavSyncSettings,
508 auth: &webdav::WebDavAuth,
509) -> Result<Option<RemoteSnapshot>, AppError> {
510 if let Some(snapshot) = fetch_remote_snapshot(settings, auth, RemoteLayout::Current).await? {
511 return Ok(Some(snapshot));
512 }
513
514 fetch_remote_snapshot(settings, auth, RemoteLayout::Legacy).await
515}
516
517async fn fetch_remote_snapshot(
518 settings: &WebDavSyncSettings,
519 auth: &webdav::WebDavAuth,
520 layout: RemoteLayout,
521) -> Result<Option<RemoteSnapshot>, AppError> {
522 let manifest_url = build_artifact_url(settings, layout, REMOTE_MANIFEST)?;
523 let Some((manifest_bytes, manifest_etag)) =
524 webdav::get_bytes(&manifest_url, auth, Some(MAX_MANIFEST_BYTES)).await?
525 else {
526 return Ok(None);
527 };
528
529 let manifest: SyncManifest =
530 serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
531 path: REMOTE_MANIFEST.to_string(),
532 source: e,
533 })?;
534
535 Ok(Some(RemoteSnapshot {
536 layout,
537 manifest,
538 manifest_bytes,
539 manifest_etag,
540 }))
541}
542
543async fn download_and_verify(
548 settings: &WebDavSyncSettings,
549 auth: &webdav::WebDavAuth,
550 layout: RemoteLayout,
551 artifact_name: &str,
552 artifacts: &BTreeMap<String, ArtifactMeta>,
553) -> Result<Vec<u8>, AppError> {
554 let meta = artifacts.get(artifact_name).ok_or_else(|| {
555 localized(
556 "webdav.sync.manifest_missing_artifact",
557 format!("manifest 中缺少 artifact: {artifact_name}"),
558 format!("Manifest missing artifact: {artifact_name}"),
559 )
560 })?;
561
562 validate_artifact_size_limit(artifact_name, meta.size)?;
563
564 let url = build_artifact_url(settings, layout, artifact_name)?;
565 let (bytes, _) = webdav::get_bytes(&url, auth, Some(MAX_SYNC_ARTIFACT_BYTES))
566 .await?
567 .ok_or_else(|| {
568 localized(
569 "webdav.sync.remote_missing_artifact",
570 format!("远端缺少 artifact 文件: {artifact_name}"),
571 format!("Remote artifact file missing: {artifact_name}"),
572 )
573 })?;
574
575 if bytes.len() as u64 != meta.size {
577 return Err(localized(
578 "webdav.sync.artifact_size_mismatch",
579 format!(
580 "artifact {artifact_name} 大小不匹配 (expected: {}, got: {})",
581 meta.size,
582 bytes.len(),
583 ),
584 format!(
585 "Artifact {artifact_name} size mismatch (expected: {}, got: {})",
586 meta.size,
587 bytes.len(),
588 ),
589 ));
590 }
591
592 let actual_hash = sha256_hex(&bytes);
593 if actual_hash != meta.sha256 {
594 return Err(localized(
595 "webdav.sync.artifact_hash_mismatch",
596 format!(
597 "artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)",
598 meta.sha256.get(..8).unwrap_or(&meta.sha256),
599 actual_hash.get(..8).unwrap_or(&actual_hash),
600 ),
601 format!(
602 "Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)",
603 meta.sha256.get(..8).unwrap_or(&meta.sha256),
604 actual_hash.get(..8).unwrap_or(&actual_hash),
605 ),
606 ));
607 }
608
609 Ok(bytes)
610}
611
612fn validate_artifact_size_limit(name: &str, size: u64) -> Result<(), AppError> {
613 if size > MAX_SYNC_ARTIFACT_BYTES {
614 let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
615 return Err(localized(
616 "webdav.sync.artifact_too_large",
617 format!("artifact {name} 超过下载上限({max_mb} MB)"),
618 format!("Artifact {name} exceeds download limit ({max_mb} MB)"),
619 ));
620 }
621 Ok(())
622}
623
624fn apply_snapshot(db_sql: &[u8], skills_zip: &[u8]) -> Result<(), AppError> {
629 let sql_str = std::str::from_utf8(db_sql).map_err(|e| {
630 localized(
631 "webdav.sync.sql_not_utf8",
632 format!("SQL 非 UTF-8: {e}"),
633 format!("SQL is not valid UTF-8: {e}"),
634 )
635 })?;
636
637 let skills_backup = SkillsBackup::backup_current_skills()?;
638
639 restore_skills_zip(skills_zip)?;
641
642 if let Err(db_err) = Database::init()?.import_sql_string_for_sync(sql_str) {
643 if let Err(rollback_err) = skills_backup.restore() {
644 return Err(localized(
645 "webdav.sync.db_import_and_rollback_failed",
646 format!("导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}"),
647 format!(
648 "Database import failed: {db_err}; skills rollback also failed: {rollback_err}"
649 ),
650 ));
651 }
652 return Err(db_err);
653 }
654
655 Ok(())
656}
657
658fn parse_settings_json(raw: &[u8]) -> Result<AppSettings, AppError> {
659 serde_json::from_slice(raw).map_err(|e| AppError::Json {
660 path: REMOTE_SETTINGS_JSON.to_string(),
661 source: e,
662 })
663}
664
665fn apply_settings_preserving_webdav(mut incoming: AppSettings) -> Result<(), AppError> {
666 let current = get_settings();
667 incoming.webdav_sync = current.webdav_sync;
668 update_settings(incoming)
669}
670
671fn persist_sync_success(
676 settings: &mut WebDavSyncSettings,
677 manifest_hash: &str,
678 etag: Option<String>,
679) -> Result<(), AppError> {
680 let status = WebDavSyncStatus {
681 last_sync_at: Some(Utc::now().timestamp()),
682 last_error: None,
683 last_error_source: None,
684 last_remote_etag: etag,
685 last_local_manifest_hash: Some(manifest_hash.to_string()),
686 last_remote_manifest_hash: Some(manifest_hash.to_string()),
687 };
688 settings.status = status.clone();
689 update_webdav_sync_status(status)
690}
691
692fn persist_sync_success_best_effort(
694 settings: &mut WebDavSyncSettings,
695 manifest_hash: &str,
696 etag: Option<String>,
697) -> bool {
698 match persist_sync_success(settings, manifest_hash, etag) {
699 Ok(()) => true,
700 Err(e) => {
701 log::warn!("持久化同步状态失败(非致命): {e}");
702 false
703 }
704 }
705}
706
707fn compute_snapshot_id(artifacts: &BTreeMap<String, ArtifactMeta>) -> String {
712 let combined: String = artifacts
713 .iter()
714 .map(|(name, meta)| format!("{name}:{}", meta.sha256))
715 .collect::<Vec<_>>()
716 .join("|");
717 sha256_hex(combined.as_bytes())
718}
719
720fn detect_system_device_name() -> Option<String> {
725 let env_name = ["CC_SWITCH_DEVICE_NAME", "COMPUTERNAME", "HOSTNAME"]
726 .iter()
727 .filter_map(|key| std::env::var(key).ok())
728 .find_map(|value| normalize_device_name(&value));
729
730 if env_name.is_some() {
731 return env_name;
732 }
733
734 let output = std::process::Command::new("hostname").output().ok()?;
735 if !output.status.success() {
736 return None;
737 }
738 let hostname = String::from_utf8(output.stdout).ok()?;
739 normalize_device_name(&hostname)
740}
741
742fn normalize_device_name(raw: &str) -> Option<String> {
743 let compact = raw
744 .chars()
745 .fold(String::with_capacity(raw.len()), |mut acc, ch| {
746 if ch.is_whitespace() {
747 acc.push(' ');
748 } else if !ch.is_control() {
749 acc.push(ch);
750 }
751 acc
752 });
753 let normalized = compact.split_whitespace().collect::<Vec<_>>().join(" ");
754 let trimmed = normalized.trim();
755 if trimmed.is_empty() {
756 return None;
757 }
758
759 let limited = trimmed
760 .chars()
761 .take(MAX_DEVICE_NAME_LEN)
762 .collect::<String>();
763 if limited.is_empty() {
764 None
765 } else {
766 Some(limited)
767 }
768}
769
770fn sha256_hex(bytes: &[u8]) -> String {
775 let mut hasher = Sha256::new();
776 hasher.update(bytes);
777 let hash = hasher.finalize();
778 format!("{hash:x}")
779}
780
781fn run_http<F, T>(future: F) -> Result<T, AppError>
782where
783 F: std::future::Future<Output = Result<T, AppError>>,
784{
785 let runtime = tokio::runtime::Builder::new_current_thread()
786 .enable_all()
787 .build()
788 .map_err(|e| {
789 localized(
790 "webdav.sync.runtime_create_failed",
791 format!("创建异步运行时失败: {e}"),
792 format!("Failed to create async runtime: {e}"),
793 )
794 })?;
795 runtime.block_on(future)
796}
797
798const V1_PROTOCOL_VERSION: u32 = 1;
803
804#[derive(Debug, Clone, Deserialize)]
806#[serde(rename_all = "camelCase")]
807struct V1Manifest {
808 format: String,
809 version: u32,
810 #[allow(dead_code)]
811 updated_at: String,
812 #[allow(dead_code)]
813 updated_by: String,
814 artifacts: V1ManifestArtifacts,
815}
816
817#[derive(Debug, Clone, Deserialize)]
818#[serde(rename_all = "camelCase")]
819struct V1ManifestArtifacts {
820 db_sql: V1ArtifactMeta,
821 skills_zip: V1ArtifactMeta,
822 settings_sync: V1ArtifactMeta,
823}
824
825#[derive(Debug, Clone, Deserialize)]
826#[serde(rename_all = "camelCase")]
827struct V1ArtifactMeta {
828 path: String,
829 sha256: String,
830 size: u64,
831}
832
833#[derive(Debug, Clone, Deserialize)]
834#[serde(rename_all = "camelCase")]
835struct V1SyncableSettings {
836 #[serde(default, skip_serializing_if = "Option::is_none")]
837 language: Option<String>,
838 #[serde(default)]
839 skill_sync_method: crate::services::skill::SyncMethod,
840 #[serde(default, skip_serializing_if = "Option::is_none")]
841 security: Option<SecuritySettings>,
842 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
843 custom_endpoints_claude: BTreeMap<String, CustomEndpoint>,
844 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
845 custom_endpoints_codex: BTreeMap<String, CustomEndpoint>,
846}
847
848fn v1_remote_dir_segments(settings: &WebDavSyncSettings) -> Vec<String> {
849 let mut segments = Vec::new();
850 segments.extend(webdav::path_segments(&settings.remote_root).map(str::to_string));
851 segments.push(format!("v{V1_PROTOCOL_VERSION}"));
852 segments.extend(webdav::path_segments(&settings.profile).map(str::to_string));
853 segments
854}
855
856fn build_v1_artifact_url(
857 settings: &WebDavSyncSettings,
858 file_name: &str,
859) -> Result<String, AppError> {
860 let mut segments = v1_remote_dir_segments(settings);
861 segments.extend(webdav::path_segments(file_name).map(str::to_string));
862 webdav::build_remote_url(&settings.base_url, &segments)
863}
864
865async fn detect_v1_manifest(
867 settings: &WebDavSyncSettings,
868 auth: &webdav::WebDavAuth,
869) -> Result<Option<V1Manifest>, AppError> {
870 let url = build_v1_artifact_url(settings, REMOTE_MANIFEST)?;
871 let result = webdav::get_bytes(&url, auth, Some(MAX_MANIFEST_BYTES)).await?;
872 match result {
873 None => Ok(None),
874 Some((bytes, _)) => {
875 let manifest: V1Manifest = match serde_json::from_slice(&bytes) {
876 Ok(m) => m,
877 Err(e) => {
878 log::debug!("[WebDAV] V1 manifest parse failed, treating as absent: {e}");
879 return Ok(None);
880 }
881 };
882 if manifest.format != PROTOCOL_FORMAT || manifest.version != V1_PROTOCOL_VERSION {
883 return Ok(None);
884 }
885 Ok(Some(manifest))
886 }
887 }
888}
889
890async fn download_v1_artifact(
892 settings: &WebDavSyncSettings,
893 auth: &webdav::WebDavAuth,
894 file_name: &str,
895 meta: &V1ArtifactMeta,
896) -> Result<Vec<u8>, AppError> {
897 if meta.size > MAX_SYNC_ARTIFACT_BYTES {
898 let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
899 return Err(localized(
900 "webdav.sync.v1_artifact_too_large",
901 format!("V1 artifact {file_name} 超过下载上限({max_mb} MB)"),
902 format!("V1 artifact {file_name} exceeds download limit ({max_mb} MB)"),
903 ));
904 }
905
906 let remote_path = meta.path.trim();
907 let remote_path = if remote_path.is_empty() {
908 file_name
909 } else {
910 remote_path
911 };
912 let url = build_v1_artifact_url(settings, remote_path)?;
913 let (bytes, _) = webdav::get_bytes(&url, auth, Some(MAX_SYNC_ARTIFACT_BYTES))
914 .await?
915 .ok_or_else(|| {
916 localized(
917 "webdav.sync.v1_artifact_missing",
918 format!("V1 远端缺少 artifact: {remote_path}"),
919 format!("V1 remote artifact missing: {remote_path}"),
920 )
921 })?;
922
923 if bytes.len() as u64 != meta.size {
924 return Err(localized(
925 "webdav.sync.v1_artifact_size_mismatch",
926 format!("V1 artifact {file_name} 大小不匹配"),
927 format!("V1 artifact {file_name} size mismatch"),
928 ));
929 }
930
931 let actual_hash = sha256_hex(&bytes);
932 if actual_hash != meta.sha256 {
933 return Err(localized(
934 "webdav.sync.v1_artifact_hash_mismatch",
935 format!("V1 artifact {file_name} SHA256 校验失败"),
936 format!("V1 artifact {file_name} SHA256 verification failed"),
937 ));
938 }
939
940 Ok(bytes)
941}
942
943fn parse_v1_syncable_settings(raw: &[u8]) -> Result<V1SyncableSettings, AppError> {
944 serde_json::from_slice(raw).map_err(|e| AppError::Json {
945 path: REMOTE_V1_SETTINGS_SYNC.to_string(),
946 source: e,
947 })
948}
949
950fn apply_v1_syncable_settings(incoming: V1SyncableSettings) -> Result<(), AppError> {
951 let mut settings = get_settings();
952 settings.language = incoming.language;
953 settings.skill_sync_method = incoming.skill_sync_method;
954 settings.security = incoming.security;
955 settings.custom_endpoints_claude = incoming.custom_endpoints_claude.into_iter().collect();
956 settings.custom_endpoints_codex = incoming.custom_endpoints_codex.into_iter().collect();
957 update_settings(settings)
958}
959
960async fn cleanup_v1_remote(settings: &WebDavSyncSettings, auth: &webdav::WebDavAuth) {
962 let segments = v1_remote_dir_segments(settings);
963 let url = match webdav::build_remote_url(&settings.base_url, &segments) {
964 Ok(u) => u,
965 Err(_) => return,
966 };
967 match webdav::delete_collection(&url, auth).await {
969 Ok(true) => log::info!("[WebDAV] V1 remote data cleaned up"),
970 Ok(false) => log::debug!("[WebDAV] V1 remote data already gone"),
971 Err(e) => log::warn!("[WebDAV] Failed to clean up V1 remote data: {e}"),
972 }
973}
974
975async fn migrate_v1_to_v2() -> Result<WebDavSyncSummary, AppError> {
977 let settings = load_webdav_settings()?;
978 let auth = webdav::auth_from_credentials(&settings.username, &settings.password);
979
980 let v1_manifest = detect_v1_manifest(&settings, &auth).await?.ok_or_else(|| {
982 localized(
983 "webdav.sync.v1_not_found",
984 "远端未找到 V1 同步数据",
985 "No V1 sync data found on the remote",
986 )
987 })?;
988
989 ensure_restore_allowed().await?;
990
991 let db_sql = download_v1_artifact(
994 &settings,
995 &auth,
996 REMOTE_DB_SQL,
997 &v1_manifest.artifacts.db_sql,
998 )
999 .await?;
1000 let skills_zip = download_v1_artifact(
1001 &settings,
1002 &auth,
1003 REMOTE_SKILLS_ZIP,
1004 &v1_manifest.artifacts.skills_zip,
1005 )
1006 .await?;
1007 let settings_sync = download_v1_artifact(
1008 &settings,
1009 &auth,
1010 REMOTE_V1_SETTINGS_SYNC,
1011 &v1_manifest.artifacts.settings_sync,
1012 )
1013 .await?;
1014 let syncable_settings = parse_v1_syncable_settings(&settings_sync)?;
1015
1016 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard()
1018 .await
1019 .map_err(AppError::Message)?;
1020 ensure_restore_allowed().await?;
1021 apply_snapshot(&db_sql, &skills_zip)?;
1022 apply_v1_syncable_settings(syncable_settings)?;
1023 drop(_guard);
1024
1025 upload().await?;
1027
1028 Ok(WebDavSyncSummary {
1029 decision: SyncDecision::Download,
1030 message: "V1 → V2 migration completed".to_string(),
1031 })
1032}
1033
1034async fn ensure_restore_allowed() -> Result<(), AppError> {
1035 let db = std::sync::Arc::new(Database::init()?);
1036 let proxy_service = crate::services::ProxyService::new(db);
1037 let status = proxy_service.get_status().await;
1038 if status.running {
1039 return Err(localized(
1040 "webdav.sync.restore_proxy_running",
1041 "本地代理正在运行,请先停止代理后再执行 WebDAV 恢复或迁移",
1042 "The local proxy is running. Stop it before WebDAV restore or migration.",
1043 ));
1044 }
1045
1046 let takeover_active = proxy_service
1047 .is_app_takeover_active(&crate::AppType::Claude)
1048 .await
1049 .map_err(AppError::Message)?
1050 || proxy_service
1051 .is_app_takeover_active(&crate::AppType::Codex)
1052 .await
1053 .map_err(AppError::Message)?
1054 || proxy_service
1055 .is_app_takeover_active(&crate::AppType::Gemini)
1056 .await
1057 .map_err(AppError::Message)?;
1058 if takeover_active {
1059 return Err(localized(
1060 "webdav.sync.restore_takeover_active",
1061 "当前仍有应用处于代理接管状态,请先关闭接管后再执行 WebDAV 恢复或迁移",
1062 "An app takeover is still active. Disable takeover before WebDAV restore or migration.",
1063 ));
1064 }
1065
1066 Ok(())
1067}
1068
1069#[cfg(test)]
1074mod tests {
1075 use super::*;
1076
1077 fn sample_settings() -> WebDavSyncSettings {
1078 WebDavSyncSettings {
1079 enabled: true,
1080 base_url: "https://dav.example.com/remote.php/dav/files/demo/".to_string(),
1081 remote_root: "cc switch-sync/team a".to_string(),
1082 profile: "default profile".to_string(),
1083 username: "demo".to_string(),
1084 password: "secret".to_string(),
1085 auto_sync: false,
1086 status: WebDavSyncStatus::default(),
1087 }
1088 }
1089
1090 #[test]
1091 fn remote_dir_segments_uses_current_layout() {
1092 let mut settings = sample_settings();
1093 settings.normalize();
1094 let segments = remote_dir_segments(&settings, RemoteLayout::Current);
1095 assert_eq!(
1096 segments,
1097 vec![
1098 "cc switch-sync".to_string(),
1099 "team a".to_string(),
1100 "v2".to_string(),
1101 "db-v6".to_string(),
1102 "default profile".to_string(),
1103 ]
1104 );
1105 }
1106
1107 #[test]
1108 fn build_artifact_url_encodes_path_segments() {
1109 let mut settings = sample_settings();
1110 settings.normalize();
1111 let url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_MANIFEST)
1112 .expect("build artifact url");
1113 assert_eq!(
1114 url,
1115 "https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/team%20a/v2/db-v6/default%20profile/manifest.json"
1116 );
1117 assert!(
1118 !url.contains("//cc"),
1119 "url should not contain duplicated slash: {url}"
1120 );
1121 }
1122
1123 #[test]
1124 fn snapshot_id_is_stable() {
1125 let mut artifacts = BTreeMap::new();
1126 artifacts.insert(
1127 "db.sql".to_string(),
1128 ArtifactMeta {
1129 sha256: "aaa".to_string(),
1130 size: 1,
1131 },
1132 );
1133 artifacts.insert(
1134 "skills.zip".to_string(),
1135 ArtifactMeta {
1136 sha256: "bbb".to_string(),
1137 size: 2,
1138 },
1139 );
1140 let id1 = compute_snapshot_id(&artifacts);
1141 let id2 = compute_snapshot_id(&artifacts);
1142 assert_eq!(id1, id2);
1143 }
1144
1145 #[test]
1146 fn snapshot_id_changes_with_artifacts() {
1147 let mut artifacts_a = BTreeMap::new();
1148 artifacts_a.insert(
1149 "db.sql".to_string(),
1150 ArtifactMeta {
1151 sha256: "aaa".to_string(),
1152 size: 1,
1153 },
1154 );
1155 artifacts_a.insert(
1156 "skills.zip".to_string(),
1157 ArtifactMeta {
1158 sha256: "bbb".to_string(),
1159 size: 2,
1160 },
1161 );
1162
1163 let mut artifacts_b = artifacts_a.clone();
1164 artifacts_b.get_mut("db.sql").unwrap().sha256 = "ccc".to_string();
1165
1166 assert_ne!(
1167 compute_snapshot_id(&artifacts_a),
1168 compute_snapshot_id(&artifacts_b)
1169 );
1170 }
1171
1172 fn manifest_with(format: &str, version: u32, db_compat_version: Option<u32>) -> SyncManifest {
1173 SyncManifest {
1174 format: format.to_string(),
1175 version,
1176 db_compat_version,
1177 device_name: "test".to_string(),
1178 created_at: "2026-01-01T00:00:00Z".to_string(),
1179 artifacts: BTreeMap::new(),
1180 snapshot_id: "id".to_string(),
1181 }
1182 }
1183
1184 #[test]
1185 fn validate_manifest_compat_accepts_supported_manifest() {
1186 let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
1187 assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok());
1188 }
1189
1190 #[test]
1191 fn validate_manifest_compat_wrong_format() {
1192 let manifest = manifest_with("wrong-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
1193 assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
1194 }
1195
1196 #[test]
1197 fn validate_manifest_compat_wrong_version() {
1198 let manifest = manifest_with(PROTOCOL_FORMAT, 999, Some(DB_COMPAT_VERSION));
1199 assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
1200 }
1201
1202 #[test]
1203 fn validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat() {
1204 let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(5));
1205 assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
1206 }
1207
1208 #[test]
1209 fn remote_dir_segments_uses_legacy_layout() {
1210 let mut settings = sample_settings();
1211 settings.normalize();
1212 let segments = remote_dir_segments(&settings, RemoteLayout::Legacy);
1213 assert_eq!(
1214 segments,
1215 vec![
1216 "cc switch-sync".to_string(),
1217 "team a".to_string(),
1218 "v2".to_string(),
1219 "default profile".to_string(),
1220 ]
1221 );
1222 }
1223
1224 #[test]
1225 fn validate_manifest_compat_accepts_legacy_manifest_without_db_compat() {
1226 let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
1227 assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_ok());
1228 }
1229
1230 #[test]
1231 fn validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation() {
1232 let manifest = manifest_with(
1233 PROTOCOL_FORMAT,
1234 PROTOCOL_VERSION,
1235 Some(DB_COMPAT_VERSION + 1),
1236 );
1237 assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_err());
1238 }
1239
1240 #[test]
1241 fn effective_db_compat_version_defaults_legacy_layout_to_v5() {
1242 let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
1243 assert_eq!(
1244 effective_db_compat_version(&manifest, RemoteLayout::Legacy),
1245 Some(LEGACY_DB_COMPAT_VERSION)
1246 );
1247 assert_eq!(
1248 effective_db_compat_version(&manifest, RemoteLayout::Current),
1249 None
1250 );
1251 }
1252
1253 #[test]
1254 fn validate_artifact_size_limit_ok() {
1255 assert!(validate_artifact_size_limit("db.sql", 1024).is_ok());
1256 }
1257
1258 #[test]
1259 fn validate_artifact_size_limit_exceeded() {
1260 assert!(validate_artifact_size_limit("db.sql", MAX_SYNC_ARTIFACT_BYTES + 1).is_err());
1261 }
1262
1263 #[test]
1264 fn normalize_device_name_trims() {
1265 assert_eq!(
1266 normalize_device_name(" my-host "),
1267 Some("my-host".to_string())
1268 );
1269 }
1270
1271 #[test]
1272 fn normalize_device_name_empty() {
1273 assert_eq!(normalize_device_name(""), None);
1274 assert_eq!(normalize_device_name(" "), None);
1275 }
1276
1277 #[test]
1278 fn normalize_device_name_truncates() {
1279 let long = "a".repeat(100);
1280 let result = normalize_device_name(&long).unwrap();
1281 assert_eq!(result.chars().count(), MAX_DEVICE_NAME_LEN);
1282 }
1283
1284 #[test]
1285 fn normalize_device_name_collapses_whitespace() {
1286 assert_eq!(
1287 normalize_device_name(" Mac Book Pro "),
1288 Some("Mac Book Pro".to_string())
1289 );
1290 }
1291
1292 #[test]
1293 fn normalize_device_name_truncates_by_chars_not_bytes() {
1294 let long_cn = "测".repeat(80);
1296 let result = normalize_device_name(&long_cn).unwrap();
1297 assert_eq!(result.chars().count(), MAX_DEVICE_NAME_LEN);
1298 }
1299
1300 #[test]
1301 fn detect_system_device_name_returns_some() {
1302 let name = detect_system_device_name();
1304 assert!(name.is_some(), "should detect a device name");
1305 }
1306
1307 #[test]
1308 fn manifest_serialization_uses_device_name_only() {
1309 let manifest = SyncManifest {
1310 format: PROTOCOL_FORMAT.to_string(),
1311 version: PROTOCOL_VERSION,
1312 db_compat_version: Some(DB_COMPAT_VERSION),
1313 device_name: "My MacBook".to_string(),
1314 created_at: "2026-01-01T00:00:00Z".to_string(),
1315 artifacts: BTreeMap::new(),
1316 snapshot_id: "snap-1".to_string(),
1317 };
1318 let value = serde_json::to_value(&manifest).expect("serialize manifest");
1319 assert!(
1320 value.get("deviceName").is_some(),
1321 "manifest should contain deviceName"
1322 );
1323 assert!(
1324 value.get("deviceId").is_none(),
1325 "manifest should not contain deviceId"
1326 );
1327 assert_eq!(
1328 value.get("dbCompatVersion").and_then(|v| v.as_u64()),
1329 Some(DB_COMPAT_VERSION as u64)
1330 );
1331 }
1332}