mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 18:05:37 +08:00
Webdav (#923)
* feat: WebDAV backup/restore - Add WebDAV test/backup/restore commands and settings\n- Fix ja i18n missing keys; decode PROPFIND href as UTF-8\n- Stabilize Windows prompt auto-import tests via CC_SWITCH_TEST_HOME * chore: format and minor cleanups * fix: update build config * feat(webdav): unify sync UX and hardening fixes * fix(webdav): harden sync flow and stabilize sync UX/tests * fix(webdav): add resource limits to skills.zip extraction Prevent zip bomb / resource exhaustion by enforcing: - MAX_EXTRACT_ENTRIES (10,000 files) - MAX_EXTRACT_BYTES (512 MB cumulative) * refactor(webdav): drop deviceId and display deviceName only --------- Co-authored-by: small-lovely-cat <77799160+small-lovely-cat@users.noreply.github.com> Co-authored-by: saladday <1203511142@qq.com>
This commit is contained in:
@@ -0,0 +1,649 @@
|
||||
//! 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::process::Command;
|
||||
|
||||
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;
|
||||
|
||||
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).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).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_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}"),
|
||||
)
|
||||
})?;
|
||||
let url = remote_file_url(settings, artifact_name)?;
|
||||
let (bytes, _) = get_bytes(&url, auth).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)
|
||||
}
|
||||
|
||||
// ─── 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user