mod archive;
use std::collections::BTreeMap;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tempfile::tempdir;
use crate::database::Database;
use crate::error::AppError;
use crate::services::webdav;
use crate::settings::{
get_settings, get_webdav_sync_settings, update_settings, update_webdav_sync_status,
AppSettings, CustomEndpoint, SecuritySettings, WebDavSyncSettings, WebDavSyncStatus,
};
use self::archive::{restore_skills_zip, zip_skills_ssot, SkillsBackup};
fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> AppError {
AppError::localized(key, zh, en)
}
fn io_context_localized(
_key: &'static str,
zh: impl Into<String>,
en: impl Into<String>,
source: std::io::Error,
) -> AppError {
let zh_msg = zh.into();
let en_msg = en.into();
AppError::IoContext {
context: format!("{zh_msg} ({en_msg})"),
source,
}
}
const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync";
const PROTOCOL_VERSION: u32 = 2;
const DB_COMPAT_VERSION: u32 = 6;
const LEGACY_DB_COMPAT_VERSION: u32 = 5;
const REMOTE_DB_SQL: &str = "db.sql";
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
const REMOTE_SETTINGS_JSON: &str = "settings.json";
const REMOTE_MANIFEST: &str = "manifest.json";
const REMOTE_V1_SETTINGS_SYNC: &str = "settings.sync.json";
const MAX_DEVICE_NAME_LEN: usize = 64;
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncDecision {
Upload,
Download,
V1MigrationNeeded,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebDavSyncSummary {
pub decision: SyncDecision,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncManifest {
format: String,
version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
db_compat_version: Option<u32>,
device_name: String,
created_at: String,
artifacts: BTreeMap<String, ArtifactMeta>,
snapshot_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ArtifactMeta {
sha256: String,
size: u64,
}
struct LocalSnapshot {
db_sql: Vec<u8>,
skills_zip: Vec<u8>,
settings_json: Vec<u8>,
manifest_bytes: Vec<u8>,
manifest_hash: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RemoteLayout {
Current,
Legacy,
}
struct RemoteSnapshot {
layout: RemoteLayout,
manifest: SyncManifest,
manifest_bytes: Vec<u8>,
manifest_etag: Option<String>,
}
pub struct WebDavSyncService;
impl WebDavSyncService {
pub fn check_connection() -> Result<(), AppError> {
run_http(check_connection())
}
pub fn upload() -> Result<WebDavSyncSummary, AppError> {
run_http(upload())
}
pub fn download() -> Result<WebDavSyncSummary, AppError> {
run_http(download())
}
pub fn migrate_v1_to_v2() -> Result<WebDavSyncSummary, AppError> {
run_http(migrate_v1_to_v2())
}
}
async fn check_connection() -> Result<(), AppError> {
let settings = load_webdav_settings()?;
let auth = webdav::auth_from_credentials(&settings.username, &settings.password);
webdav::test_connection(&settings.base_url, &auth).await?;
let dir_segments = remote_dir_segments(&settings, RemoteLayout::Current);
webdav::ensure_remote_directories(&settings.base_url, &dir_segments, &auth).await?;
Ok(())
}
async fn upload() -> Result<WebDavSyncSummary, AppError> {
let mut settings = load_webdav_settings()?;
let auth = webdav::auth_from_credentials(&settings.username, &settings.password);
let dir_segments = remote_dir_segments(&settings, RemoteLayout::Current);
webdav::ensure_remote_directories(&settings.base_url, &dir_segments, &auth).await?;
let snapshot = build_local_snapshot(&settings)?;
let db_url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_DB_SQL)?;
webdav::put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?;
let skills_url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_SKILLS_ZIP)?;
webdav::put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?;
let settings_url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_SETTINGS_JSON)?;
webdav::put_bytes(
&settings_url,
&auth,
snapshot.settings_json,
"application/json",
)
.await?;
let manifest_url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_MANIFEST)?;
webdav::put_bytes(
&manifest_url,
&auth,
snapshot.manifest_bytes,
"application/json",
)
.await?;
let etag = match webdav::head_etag(&manifest_url, &auth).await {
Ok(e) => e,
Err(e) => {
log::debug!("[WebDAV] Failed to fetch ETag after upload: {e}");
None
}
};
persist_sync_success_best_effort(&mut settings, &snapshot.manifest_hash, etag);
Ok(WebDavSyncSummary {
decision: SyncDecision::Upload,
message: "WebDAV upload completed".to_string(),
})
}
async fn download() -> Result<WebDavSyncSummary, AppError> {
let mut settings = load_webdav_settings()?;
let auth = webdav::auth_from_credentials(&settings.username, &settings.password);
if let Some(snapshot) = find_remote_snapshot(&settings, &auth).await? {
validate_manifest_compat(&snapshot.manifest, snapshot.layout)?;
let manifest_hash = sha256_hex(&snapshot.manifest_bytes);
let db_sql = download_and_verify(
&settings,
&auth,
snapshot.layout,
REMOTE_DB_SQL,
&snapshot.manifest.artifacts,
)
.await?;
let skills_zip = download_and_verify(
&settings,
&auth,
snapshot.layout,
REMOTE_SKILLS_ZIP,
&snapshot.manifest.artifacts,
)
.await?;
let incoming_settings = if snapshot
.manifest
.artifacts
.contains_key(REMOTE_SETTINGS_JSON)
{
let settings_json = download_and_verify(
&settings,
&auth,
snapshot.layout,
REMOTE_SETTINGS_JSON,
&snapshot.manifest.artifacts,
)
.await?;
Some(parse_settings_json(&settings_json)?)
} else {
None
};
{
let _guard = crate::services::state_coordination::acquire_restore_mutation_guard()
.await
.map_err(AppError::Message)?;
ensure_restore_allowed().await?;
apply_snapshot(&db_sql, &skills_zip)?;
if let Some(incoming_settings) = incoming_settings {
apply_settings_preserving_webdav(incoming_settings)?;
}
}
persist_sync_success_best_effort(&mut settings, &manifest_hash, snapshot.manifest_etag);
cleanup_v1_remote(&settings, &auth).await;
Ok(WebDavSyncSummary {
decision: SyncDecision::Download,
message: "WebDAV download completed".to_string(),
})
} else if detect_v1_manifest(&settings, &auth).await?.is_some() {
Ok(WebDavSyncSummary {
decision: SyncDecision::V1MigrationNeeded,
message: String::new(),
})
} else {
Err(localized(
"webdav.sync.remote_empty",
"远端没有可下载的同步数据",
"No downloadable sync data found on the remote",
))
}
}
fn load_webdav_settings() -> Result<WebDavSyncSettings, AppError> {
let settings = get_webdav_sync_settings().ok_or_else(|| {
localized(
"webdav.sync.not_configured",
"未配置 WebDAV 同步",
"WebDAV sync is not configured",
)
})?;
if !settings.enabled {
return Err(localized(
"webdav.sync.not_enabled",
"WebDAV 同步未启用",
"WebDAV sync is not enabled",
));
}
settings.validate()?;
Ok(settings)
}
fn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec<String> {
let mut segments = Vec::new();
segments.extend(webdav::path_segments(&settings.remote_root).map(str::to_string));
segments.push(format!("v{PROTOCOL_VERSION}"));
if layout == RemoteLayout::Current {
segments.push(format!("db-v{DB_COMPAT_VERSION}"));
}
segments.extend(webdav::path_segments(&settings.profile).map(str::to_string));
segments
}
fn build_artifact_url(
settings: &WebDavSyncSettings,
layout: RemoteLayout,
file_name: &str,
) -> Result<String, AppError> {
let mut segments = remote_dir_segments(settings, layout);
segments.extend(webdav::path_segments(file_name).map(str::to_string));
webdav::build_remote_url(&settings.base_url, &segments)
}
fn build_local_snapshot(_settings: &WebDavSyncSettings) -> Result<LocalSnapshot, AppError> {
let tmp = tempdir().map_err(|e| {
io_context_localized(
"webdav.sync.snapshot_tmpdir_failed",
"创建 WebDAV 快照临时目录失败",
"Failed to create temporary directory for WebDAV snapshot",
e,
)
})?;
let db_sql = Database::init()?.export_sql_string_for_sync()?.into_bytes();
let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);
zip_skills_ssot(&skills_zip_path)?;
let skills_zip =
std::fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?;
let settings_json = serde_json::to_vec_pretty(&get_settings())
.map_err(|e| AppError::JsonSerialize { source: e })?;
let mut artifacts = BTreeMap::new();
artifacts.insert(
REMOTE_DB_SQL.to_string(),
ArtifactMeta {
sha256: sha256_hex(&db_sql),
size: db_sql.len() as u64,
},
);
artifacts.insert(
REMOTE_SKILLS_ZIP.to_string(),
ArtifactMeta {
sha256: sha256_hex(&skills_zip),
size: skills_zip.len() as u64,
},
);
artifacts.insert(
REMOTE_SETTINGS_JSON.to_string(),
ArtifactMeta {
sha256: sha256_hex(&settings_json),
size: settings_json.len() as u64,
},
);
let snapshot_id = compute_snapshot_id(&artifacts);
let device_name = detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string());
let manifest = SyncManifest {
format: PROTOCOL_FORMAT.to_string(),
version: PROTOCOL_VERSION,
db_compat_version: Some(DB_COMPAT_VERSION),
device_name,
created_at: Utc::now().to_rfc3339(),
artifacts,
snapshot_id,
};
let manifest_bytes =
serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?;
let manifest_hash = sha256_hex(&manifest_bytes);
Ok(LocalSnapshot {
db_sql,
skills_zip,
settings_json,
manifest_bytes,
manifest_hash,
})
}
fn effective_db_compat_version(manifest: &SyncManifest, layout: RemoteLayout) -> Option<u32> {
manifest
.db_compat_version
.or_else(|| (layout == RemoteLayout::Legacy).then_some(LEGACY_DB_COMPAT_VERSION))
}
fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Result<(), AppError> {
if manifest.format != PROTOCOL_FORMAT {
return Err(localized(
"webdav.sync.manifest_format_incompatible",
format!("远端 manifest 格式不兼容: {}", manifest.format),
format!(
"Remote manifest format is incompatible: {}",
manifest.format
),
));
}
if manifest.version != PROTOCOL_VERSION {
return Err(localized(
"webdav.sync.manifest_version_incompatible",
format!(
"远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})",
manifest.version
),
format!(
"Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})",
manifest.version
),
));
}
let Some(db_compat_version) = effective_db_compat_version(manifest, layout) else {
return Err(localized(
"webdav.sync.manifest_db_version_missing",
"远端 manifest 缺少数据库兼容版本",
"Remote manifest is missing the database compatibility version.",
));
};
match layout {
RemoteLayout::Current if db_compat_version != DB_COMPAT_VERSION => {
return Err(localized(
"webdav.sync.manifest_db_version_incompatible",
format!(
"远端数据库快照版本不兼容: db-v{} (本地 db-v{DB_COMPAT_VERSION})",
db_compat_version
),
format!(
"Remote database snapshot version is incompatible: db-v{} (local db-v{DB_COMPAT_VERSION})",
db_compat_version
),
));
}
RemoteLayout::Legacy if db_compat_version > DB_COMPAT_VERSION => {
return Err(localized(
"webdav.sync.manifest_db_version_incompatible",
format!(
"远端数据库快照版本不兼容: db-v{} (本地最高支持 db-v{DB_COMPAT_VERSION})",
db_compat_version
),
format!(
"Remote database snapshot version is incompatible: db-v{} (local supports up to db-v{DB_COMPAT_VERSION})",
db_compat_version
),
));
}
_ => {}
}
Ok(())
}
async fn find_remote_snapshot(
settings: &WebDavSyncSettings,
auth: &webdav::WebDavAuth,
) -> Result<Option<RemoteSnapshot>, AppError> {
if let Some(snapshot) = fetch_remote_snapshot(settings, auth, RemoteLayout::Current).await? {
return Ok(Some(snapshot));
}
fetch_remote_snapshot(settings, auth, RemoteLayout::Legacy).await
}
async fn fetch_remote_snapshot(
settings: &WebDavSyncSettings,
auth: &webdav::WebDavAuth,
layout: RemoteLayout,
) -> Result<Option<RemoteSnapshot>, AppError> {
let manifest_url = build_artifact_url(settings, layout, REMOTE_MANIFEST)?;
let Some((manifest_bytes, manifest_etag)) =
webdav::get_bytes(&manifest_url, auth, Some(MAX_MANIFEST_BYTES)).await?
else {
return Ok(None);
};
let manifest: SyncManifest =
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
path: REMOTE_MANIFEST.to_string(),
source: e,
})?;
Ok(Some(RemoteSnapshot {
layout,
manifest,
manifest_bytes,
manifest_etag,
}))
}
async fn download_and_verify(
settings: &WebDavSyncSettings,
auth: &webdav::WebDavAuth,
layout: RemoteLayout,
artifact_name: &str,
artifacts: &BTreeMap<String, ArtifactMeta>,
) -> Result<Vec<u8>, AppError> {
let meta = artifacts.get(artifact_name).ok_or_else(|| {
localized(
"webdav.sync.manifest_missing_artifact",
format!("manifest 中缺少 artifact: {artifact_name}"),
format!("Manifest missing artifact: {artifact_name}"),
)
})?;
validate_artifact_size_limit(artifact_name, meta.size)?;
let url = build_artifact_url(settings, layout, artifact_name)?;
let (bytes, _) = webdav::get_bytes(&url, auth, Some(MAX_SYNC_ARTIFACT_BYTES))
.await?
.ok_or_else(|| {
localized(
"webdav.sync.remote_missing_artifact",
format!("远端缺少 artifact 文件: {artifact_name}"),
format!("Remote artifact file missing: {artifact_name}"),
)
})?;
if bytes.len() as u64 != meta.size {
return Err(localized(
"webdav.sync.artifact_size_mismatch",
format!(
"artifact {artifact_name} 大小不匹配 (expected: {}, got: {})",
meta.size,
bytes.len(),
),
format!(
"Artifact {artifact_name} size mismatch (expected: {}, got: {})",
meta.size,
bytes.len(),
),
));
}
let actual_hash = sha256_hex(&bytes);
if actual_hash != meta.sha256 {
return Err(localized(
"webdav.sync.artifact_hash_mismatch",
format!(
"artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)",
meta.sha256.get(..8).unwrap_or(&meta.sha256),
actual_hash.get(..8).unwrap_or(&actual_hash),
),
format!(
"Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)",
meta.sha256.get(..8).unwrap_or(&meta.sha256),
actual_hash.get(..8).unwrap_or(&actual_hash),
),
));
}
Ok(bytes)
}
fn validate_artifact_size_limit(name: &str, size: u64) -> Result<(), AppError> {
if size > MAX_SYNC_ARTIFACT_BYTES {
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
return Err(localized(
"webdav.sync.artifact_too_large",
format!("artifact {name} 超过下载上限({max_mb} MB)"),
format!("Artifact {name} exceeds download limit ({max_mb} MB)"),
));
}
Ok(())
}
fn apply_snapshot(db_sql: &[u8], skills_zip: &[u8]) -> Result<(), AppError> {
let sql_str = std::str::from_utf8(db_sql).map_err(|e| {
localized(
"webdav.sync.sql_not_utf8",
format!("SQL 非 UTF-8: {e}"),
format!("SQL is not valid UTF-8: {e}"),
)
})?;
let skills_backup = SkillsBackup::backup_current_skills()?;
restore_skills_zip(skills_zip)?;
if let Err(db_err) = Database::init()?.import_sql_string_for_sync(sql_str) {
if let Err(rollback_err) = skills_backup.restore() {
return Err(localized(
"webdav.sync.db_import_and_rollback_failed",
format!("导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}"),
format!(
"Database import failed: {db_err}; skills rollback also failed: {rollback_err}"
),
));
}
return Err(db_err);
}
Ok(())
}
fn parse_settings_json(raw: &[u8]) -> Result<AppSettings, AppError> {
serde_json::from_slice(raw).map_err(|e| AppError::Json {
path: REMOTE_SETTINGS_JSON.to_string(),
source: e,
})
}
fn apply_settings_preserving_webdav(mut incoming: AppSettings) -> Result<(), AppError> {
let current = get_settings();
incoming.webdav_sync = current.webdav_sync;
update_settings(incoming)
}
fn persist_sync_success(
settings: &mut WebDavSyncSettings,
manifest_hash: &str,
etag: Option<String>,
) -> Result<(), AppError> {
let status = WebDavSyncStatus {
last_sync_at: Some(Utc::now().timestamp()),
last_error: None,
last_error_source: None,
last_remote_etag: etag,
last_local_manifest_hash: Some(manifest_hash.to_string()),
last_remote_manifest_hash: Some(manifest_hash.to_string()),
};
settings.status = status.clone();
update_webdav_sync_status(status)
}
fn persist_sync_success_best_effort(
settings: &mut WebDavSyncSettings,
manifest_hash: &str,
etag: Option<String>,
) -> bool {
match persist_sync_success(settings, manifest_hash, etag) {
Ok(()) => true,
Err(e) => {
log::warn!("持久化同步状态失败(非致命): {e}");
false
}
}
}
fn compute_snapshot_id(artifacts: &BTreeMap<String, ArtifactMeta>) -> String {
let combined: String = artifacts
.iter()
.map(|(name, meta)| format!("{name}:{}", meta.sha256))
.collect::<Vec<_>>()
.join("|");
sha256_hex(combined.as_bytes())
}
fn detect_system_device_name() -> Option<String> {
let env_name = ["CC_SWITCH_DEVICE_NAME", "COMPUTERNAME", "HOSTNAME"]
.iter()
.filter_map(|key| std::env::var(key).ok())
.find_map(|value| normalize_device_name(&value));
if env_name.is_some() {
return env_name;
}
let output = std::process::Command::new("hostname").output().ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8(output.stdout).ok()?;
normalize_device_name(&hostname)
}
fn normalize_device_name(raw: &str) -> Option<String> {
let compact = raw
.chars()
.fold(String::with_capacity(raw.len()), |mut acc, ch| {
if ch.is_whitespace() {
acc.push(' ');
} else if !ch.is_control() {
acc.push(ch);
}
acc
});
let normalized = compact.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = normalized.trim();
if trimmed.is_empty() {
return None;
}
let limited = trimmed
.chars()
.take(MAX_DEVICE_NAME_LEN)
.collect::<String>();
if limited.is_empty() {
None
} else {
Some(limited)
}
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let hash = hasher.finalize();
format!("{hash:x}")
}
fn run_http<F, T>(future: F) -> Result<T, AppError>
where
F: std::future::Future<Output = Result<T, AppError>>,
{
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| {
localized(
"webdav.sync.runtime_create_failed",
format!("创建异步运行时失败: {e}"),
format!("Failed to create async runtime: {e}"),
)
})?;
runtime.block_on(future)
}
const V1_PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct V1Manifest {
format: String,
version: u32,
#[allow(dead_code)]
updated_at: String,
#[allow(dead_code)]
updated_by: String,
artifacts: V1ManifestArtifacts,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct V1ManifestArtifacts {
db_sql: V1ArtifactMeta,
skills_zip: V1ArtifactMeta,
settings_sync: V1ArtifactMeta,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct V1ArtifactMeta {
path: String,
sha256: String,
size: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct V1SyncableSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
language: Option<String>,
#[serde(default)]
skill_sync_method: crate::services::skill::SyncMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
security: Option<SecuritySettings>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
custom_endpoints_claude: BTreeMap<String, CustomEndpoint>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
custom_endpoints_codex: BTreeMap<String, CustomEndpoint>,
}
fn v1_remote_dir_segments(settings: &WebDavSyncSettings) -> Vec<String> {
let mut segments = Vec::new();
segments.extend(webdav::path_segments(&settings.remote_root).map(str::to_string));
segments.push(format!("v{V1_PROTOCOL_VERSION}"));
segments.extend(webdav::path_segments(&settings.profile).map(str::to_string));
segments
}
fn build_v1_artifact_url(
settings: &WebDavSyncSettings,
file_name: &str,
) -> Result<String, AppError> {
let mut segments = v1_remote_dir_segments(settings);
segments.extend(webdav::path_segments(file_name).map(str::to_string));
webdav::build_remote_url(&settings.base_url, &segments)
}
async fn detect_v1_manifest(
settings: &WebDavSyncSettings,
auth: &webdav::WebDavAuth,
) -> Result<Option<V1Manifest>, AppError> {
let url = build_v1_artifact_url(settings, REMOTE_MANIFEST)?;
let result = webdav::get_bytes(&url, auth, Some(MAX_MANIFEST_BYTES)).await?;
match result {
None => Ok(None),
Some((bytes, _)) => {
let manifest: V1Manifest = match serde_json::from_slice(&bytes) {
Ok(m) => m,
Err(e) => {
log::debug!("[WebDAV] V1 manifest parse failed, treating as absent: {e}");
return Ok(None);
}
};
if manifest.format != PROTOCOL_FORMAT || manifest.version != V1_PROTOCOL_VERSION {
return Ok(None);
}
Ok(Some(manifest))
}
}
}
async fn download_v1_artifact(
settings: &WebDavSyncSettings,
auth: &webdav::WebDavAuth,
file_name: &str,
meta: &V1ArtifactMeta,
) -> Result<Vec<u8>, AppError> {
if meta.size > MAX_SYNC_ARTIFACT_BYTES {
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
return Err(localized(
"webdav.sync.v1_artifact_too_large",
format!("V1 artifact {file_name} 超过下载上限({max_mb} MB)"),
format!("V1 artifact {file_name} exceeds download limit ({max_mb} MB)"),
));
}
let remote_path = meta.path.trim();
let remote_path = if remote_path.is_empty() {
file_name
} else {
remote_path
};
let url = build_v1_artifact_url(settings, remote_path)?;
let (bytes, _) = webdav::get_bytes(&url, auth, Some(MAX_SYNC_ARTIFACT_BYTES))
.await?
.ok_or_else(|| {
localized(
"webdav.sync.v1_artifact_missing",
format!("V1 远端缺少 artifact: {remote_path}"),
format!("V1 remote artifact missing: {remote_path}"),
)
})?;
if bytes.len() as u64 != meta.size {
return Err(localized(
"webdav.sync.v1_artifact_size_mismatch",
format!("V1 artifact {file_name} 大小不匹配"),
format!("V1 artifact {file_name} size mismatch"),
));
}
let actual_hash = sha256_hex(&bytes);
if actual_hash != meta.sha256 {
return Err(localized(
"webdav.sync.v1_artifact_hash_mismatch",
format!("V1 artifact {file_name} SHA256 校验失败"),
format!("V1 artifact {file_name} SHA256 verification failed"),
));
}
Ok(bytes)
}
fn parse_v1_syncable_settings(raw: &[u8]) -> Result<V1SyncableSettings, AppError> {
serde_json::from_slice(raw).map_err(|e| AppError::Json {
path: REMOTE_V1_SETTINGS_SYNC.to_string(),
source: e,
})
}
fn apply_v1_syncable_settings(incoming: V1SyncableSettings) -> Result<(), AppError> {
let mut settings = get_settings();
settings.language = incoming.language;
settings.skill_sync_method = incoming.skill_sync_method;
settings.security = incoming.security;
settings.custom_endpoints_claude = incoming.custom_endpoints_claude.into_iter().collect();
settings.custom_endpoints_codex = incoming.custom_endpoints_codex.into_iter().collect();
update_settings(settings)
}
async fn cleanup_v1_remote(settings: &WebDavSyncSettings, auth: &webdav::WebDavAuth) {
let segments = v1_remote_dir_segments(settings);
let url = match webdav::build_remote_url(&settings.base_url, &segments) {
Ok(u) => u,
Err(_) => return,
};
match webdav::delete_collection(&url, auth).await {
Ok(true) => log::info!("[WebDAV] V1 remote data cleaned up"),
Ok(false) => log::debug!("[WebDAV] V1 remote data already gone"),
Err(e) => log::warn!("[WebDAV] Failed to clean up V1 remote data: {e}"),
}
}
async fn migrate_v1_to_v2() -> Result<WebDavSyncSummary, AppError> {
let settings = load_webdav_settings()?;
let auth = webdav::auth_from_credentials(&settings.username, &settings.password);
let v1_manifest = detect_v1_manifest(&settings, &auth).await?.ok_or_else(|| {
localized(
"webdav.sync.v1_not_found",
"远端未找到 V1 同步数据",
"No V1 sync data found on the remote",
)
})?;
ensure_restore_allowed().await?;
let db_sql = download_v1_artifact(
&settings,
&auth,
REMOTE_DB_SQL,
&v1_manifest.artifacts.db_sql,
)
.await?;
let skills_zip = download_v1_artifact(
&settings,
&auth,
REMOTE_SKILLS_ZIP,
&v1_manifest.artifacts.skills_zip,
)
.await?;
let settings_sync = download_v1_artifact(
&settings,
&auth,
REMOTE_V1_SETTINGS_SYNC,
&v1_manifest.artifacts.settings_sync,
)
.await?;
let syncable_settings = parse_v1_syncable_settings(&settings_sync)?;
let _guard = crate::services::state_coordination::acquire_restore_mutation_guard()
.await
.map_err(AppError::Message)?;
ensure_restore_allowed().await?;
apply_snapshot(&db_sql, &skills_zip)?;
apply_v1_syncable_settings(syncable_settings)?;
drop(_guard);
upload().await?;
Ok(WebDavSyncSummary {
decision: SyncDecision::Download,
message: "V1 → V2 migration completed".to_string(),
})
}
async fn ensure_restore_allowed() -> Result<(), AppError> {
let db = std::sync::Arc::new(Database::init()?);
let proxy_service = crate::services::ProxyService::new(db);
let status = proxy_service.get_status().await;
if status.running {
return Err(localized(
"webdav.sync.restore_proxy_running",
"本地代理正在运行,请先停止代理后再执行 WebDAV 恢复或迁移",
"The local proxy is running. Stop it before WebDAV restore or migration.",
));
}
let takeover_active = proxy_service
.is_app_takeover_active(&crate::AppType::Claude)
.await
.map_err(AppError::Message)?
|| proxy_service
.is_app_takeover_active(&crate::AppType::Codex)
.await
.map_err(AppError::Message)?
|| proxy_service
.is_app_takeover_active(&crate::AppType::Gemini)
.await
.map_err(AppError::Message)?;
if takeover_active {
return Err(localized(
"webdav.sync.restore_takeover_active",
"当前仍有应用处于代理接管状态,请先关闭接管后再执行 WebDAV 恢复或迁移",
"An app takeover is still active. Disable takeover before WebDAV restore or migration.",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_settings() -> WebDavSyncSettings {
WebDavSyncSettings {
enabled: true,
base_url: "https://dav.example.com/remote.php/dav/files/demo/".to_string(),
remote_root: "cc switch-sync/team a".to_string(),
profile: "default profile".to_string(),
username: "demo".to_string(),
password: "secret".to_string(),
auto_sync: false,
status: WebDavSyncStatus::default(),
}
}
#[test]
fn remote_dir_segments_uses_current_layout() {
let mut settings = sample_settings();
settings.normalize();
let segments = remote_dir_segments(&settings, RemoteLayout::Current);
assert_eq!(
segments,
vec![
"cc switch-sync".to_string(),
"team a".to_string(),
"v2".to_string(),
"db-v6".to_string(),
"default profile".to_string(),
]
);
}
#[test]
fn build_artifact_url_encodes_path_segments() {
let mut settings = sample_settings();
settings.normalize();
let url = build_artifact_url(&settings, RemoteLayout::Current, REMOTE_MANIFEST)
.expect("build artifact url");
assert_eq!(
url,
"https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/team%20a/v2/db-v6/default%20profile/manifest.json"
);
assert!(
!url.contains("//cc"),
"url should not contain duplicated slash: {url}"
);
}
#[test]
fn snapshot_id_is_stable() {
let mut artifacts = BTreeMap::new();
artifacts.insert(
"db.sql".to_string(),
ArtifactMeta {
sha256: "aaa".to_string(),
size: 1,
},
);
artifacts.insert(
"skills.zip".to_string(),
ArtifactMeta {
sha256: "bbb".to_string(),
size: 2,
},
);
let id1 = compute_snapshot_id(&artifacts);
let id2 = compute_snapshot_id(&artifacts);
assert_eq!(id1, id2);
}
#[test]
fn snapshot_id_changes_with_artifacts() {
let mut artifacts_a = BTreeMap::new();
artifacts_a.insert(
"db.sql".to_string(),
ArtifactMeta {
sha256: "aaa".to_string(),
size: 1,
},
);
artifacts_a.insert(
"skills.zip".to_string(),
ArtifactMeta {
sha256: "bbb".to_string(),
size: 2,
},
);
let mut artifacts_b = artifacts_a.clone();
artifacts_b.get_mut("db.sql").unwrap().sha256 = "ccc".to_string();
assert_ne!(
compute_snapshot_id(&artifacts_a),
compute_snapshot_id(&artifacts_b)
);
}
fn manifest_with(format: &str, version: u32, db_compat_version: Option<u32>) -> SyncManifest {
SyncManifest {
format: format.to_string(),
version,
db_compat_version,
device_name: "test".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
artifacts: BTreeMap::new(),
snapshot_id: "id".to_string(),
}
}
#[test]
fn validate_manifest_compat_accepts_supported_manifest() {
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok());
}
#[test]
fn validate_manifest_compat_wrong_format() {
let manifest = manifest_with("wrong-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
}
#[test]
fn validate_manifest_compat_wrong_version() {
let manifest = manifest_with(PROTOCOL_FORMAT, 999, Some(DB_COMPAT_VERSION));
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
}
#[test]
fn validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat() {
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(5));
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
}
#[test]
fn remote_dir_segments_uses_legacy_layout() {
let mut settings = sample_settings();
settings.normalize();
let segments = remote_dir_segments(&settings, RemoteLayout::Legacy);
assert_eq!(
segments,
vec![
"cc switch-sync".to_string(),
"team a".to_string(),
"v2".to_string(),
"default profile".to_string(),
]
);
}
#[test]
fn validate_manifest_compat_accepts_legacy_manifest_without_db_compat() {
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_ok());
}
#[test]
fn validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation() {
let manifest = manifest_with(
PROTOCOL_FORMAT,
PROTOCOL_VERSION,
Some(DB_COMPAT_VERSION + 1),
);
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_err());
}
#[test]
fn effective_db_compat_version_defaults_legacy_layout_to_v5() {
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
assert_eq!(
effective_db_compat_version(&manifest, RemoteLayout::Legacy),
Some(LEGACY_DB_COMPAT_VERSION)
);
assert_eq!(
effective_db_compat_version(&manifest, RemoteLayout::Current),
None
);
}
#[test]
fn validate_artifact_size_limit_ok() {
assert!(validate_artifact_size_limit("db.sql", 1024).is_ok());
}
#[test]
fn validate_artifact_size_limit_exceeded() {
assert!(validate_artifact_size_limit("db.sql", MAX_SYNC_ARTIFACT_BYTES + 1).is_err());
}
#[test]
fn normalize_device_name_trims() {
assert_eq!(
normalize_device_name(" my-host "),
Some("my-host".to_string())
);
}
#[test]
fn normalize_device_name_empty() {
assert_eq!(normalize_device_name(""), None);
assert_eq!(normalize_device_name(" "), None);
}
#[test]
fn normalize_device_name_truncates() {
let long = "a".repeat(100);
let result = normalize_device_name(&long).unwrap();
assert_eq!(result.chars().count(), MAX_DEVICE_NAME_LEN);
}
#[test]
fn normalize_device_name_collapses_whitespace() {
assert_eq!(
normalize_device_name(" Mac Book Pro "),
Some("Mac Book Pro".to_string())
);
}
#[test]
fn normalize_device_name_truncates_by_chars_not_bytes() {
let long_cn = "测".repeat(80);
let result = normalize_device_name(&long_cn).unwrap();
assert_eq!(result.chars().count(), MAX_DEVICE_NAME_LEN);
}
#[test]
fn detect_system_device_name_returns_some() {
let name = detect_system_device_name();
assert!(name.is_some(), "should detect a device name");
}
#[test]
fn manifest_serialization_uses_device_name_only() {
let manifest = SyncManifest {
format: PROTOCOL_FORMAT.to_string(),
version: PROTOCOL_VERSION,
db_compat_version: Some(DB_COMPAT_VERSION),
device_name: "My MacBook".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
artifacts: BTreeMap::new(),
snapshot_id: "snap-1".to_string(),
};
let value = serde_json::to_value(&manifest).expect("serialize manifest");
assert!(
value.get("deviceName").is_some(),
"manifest should contain deviceName"
);
assert!(
value.get("deviceId").is_none(),
"manifest should not contain deviceId"
);
assert_eq!(
value.get("dbCompatVersion").and_then(|v| v.as_u64()),
Some(DB_COMPAT_VERSION as u64)
);
}
}