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