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