mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
c0737f2cfe
Separate protocol version from database compatibility version in WebDAV sync paths. Upload writes to v2/db-v6/<profile>, download falls back to legacy v2/<profile> when current path has no data. Extend manifest with optional dbCompatVersion field and add legacy layout detection to UI.
892 lines
29 KiB
Rust
892 lines
29 KiB
Rust
//! WebDAV v2 sync protocol layer with DB compatibility subdirectories.
|
||
//!
|
||
//! Implements manifest-based synchronization on top of the HTTP transport
|
||
//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::fs;
|
||
use std::future::Future;
|
||
use std::process::Command;
|
||
use std::sync::OnceLock;
|
||
|
||
use chrono::Utc;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::Value;
|
||
use sha2::{Digest, Sha256};
|
||
use tempfile::tempdir;
|
||
|
||
use crate::error::AppError;
|
||
use crate::services::webdav::{
|
||
auth_from_credentials, build_remote_url, ensure_remote_directories, get_bytes, head_etag,
|
||
path_segments, put_bytes, test_connection, WebDavAuth,
|
||
};
|
||
use crate::settings::{update_webdav_sync_status, WebDavSyncSettings, WebDavSyncStatus};
|
||
|
||
mod archive;
|
||
use archive::{
|
||
backup_current_skills, restore_skills_from_backup, restore_skills_zip, zip_skills_ssot,
|
||
};
|
||
|
||
// ─── Protocol constants ──────────────────────────────────────
|
||
|
||
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_MANIFEST: &str = "manifest.json";
|
||
const MAX_DEVICE_NAME_LEN: usize = 64;
|
||
const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
|
||
pub(super) const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
|
||
|
||
pub fn sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||
}
|
||
|
||
pub async fn run_with_sync_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||
where
|
||
Fut: Future<Output = Result<T, AppError>>,
|
||
{
|
||
let _guard = sync_mutex().lock().await;
|
||
operation.await
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
// ─── Types ───────────────────────────────────────────────────
|
||
|
||
#[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>,
|
||
manifest_bytes: Vec<u8>,
|
||
manifest_hash: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum RemoteLayout {
|
||
Current,
|
||
Legacy,
|
||
}
|
||
|
||
impl RemoteLayout {
|
||
fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Current => "current",
|
||
Self::Legacy => "legacy",
|
||
}
|
||
}
|
||
}
|
||
|
||
struct RemoteSnapshot {
|
||
layout: RemoteLayout,
|
||
manifest: SyncManifest,
|
||
manifest_bytes: Vec<u8>,
|
||
manifest_etag: Option<String>,
|
||
}
|
||
|
||
// ─── Public API ──────────────────────────────────────────────
|
||
|
||
/// Check WebDAV connectivity and ensure remote directory structure.
|
||
pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppError> {
|
||
settings.validate()?;
|
||
let auth = auth_for(settings);
|
||
test_connection(&settings.base_url, &auth).await?;
|
||
let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);
|
||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Upload local snapshot (db + skills) to remote.
|
||
pub async fn upload(
|
||
db: &crate::database::Database,
|
||
settings: &mut WebDavSyncSettings,
|
||
) -> Result<Value, AppError> {
|
||
settings.validate()?;
|
||
let auth = auth_for(settings);
|
||
let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);
|
||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||
|
||
let snapshot = build_local_snapshot(db, settings)?;
|
||
|
||
// Upload order: artifacts first, manifest last (best-effort consistency)
|
||
let db_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_DB_SQL)?;
|
||
put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?;
|
||
|
||
let skills_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_SKILLS_ZIP)?;
|
||
put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?;
|
||
|
||
let manifest_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_MANIFEST)?;
|
||
put_bytes(
|
||
&manifest_url,
|
||
&auth,
|
||
snapshot.manifest_bytes,
|
||
"application/json",
|
||
)
|
||
.await?;
|
||
|
||
// Fetch etag (best-effort, don't fail the upload)
|
||
let etag = match head_etag(&manifest_url, &auth).await {
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
log::debug!("[WebDAV] Failed to fetch ETag after upload: {e}");
|
||
None
|
||
}
|
||
};
|
||
|
||
let _persisted = persist_sync_success_best_effort(
|
||
settings,
|
||
snapshot.manifest_hash,
|
||
etag,
|
||
persist_sync_success,
|
||
);
|
||
Ok(serde_json::json!({ "status": "uploaded" }))
|
||
}
|
||
|
||
/// Download remote snapshot and apply to local database + skills.
|
||
pub async fn download(
|
||
db: &crate::database::Database,
|
||
settings: &mut WebDavSyncSettings,
|
||
) -> Result<Value, AppError> {
|
||
settings.validate()?;
|
||
let auth = auth_for(settings);
|
||
let snapshot = find_remote_snapshot(settings, &auth)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
localized(
|
||
"webdav.sync.remote_empty",
|
||
"远端没有可下载的同步数据",
|
||
"No downloadable sync data found on the remote.",
|
||
)
|
||
})?;
|
||
|
||
validate_manifest_compat(&snapshot.manifest, snapshot.layout)?;
|
||
|
||
// Download and verify artifacts
|
||
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?;
|
||
|
||
// Apply snapshot
|
||
apply_snapshot(db, &db_sql, &skills_zip)?;
|
||
|
||
let manifest_hash = sha256_hex(&snapshot.manifest_bytes);
|
||
let _persisted = persist_sync_success_best_effort(
|
||
settings,
|
||
manifest_hash,
|
||
snapshot.manifest_etag,
|
||
persist_sync_success,
|
||
);
|
||
Ok(serde_json::json!({
|
||
"status": "downloaded",
|
||
"sourceLayout": snapshot.layout.as_str(),
|
||
"sourcePath": remote_dir_display(settings, snapshot.layout),
|
||
}))
|
||
}
|
||
|
||
/// Fetch remote manifest info without downloading artifacts.
|
||
pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<Value>, AppError> {
|
||
settings.validate()?;
|
||
let auth = auth_for(settings);
|
||
let Some(snapshot) = find_remote_snapshot(settings, &auth).await? else {
|
||
return Ok(None);
|
||
};
|
||
let compatible = validate_manifest_compat(&snapshot.manifest, snapshot.layout).is_ok();
|
||
let db_compat_version = effective_db_compat_version(&snapshot.manifest, snapshot.layout);
|
||
|
||
let payload = serde_json::json!({
|
||
"deviceName": snapshot.manifest.device_name,
|
||
"createdAt": snapshot.manifest.created_at,
|
||
"snapshotId": snapshot.manifest.snapshot_id,
|
||
"version": snapshot.manifest.version,
|
||
"protocolVersion": snapshot.manifest.version,
|
||
"dbCompatVersion": db_compat_version,
|
||
"compatible": compatible,
|
||
"artifacts": snapshot.manifest.artifacts.keys().collect::<Vec<_>>(),
|
||
"layout": snapshot.layout.as_str(),
|
||
"remotePath": remote_dir_display(settings, snapshot.layout),
|
||
});
|
||
|
||
Ok(Some(payload))
|
||
}
|
||
|
||
// ─── Sync status persistence (I3: deduplicated) ─────────────
|
||
|
||
fn persist_sync_success(
|
||
settings: &mut WebDavSyncSettings,
|
||
manifest_hash: String,
|
||
etag: Option<String>,
|
||
) -> Result<(), AppError> {
|
||
let status = WebDavSyncStatus {
|
||
last_sync_at: Some(Utc::now().timestamp()),
|
||
last_error: None,
|
||
last_error_source: None,
|
||
last_local_manifest_hash: Some(manifest_hash.clone()),
|
||
last_remote_manifest_hash: Some(manifest_hash),
|
||
last_remote_etag: etag,
|
||
};
|
||
settings.status = status.clone();
|
||
update_webdav_sync_status(status)
|
||
}
|
||
|
||
fn persist_sync_success_best_effort<F>(
|
||
settings: &mut WebDavSyncSettings,
|
||
manifest_hash: String,
|
||
etag: Option<String>,
|
||
persist_fn: F,
|
||
) -> bool
|
||
where
|
||
F: FnOnce(&mut WebDavSyncSettings, String, Option<String>) -> Result<(), AppError>,
|
||
{
|
||
match persist_fn(settings, manifest_hash, etag) {
|
||
Ok(()) => true,
|
||
Err(err) => {
|
||
log::warn!("[WebDAV] Persist sync status failed, keep operation success: {err}");
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Snapshot building ───────────────────────────────────────
|
||
|
||
fn build_local_snapshot(
|
||
db: &crate::database::Database,
|
||
_settings: &WebDavSyncSettings,
|
||
) -> Result<LocalSnapshot, AppError> {
|
||
// Export database to SQL string
|
||
let sql_string = db.export_sql_string_for_sync()?;
|
||
let db_sql = sql_string.into_bytes();
|
||
|
||
// Pack skills into deterministic ZIP
|
||
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 skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);
|
||
zip_skills_ssot(&skills_zip_path)?;
|
||
let skills_zip = fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?;
|
||
|
||
// Build artifact map and compute hashes
|
||
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,
|
||
},
|
||
);
|
||
|
||
let snapshot_id = compute_snapshot_id(&artifacts);
|
||
let manifest = SyncManifest {
|
||
format: PROTOCOL_FORMAT.to_string(),
|
||
version: PROTOCOL_VERSION,
|
||
db_compat_version: Some(DB_COMPAT_VERSION),
|
||
device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()),
|
||
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,
|
||
manifest_bytes,
|
||
manifest_hash,
|
||
})
|
||
}
|
||
|
||
/// Compute a deterministic snapshot identity from artifact hashes.
|
||
///
|
||
/// BTreeMap iteration order is sorted by key, ensuring stability.
|
||
fn compute_snapshot_id(artifacts: &BTreeMap<String, ArtifactMeta>) -> String {
|
||
let parts: Vec<String> = artifacts
|
||
.iter()
|
||
.map(|(name, meta)| format!("{}:{}", name, meta.sha256))
|
||
.collect();
|
||
sha256_hex(parts.join("|").as_bytes())
|
||
}
|
||
|
||
fn sha256_hex(bytes: &[u8]) -> String {
|
||
let mut hasher = Sha256::new();
|
||
hasher.update(bytes);
|
||
format!("{:x}", hasher.finalize())
|
||
}
|
||
|
||
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 = 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 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: &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: &WebDavAuth,
|
||
layout: RemoteLayout,
|
||
) -> Result<Option<RemoteSnapshot>, AppError> {
|
||
let manifest_url = remote_file_url(settings, layout, REMOTE_MANIFEST)?;
|
||
let Some((manifest_bytes, manifest_etag)) =
|
||
get_bytes(&manifest_url, auth, 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,
|
||
}))
|
||
}
|
||
|
||
// ─── Download & verify ───────────────────────────────────────
|
||
|
||
async fn download_and_verify(
|
||
settings: &WebDavSyncSettings,
|
||
auth: &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 = remote_file_url(settings, layout, artifact_name)?;
|
||
let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
localized(
|
||
"webdav.sync.remote_missing_artifact",
|
||
format!("远端缺少 artifact 文件: {artifact_name}"),
|
||
format!("Remote artifact file missing: {artifact_name}"),
|
||
)
|
||
})?;
|
||
|
||
// Quick size check before expensive hash
|
||
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 apply_snapshot(
|
||
db: &crate::database::Database,
|
||
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 = backup_current_skills()?;
|
||
|
||
// 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免“半恢复”。
|
||
restore_skills_zip(skills_zip)?;
|
||
|
||
if let Err(db_err) = db.import_sql_string_for_sync(sql_str) {
|
||
if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) {
|
||
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(())
|
||
}
|
||
|
||
// ─── Remote path helpers ─────────────────────────────────────
|
||
|
||
fn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec<String> {
|
||
let mut segs = Vec::new();
|
||
segs.extend(path_segments(&settings.remote_root).map(str::to_string));
|
||
segs.push(format!("v{PROTOCOL_VERSION}"));
|
||
if layout == RemoteLayout::Current {
|
||
segs.push(format!("db-v{DB_COMPAT_VERSION}"));
|
||
}
|
||
segs.extend(path_segments(&settings.profile).map(str::to_string));
|
||
segs
|
||
}
|
||
|
||
fn remote_file_url(
|
||
settings: &WebDavSyncSettings,
|
||
layout: RemoteLayout,
|
||
file_name: &str,
|
||
) -> Result<String, AppError> {
|
||
let mut segs = remote_dir_segments(settings, layout);
|
||
segs.extend(path_segments(file_name).map(str::to_string));
|
||
build_remote_url(&settings.base_url, &segs)
|
||
}
|
||
|
||
fn remote_dir_display(settings: &WebDavSyncSettings, layout: RemoteLayout) -> String {
|
||
let segs = remote_dir_segments(settings, layout);
|
||
format!("/{}", segs.join("/"))
|
||
}
|
||
|
||
fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {
|
||
auth_from_credentials(&settings.username, &settings.password)
|
||
}
|
||
|
||
fn validate_artifact_size_limit(artifact_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 {artifact_name} 超过下载上限({} MB)", max_mb),
|
||
format!(
|
||
"Artifact {artifact_name} exceeds download limit ({} MB)",
|
||
max_mb
|
||
),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ─── Tests ───────────────────────────────────────────────────
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn artifact(sha256: &str, size: u64) -> ArtifactMeta {
|
||
ArtifactMeta {
|
||
sha256: sha256.to_string(),
|
||
size,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn snapshot_id_is_stable() {
|
||
let mut artifacts = BTreeMap::new();
|
||
artifacts.insert("db.sql".to_string(), artifact("abc123", 100));
|
||
artifacts.insert("skills.zip".to_string(), artifact("def456", 200));
|
||
|
||
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 a1 = BTreeMap::new();
|
||
a1.insert("db.sql".to_string(), artifact("hash-a", 1));
|
||
|
||
let mut a2 = BTreeMap::new();
|
||
a2.insert("db.sql".to_string(), artifact("hash-b", 1));
|
||
|
||
assert_ne!(compute_snapshot_id(&a1), compute_snapshot_id(&a2));
|
||
}
|
||
|
||
#[test]
|
||
fn remote_dir_segments_uses_current_layout() {
|
||
let settings = WebDavSyncSettings {
|
||
remote_root: "cc-switch-sync".to_string(),
|
||
profile: "default".to_string(),
|
||
..WebDavSyncSettings::default()
|
||
};
|
||
let segs = remote_dir_segments(&settings, RemoteLayout::Current);
|
||
assert_eq!(segs, vec!["cc-switch-sync", "v2", "db-v6", "default"]);
|
||
}
|
||
|
||
#[test]
|
||
fn remote_dir_segments_uses_legacy_layout() {
|
||
let settings = WebDavSyncSettings {
|
||
remote_root: "cc-switch-sync".to_string(),
|
||
profile: "default".to_string(),
|
||
..WebDavSyncSettings::default()
|
||
};
|
||
let segs = remote_dir_segments(&settings, RemoteLayout::Legacy);
|
||
assert_eq!(segs, vec!["cc-switch-sync", "v2", "default"]);
|
||
}
|
||
|
||
#[test]
|
||
fn sha256_hex_is_correct() {
|
||
let hash = sha256_hex(b"hello");
|
||
assert_eq!(
|
||
hash,
|
||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn persist_best_effort_returns_true_on_success() {
|
||
let mut settings = WebDavSyncSettings::default();
|
||
let ok = persist_sync_success_best_effort(
|
||
&mut settings,
|
||
"hash".to_string(),
|
||
Some("etag".to_string()),
|
||
|_settings, _hash, _etag| Ok(()),
|
||
);
|
||
assert!(ok);
|
||
}
|
||
|
||
#[test]
|
||
fn persist_best_effort_returns_false_on_error() {
|
||
let mut settings = WebDavSyncSettings::default();
|
||
let ok = persist_sync_success_best_effort(
|
||
&mut settings,
|
||
"hash".to_string(),
|
||
None,
|
||
|_settings, _hash, _etag| Err(AppError::Config("boom".to_string())),
|
||
);
|
||
assert!(!ok);
|
||
}
|
||
|
||
fn manifest_with(format: &str, version: u32, db_compat_version: Option<u32>) -> SyncManifest {
|
||
let mut artifacts = BTreeMap::new();
|
||
artifacts.insert("db.sql".to_string(), artifact("abc", 1));
|
||
artifacts.insert("skills.zip".to_string(), artifact("def", 2));
|
||
SyncManifest {
|
||
format: format.to_string(),
|
||
version,
|
||
db_compat_version,
|
||
device_name: "My MacBook".to_string(),
|
||
created_at: "2026-02-12T00:00:00Z".to_string(),
|
||
artifacts,
|
||
snapshot_id: "snap-1".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_rejects_wrong_format() {
|
||
let manifest = manifest_with("other-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_manifest_compat_rejects_wrong_version() {
|
||
let manifest = manifest_with(
|
||
PROTOCOL_FORMAT,
|
||
PROTOCOL_VERSION + 1,
|
||
Some(DB_COMPAT_VERSION),
|
||
);
|
||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||
}
|
||
|
||
#[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_current_manifest_with_wrong_db_compat() {
|
||
let manifest = manifest_with(
|
||
PROTOCOL_FORMAT,
|
||
PROTOCOL_VERSION,
|
||
Some(LEGACY_DB_COMPAT_VERSION),
|
||
);
|
||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||
}
|
||
|
||
#[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 normalize_device_name_returns_none_for_blank_input() {
|
||
assert_eq!(normalize_device_name(" \n\t "), None);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_device_name_collapses_whitespace_and_drops_control_chars() {
|
||
assert_eq!(
|
||
normalize_device_name(" Mac\tBook \n Pro\u{0007} "),
|
||
Some("Mac Book Pro".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_device_name_truncates_to_max_len() {
|
||
let long = "a".repeat(80);
|
||
assert_eq!(normalize_device_name(&long).map(|s| s.len()), Some(64));
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_serialization_uses_device_name_only() {
|
||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||
let value = serde_json::to_value(&manifest).expect("serialize manifest");
|
||
assert!(
|
||
value.get("deviceName").is_some(),
|
||
"manifest should contain deviceName"
|
||
);
|
||
assert_eq!(
|
||
value.get("dbCompatVersion").and_then(|v| v.as_u64()),
|
||
Some(DB_COMPAT_VERSION as u64)
|
||
);
|
||
assert!(
|
||
value.get("deviceId").is_none(),
|
||
"manifest should not contain deviceId"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn validate_artifact_size_limit_rejects_oversized_artifacts() {
|
||
let err = validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES + 1)
|
||
.expect_err("artifact larger than limit should be rejected");
|
||
assert!(
|
||
err.to_string().contains("too large") || err.to_string().contains("超过"),
|
||
"unexpected error: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn validate_artifact_size_limit_accepts_limit_boundary() {
|
||
assert!(validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES).is_ok());
|
||
}
|
||
}
|