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