mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
feat: 新增 S3 兼容云存储同步 (#1351)
* Add S3 Cloud Sync design document Design for adding AWS S3 as a new Cloud Sync backend alongside WebDAV. Hybrid approach: extract shared sync protocol, add independent S3 transport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add S3 cloud sync implementation design (reqwest + Sig V4) Updated design based on 2026-03-06 draft: switches from rust-s3 crate to hand-rolled AWS Sig V4 on existing reqwest for broader S3-compatible service support (AWS, MinIO, R2, Alibaba OSS, Tencent COS, Huawei OBS). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add S3 cloud sync implementation plan (11 tasks, TDD) Detailed step-by-step plan covering: sync_protocol extraction, S3 Sig V4 transport, settings, sync/auto-sync modules, Tauri commands, frontend presets/dynamic form, and i18n. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * deps: add hmac crate for S3 Sig V4 signing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract sync_protocol.rs from webdav_sync.rs for shared use Move transport-agnostic sync protocol logic (constants, types, snapshot building, manifest validation, artifact verification, snapshot application, utilities) into a new shared sync_protocol module so both WebDAV and the upcoming S3 transport can reuse it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use transport-neutral error keys in sync_protocol Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 transport layer with AWS Sig V4 signing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3SyncSettings to AppSettings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync module with upload/download/fetch Implements the S3 sync protocol layer (s3_sync.rs) that combines the shared sync_protocol with the S3 transport. Mirrors the WebDAV sync module structure with independent sync mutex, connection check, upload, download, fetch_remote_info, and sync status persistence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 auto sync worker with debounce Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync Tauri commands and auto sync worker startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync TypeScript types and API layer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync i18n translations (en/zh/ja) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync presets and dynamic form to sync settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: preserve HTTP scheme for S3 custom endpoints (MinIO support) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add live S3 integration tests (env-var driven, --ignored) Run with: S3_TEST_AK=... S3_TEST_SK=... S3_TEST_BUCKET=... cargo test --lib services::s3::integration_tests -- --ignored Verifies test_connection, put_object, get_object, head_object, and 404 handling against a real S3 bucket using the project's own Sig V4 signing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove internal design docs before PR * fix: wire S3 auto-sync to DB hook & sync UI state on async load - P1: Add s3_auto_sync::notify_db_changed call in SQLite update_hook so S3 auto-sync worker receives DB change signals (was only wired for WebDAV, leaving S3 worker idle) - P2: Add useEffect to update syncType selector when s3Config loads asynchronously, preventing stale "webdav" default for S3 users * fix: satisfy clippy for s3 sync * fix: address s3 sync review feedback --------- Co-authored-by: Keith (via OpenClaw) <keithyt06@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -29,6 +29,7 @@ mod subscription;
|
||||
mod sync_support;
|
||||
|
||||
mod lightweight;
|
||||
mod s3_sync;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
@@ -61,6 +62,7 @@ pub use stream_check::*;
|
||||
pub use subscription::*;
|
||||
|
||||
pub use lightweight::*;
|
||||
pub use s3_sync::*;
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::sync_support::{
|
||||
attach_warning, post_sync_warning_from_result, run_post_import_sync,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::services::s3_sync as s3_sync_service;
|
||||
use crate::settings::{self, S3SyncSettings};
|
||||
use crate::store::AppState;
|
||||
|
||||
fn persist_sync_error(settings: &mut S3SyncSettings, error: &AppError, source: &str) {
|
||||
settings.status.last_error = Some(error.to_string());
|
||||
settings.status.last_error_source = Some(source.to_string());
|
||||
let _ = settings::update_s3_sync_status(settings.status.clone());
|
||||
}
|
||||
|
||||
fn s3_not_configured_error() -> String {
|
||||
AppError::localized(
|
||||
"s3.sync.not_configured",
|
||||
"未配置 S3 同步",
|
||||
"S3 sync is not configured.",
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn s3_sync_disabled_error() -> String {
|
||||
AppError::localized("s3.sync.disabled", "S3 同步未启用", "S3 sync is disabled.").to_string()
|
||||
}
|
||||
|
||||
fn require_enabled_s3_settings() -> Result<S3SyncSettings, String> {
|
||||
let settings = settings::get_s3_sync_settings().ok_or_else(s3_not_configured_error)?;
|
||||
if !settings.enabled {
|
||||
return Err(s3_sync_disabled_error());
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn resolve_secret_for_request(
|
||||
mut incoming: S3SyncSettings,
|
||||
existing: Option<S3SyncSettings>,
|
||||
preserve_empty_secret: bool,
|
||||
) -> S3SyncSettings {
|
||||
if let Some(existing_settings) = existing {
|
||||
if preserve_empty_secret && incoming.secret_access_key.is_empty() {
|
||||
incoming.secret_access_key = existing_settings.secret_access_key;
|
||||
}
|
||||
}
|
||||
incoming
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn s3_sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||
s3_sync_service::sync_mutex()
|
||||
}
|
||||
|
||||
async fn run_with_s3_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||||
where
|
||||
Fut: std::future::Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
s3_sync_service::run_with_sync_lock(operation).await
|
||||
}
|
||||
|
||||
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(&AppError),
|
||||
{
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
on_error(&err);
|
||||
Err(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_test_connection(
|
||||
settings: S3SyncSettings,
|
||||
#[allow(non_snake_case)] preserveEmptyPassword: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let preserve_empty = preserveEmptyPassword.unwrap_or(true);
|
||||
let resolved =
|
||||
resolve_secret_for_request(settings, settings::get_s3_sync_settings(), preserve_empty);
|
||||
s3_sync_service::check_connection(&resolved)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"message": "S3 connection ok"
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_upload(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let mut settings = require_enabled_s3_settings()?;
|
||||
|
||||
let result = run_with_s3_lock(s3_sync_service::upload(&db, &mut settings)).await;
|
||||
map_sync_result(result, |error| {
|
||||
persist_sync_error(&mut settings, error, "manual")
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
let mut settings = require_enabled_s3_settings()?;
|
||||
let _auto_sync_suppression = crate::services::s3_auto_sync::AutoSyncSuppressionGuard::new();
|
||||
|
||||
let sync_result = run_with_s3_lock(s3_sync_service::download(&db, &mut settings)).await;
|
||||
let mut result = map_sync_result(sync_result, |error| {
|
||||
persist_sync_error(&mut settings, error, "manual")
|
||||
})?;
|
||||
|
||||
// Post-download sync is best-effort: snapshot restore has already succeeded.
|
||||
let warning = post_sync_warning_from_result(
|
||||
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
if let Some(msg) = warning.as_ref() {
|
||||
log::warn!("[S3] post-download sync warning: {msg}");
|
||||
}
|
||||
result = attach_warning(result, warning);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_save_settings(
|
||||
settings: S3SyncSettings,
|
||||
#[allow(non_snake_case)] passwordTouched: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let password_touched = passwordTouched.unwrap_or(false);
|
||||
let existing = settings::get_s3_sync_settings();
|
||||
let mut sync_settings =
|
||||
resolve_secret_for_request(settings, existing.clone(), !password_touched);
|
||||
|
||||
// Preserve server-owned fields that the frontend does not manage
|
||||
if let Some(existing_settings) = existing {
|
||||
sync_settings.status = existing_settings.status;
|
||||
}
|
||||
|
||||
sync_settings.normalize();
|
||||
sync_settings.validate().map_err(|e| e.to_string())?;
|
||||
settings::set_s3_sync_settings(Some(sync_settings)).map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "success": true }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_fetch_remote_info() -> Result<Value, String> {
|
||||
let settings = require_enabled_s3_settings()?;
|
||||
let info = s3_sync_service::fetch_remote_info(&settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(info.unwrap_or(json!({ "empty": true })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
map_sync_result, persist_sync_error, require_enabled_s3_settings,
|
||||
resolve_secret_for_request, run_with_s3_lock, s3_sync_mutex,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::settings::{AppSettings, S3SyncSettings};
|
||||
use serial_test::serial;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn s3_sync_mutex_is_singleton() {
|
||||
let a = s3_sync_mutex() as *const _;
|
||||
let b = s3_sync_mutex() as *const _;
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn s3_sync_mutex_serializes_concurrent_access() {
|
||||
let guard = s3_sync_mutex().lock().await;
|
||||
let acquired = Arc::new(AtomicBool::new(false));
|
||||
let acquired_bg = Arc::clone(&acquired);
|
||||
|
||||
let waiter = tokio::spawn(async move {
|
||||
let _inner_guard = s3_sync_mutex().lock().await;
|
||||
acquired_bg.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
assert!(!acquired.load(Ordering::SeqCst));
|
||||
|
||||
drop(guard);
|
||||
tokio::time::timeout(Duration::from_secs(1), waiter)
|
||||
.await
|
||||
.expect("background task should complete after lock release")
|
||||
.expect("background task should not panic");
|
||||
|
||||
assert!(acquired.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn map_sync_result_runs_error_handler_after_lock_release() {
|
||||
let result =
|
||||
run_with_s3_lock(async { Err::<(), AppError>(AppError::Config("boom".to_string())) })
|
||||
.await;
|
||||
|
||||
let mut lock_released = false;
|
||||
let mapped = map_sync_result(result, |_| {
|
||||
lock_released = s3_sync_mutex().try_lock().is_ok();
|
||||
});
|
||||
|
||||
assert!(mapped.is_err());
|
||||
assert!(lock_released);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secret_for_request_preserves_existing_when_requested() {
|
||||
let incoming = S3SyncSettings {
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: String::new(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
let existing = Some(S3SyncSettings {
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
});
|
||||
let resolved = resolve_secret_for_request(incoming, existing, true);
|
||||
assert_eq!(resolved.secret_access_key, "SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secret_for_request_allows_explicit_empty_secret() {
|
||||
let incoming = S3SyncSettings {
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: String::new(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
let existing = Some(S3SyncSettings {
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
});
|
||||
let resolved = resolve_secret_for_request(incoming, existing, false);
|
||||
assert!(resolved.secret_access_key.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn persist_sync_error_updates_status_without_overwriting_credentials() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-s3-sync-error-status-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
let mut current = S3SyncSettings {
|
||||
enabled: true,
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
crate::settings::set_s3_sync_settings(Some(current.clone())).expect("seed s3 settings");
|
||||
|
||||
persist_sync_error(
|
||||
&mut current,
|
||||
&crate::error::AppError::Config("boom".to_string()),
|
||||
"manual",
|
||||
);
|
||||
|
||||
let after = crate::settings::get_s3_sync_settings().expect("read s3 settings");
|
||||
assert_eq!(after.region, "us-east-1");
|
||||
assert_eq!(after.bucket, "my-bucket");
|
||||
assert_eq!(after.access_key_id, "AKID");
|
||||
assert_eq!(after.secret_access_key, "SECRET");
|
||||
assert_eq!(after.remote_root, "cc-switch-sync");
|
||||
assert_eq!(after.profile, "default");
|
||||
assert!(
|
||||
after
|
||||
.status
|
||||
.last_error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("boom"),
|
||||
"status error should be updated"
|
||||
);
|
||||
assert_eq!(after.status.last_error_source.as_deref(), Some("manual"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn require_enabled_s3_settings_rejects_disabled_config() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-s3-sync-enabled-disabled-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
crate::settings::set_s3_sync_settings(Some(S3SyncSettings {
|
||||
enabled: false,
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}))
|
||||
.expect("seed disabled s3 settings");
|
||||
|
||||
let err = require_enabled_s3_settings().expect_err("disabled settings should fail");
|
||||
assert!(
|
||||
err.contains("disabled") || err.contains("未启用"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn require_enabled_s3_settings_returns_settings_when_enabled() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-s3-sync-enabled-ok-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
crate::settings::set_s3_sync_settings(Some(S3SyncSettings {
|
||||
enabled: true,
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}))
|
||||
.expect("seed enabled s3 settings");
|
||||
|
||||
let settings = require_enabled_s3_settings().expect("enabled settings should be accepted");
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.region, "us-east-1");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,20 @@ fn merge_settings_for_save(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match (&mut incoming.s3_sync, &existing.s3_sync) {
|
||||
// incoming 没有 s3 → 保留现有
|
||||
(None, _) => {
|
||||
incoming.s3_sync = existing.s3_sync.clone();
|
||||
}
|
||||
// incoming 有 s3 但密钥为空,且现有有密钥 → 填回现有密钥
|
||||
(Some(incoming_sync), Some(existing_sync))
|
||||
if incoming_sync.secret_access_key.is_empty()
|
||||
&& !existing_sync.secret_access_key.is_empty() =>
|
||||
{
|
||||
incoming_sync.secret_access_key = existing_sync.secret_access_key.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if incoming.local_migrations.is_none() {
|
||||
incoming.local_migrations = existing.local_migrations.clone();
|
||||
} else if let (Some(incoming_migrations), Some(existing_migrations)) =
|
||||
@@ -103,7 +117,7 @@ mod tests {
|
||||
use super::merge_settings_for_save;
|
||||
use crate::settings::{
|
||||
AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
|
||||
LocalMigrations, WebDavSyncSettings,
|
||||
LocalMigrations, S3SyncSettings, WebDavSyncSettings,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -226,6 +240,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_existing_s3_when_payload_omits_it() {
|
||||
let existing = AppSettings {
|
||||
s3_sync: Some(S3SyncSettings {
|
||||
bucket: "bucket".to_string(),
|
||||
access_key_id: "ak".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let incoming = AppSettings::default();
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert!(merged.s3_sync.is_some());
|
||||
assert_eq!(
|
||||
merged
|
||||
.s3_sync
|
||||
.as_ref()
|
||||
.map(|v| v.secret_access_key.as_str()),
|
||||
Some("secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_s3_secret_when_incoming_has_empty_secret() {
|
||||
let existing = AppSettings {
|
||||
s3_sync: Some(S3SyncSettings {
|
||||
bucket: "bucket".to_string(),
|
||||
access_key_id: "ak".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let incoming = AppSettings {
|
||||
s3_sync: Some(S3SyncSettings {
|
||||
bucket: "bucket".to_string(),
|
||||
access_key_id: "ak".to_string(),
|
||||
secret_access_key: "".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert_eq!(
|
||||
merged
|
||||
.s3_sync
|
||||
.as_ref()
|
||||
.map(|v| v.secret_access_key.as_str()),
|
||||
Some("secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_local_migrations_when_payload_omits_it() {
|
||||
let existing = AppSettings {
|
||||
|
||||
Reference in New Issue
Block a user