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