mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
20f62bf4f8
* feat(webdav): add robust auto sync with failure feedback (cherry picked from commit bb6760124a62a964b36902c004e173534910728f) * fix(webdav): enforce bounded download and extraction size (cherry picked from commit 7777d6ec2b9bba07c8bbba9b04fe3ea6b15e0e79) * fix(webdav): only show auto-sync callout for auto-source errors * refactor(webdav): remove services->commands auto-sync dependency
705 lines
23 KiB
Rust
705 lines
23 KiB
Rust
//! WebDAV v2 sync protocol layer.
|
||
//!
|
||
//! 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 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,
|
||
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,
|
||
}
|
||
|
||
// ─── 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);
|
||
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);
|
||
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, REMOTE_DB_SQL)?;
|
||
put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?;
|
||
|
||
let skills_url = remote_file_url(settings, REMOTE_SKILLS_ZIP)?;
|
||
put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?;
|
||
|
||
let manifest_url = remote_file_url(settings, 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 manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
localized(
|
||
"webdav.sync.remote_empty",
|
||
"远端没有可下载的同步数据",
|
||
"No downloadable sync data found on the remote.",
|
||
)
|
||
})?;
|
||
|
||
let manifest: SyncManifest =
|
||
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
||
path: REMOTE_MANIFEST.to_string(),
|
||
source: e,
|
||
})?;
|
||
|
||
validate_manifest_compat(&manifest)?;
|
||
|
||
// Download and verify artifacts
|
||
let db_sql = download_and_verify(settings, &auth, REMOTE_DB_SQL, &manifest.artifacts).await?;
|
||
let skills_zip =
|
||
download_and_verify(settings, &auth, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?;
|
||
|
||
// Apply snapshot
|
||
apply_snapshot(db, &db_sql, &skills_zip)?;
|
||
|
||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||
let _persisted =
|
||
persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success);
|
||
Ok(serde_json::json!({ "status": "downloaded" }))
|
||
}
|
||
|
||
/// 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 manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||
|
||
let Some((bytes, _)) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES).await? else {
|
||
return Ok(None);
|
||
};
|
||
|
||
let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json {
|
||
path: REMOTE_MANIFEST.to_string(),
|
||
source: e,
|
||
})?;
|
||
|
||
let compatible = validate_manifest_compat(&manifest).is_ok();
|
||
|
||
let payload = serde_json::json!({
|
||
"deviceName": manifest.device_name,
|
||
"createdAt": manifest.created_at,
|
||
"snapshotId": manifest.snapshot_id,
|
||
"version": manifest.version,
|
||
"compatible": compatible,
|
||
"artifacts": manifest.artifacts.keys().collect::<Vec<_>>(),
|
||
});
|
||
|
||
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()?;
|
||
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,
|
||
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 validate_manifest_compat(manifest: &SyncManifest) -> 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
|
||
),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ─── Download & verify ───────────────────────────────────────
|
||
|
||
async fn download_and_verify(
|
||
settings: &WebDavSyncSettings,
|
||
auth: &WebDavAuth,
|
||
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, 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(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) -> Vec<String> {
|
||
let mut segs = Vec::new();
|
||
segs.extend(path_segments(&settings.remote_root).map(str::to_string));
|
||
segs.push(format!("v{PROTOCOL_VERSION}"));
|
||
segs.extend(path_segments(&settings.profile).map(str::to_string));
|
||
segs
|
||
}
|
||
|
||
fn remote_file_url(settings: &WebDavSyncSettings, file_name: &str) -> Result<String, AppError> {
|
||
let mut segs = remote_dir_segments(settings);
|
||
segs.extend(path_segments(file_name).map(str::to_string));
|
||
build_remote_url(&settings.base_url, &segs)
|
||
}
|
||
|
||
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_v2() {
|
||
let settings = WebDavSyncSettings {
|
||
remote_root: "cc-switch-sync".to_string(),
|
||
profile: "default".to_string(),
|
||
..WebDavSyncSettings::default()
|
||
};
|
||
let segs = remote_dir_segments(&settings);
|
||
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) -> 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,
|
||
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);
|
||
assert!(validate_manifest_compat(&manifest).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_manifest_compat_rejects_wrong_format() {
|
||
let manifest = manifest_with("other-format", PROTOCOL_VERSION);
|
||
assert!(validate_manifest_compat(&manifest).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validate_manifest_compat_rejects_wrong_version() {
|
||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1);
|
||
assert!(validate_manifest_compat(&manifest).is_err());
|
||
}
|
||
|
||
#[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);
|
||
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"
|
||
);
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|