Skip to main content

cc_switch_lib/services/webdav_sync/
mod.rs

1//! WebDAV v2 sync protocol layer with DB compatibility subdirectories.
2//!
3//! Manifest-based synchronization on top of the WebDAV transport helpers.
4//! Current layout uses `{root}/v2/db-v6/{profile}/`, with legacy fallback to
5//! `{root}/v2/{profile}/`. Artifact set: `db.sql` + `skills.zip` + `settings.json`.
6
7mod 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
26// ---------------------------------------------------------------------------
27// i18n 辅助
28// ---------------------------------------------------------------------------
29
30fn 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
48// ---------------------------------------------------------------------------
49// 常量
50// ---------------------------------------------------------------------------
51
52const 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; // 1 MB
64const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024; // 512 MB
65
66// ---------------------------------------------------------------------------
67// 公共类型
68// ---------------------------------------------------------------------------
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum SyncDecision {
72    Upload,
73    Download,
74    /// V2 远端为空,但检测到 V1 数据,需要用户确认迁移
75    V1MigrationNeeded,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct WebDavSyncSummary {
80    pub decision: SyncDecision,
81    pub message: String,
82}
83
84// ---------------------------------------------------------------------------
85// Manifest 类型
86// ---------------------------------------------------------------------------
87
88#[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
107// ---------------------------------------------------------------------------
108// 本地快照
109// ---------------------------------------------------------------------------
110
111struct 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
132// ---------------------------------------------------------------------------
133// 公共 API(同步包装)
134// ---------------------------------------------------------------------------
135
136pub 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    /// 用户确认后调用:下载 V1 数据 → 应用 → 上传 V2 → 删除 V1
152    pub fn migrate_v1_to_v2() -> Result<WebDavSyncSummary, AppError> {
153        run_http(migrate_v1_to_v2())
154    }
155}
156
157// ---------------------------------------------------------------------------
158// 异步核心
159// ---------------------------------------------------------------------------
160
161async 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    // 上传 artifacts
180    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    // 上传 manifest(最后上传,确保 artifacts 已就绪)
196    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    // 获取 etag(best-effort,不影响上传结果)
206    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
295// ---------------------------------------------------------------------------
296// 设置加载 / 验证
297// ---------------------------------------------------------------------------
298
299fn 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
318// ---------------------------------------------------------------------------
319// 远端路径
320// ---------------------------------------------------------------------------
321
322fn 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
343// ---------------------------------------------------------------------------
344// 本地快照构建
345// ---------------------------------------------------------------------------
346
347fn 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    // 导出 DB
358    let db_sql = Database::init()?.export_sql_string_for_sync()?.into_bytes();
359
360    // 打包 skills
361    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    // 构建 artifacts map
370    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
419// ---------------------------------------------------------------------------
420// Manifest 验证
421// ---------------------------------------------------------------------------
422
423fn 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
530// ---------------------------------------------------------------------------
531// Artifact 下载 + 校验
532// ---------------------------------------------------------------------------
533
534async 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    // 先检查大小(快速),再检查 hash(昂贵)
563    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
611// ---------------------------------------------------------------------------
612// 快照应用(带 skills 备份回滚)
613// ---------------------------------------------------------------------------
614
615fn 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    // 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免"半恢复"。
627    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
658// ---------------------------------------------------------------------------
659// 同步状态持久化
660// ---------------------------------------------------------------------------
661
662fn 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
679/// 尽力持久化同步状态,失败时仅记录日志
680fn 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
694// ---------------------------------------------------------------------------
695// Snapshot ID 计算
696// ---------------------------------------------------------------------------
697
698fn 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
707// ---------------------------------------------------------------------------
708// 设备名检测
709// ---------------------------------------------------------------------------
710
711fn 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
757// ---------------------------------------------------------------------------
758// 工具函数
759// ---------------------------------------------------------------------------
760
761fn 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
785// ---------------------------------------------------------------------------
786// V1 → V2 迁移兼容
787// ---------------------------------------------------------------------------
788
789const V1_PROTOCOL_VERSION: u32 = 1;
790
791/// V1 manifest 类型(仅用于反序列化旧数据)
792#[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
852/// 检测远端是否存在 V1 manifest,返回 Some(manifest) 或 None
853async 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
877/// 下载 V1 artifact 并校验
878async 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
947/// 删除 V1 远端目录(best-effort)
948async 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    // WebDAV DELETE on a collection removes the directory and all contents
955    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
962/// 迁移 V1 → V2:下载 V1 数据 → 本地应用 → 上传 V2 → 删除 V1
963async 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    // 1. 下载 V1 manifest
968    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    // 2. 下载 V1 artifacts。V2 不再继续同步 settingsSync,但迁移时需要
979    //    一次性应用旧协议中的跨设备设置,避免丢失用户配置。
980    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    // 3. 应用到本地
1004    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    // 4. 重新上传为 V2 格式(upload 内部会 best-effort 清理 V1 远端数据)
1013    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// ---------------------------------------------------------------------------
1057// 测试
1058// ---------------------------------------------------------------------------
1059
1060#[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        // 中文字符每个 3 bytes,80 个中文 = 240 bytes
1282        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        // 在 CI/本地环境中应该总能获取到设备名
1290        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}