From 4a371f15397793aa08f522b11211c7aebb978816 Mon Sep 17 00:00:00 2001 From: galact <1582093495@qq.com> Date: Tue, 15 Sep 2026 15:02:40 +0800 Subject: [PATCH 1/9] feat(settings): sync configuration between machines over WebDAV Moving to a second machine meant retyping every model provider, agent setting and task template by hand. Backup covers the whole database, so it is the wrong tool: it carries conversations and uploads, and restoring it overwrites the target machine. This adds a configuration-only snapshot of six domains (model providers, agent settings, custom agents, quick messages, task templates and the portable app preferences) that can travel either as a JSON file or through a WebDAV server the user already owns. Applying a snapshot upserts by natural key and never deletes local rows, so a machine that has extra entries keeps them, and auto-increment ids stay put for the foreign keys that point at them. Automatic sync only uploads: pulling from the server is always a deliberate action, so a stale remote can never silently overwrite local configuration. Uploads are driven by hashing the snapshot on a timer rather than marking the database dirty, because configuration writes are spread across dozens of commands. The snapshot is unencrypted, so the WebDAV credentials themselves are excluded from it and the UI says as much. --- src-tauri/src/app_error.rs | 29 + .../src/commands/config_sync/auto_sync.rs | 210 +++++ src-tauri/src/commands/config_sync/domains.rs | 718 ++++++++++++++++ .../src/commands/config_sync/local_io.rs | 302 +++++++ src-tauri/src/commands/config_sync/mod.rs | 159 ++++ .../src/commands/config_sync/portable_keys.rs | 105 +++ .../src/commands/config_sync/snapshot.rs | 674 +++++++++++++++ .../src/commands/config_sync/webdav_sync.rs | 618 ++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 31 + src-tauri/src/network/mod.rs | 1 + src-tauri/src/network/webdav.rs | 448 ++++++++++ .../settings/config-sync-settings.test.tsx | 294 +++++++ .../settings/config-sync-settings.tsx | 790 ++++++++++++++++++ .../settings/system-network-settings.tsx | 3 + src/i18n/messages/ar.json | 99 +++ src/i18n/messages/de.json | 99 +++ src/i18n/messages/en.json | 99 +++ src/i18n/messages/es.json | 99 +++ src/i18n/messages/fr.json | 99 +++ src/i18n/messages/ja.json | 99 +++ src/i18n/messages/ko.json | 99 +++ src/i18n/messages/pt.json | 99 +++ src/i18n/messages/zh-CN.json | 99 +++ src/i18n/messages/zh-TW.json | 99 +++ src/lib/config-sync.test.ts | 36 + src/lib/config-sync.ts | 247 ++++++ 27 files changed, 5656 insertions(+) create mode 100644 src-tauri/src/commands/config_sync/auto_sync.rs create mode 100644 src-tauri/src/commands/config_sync/domains.rs create mode 100644 src-tauri/src/commands/config_sync/local_io.rs create mode 100644 src-tauri/src/commands/config_sync/mod.rs create mode 100644 src-tauri/src/commands/config_sync/portable_keys.rs create mode 100644 src-tauri/src/commands/config_sync/snapshot.rs create mode 100644 src-tauri/src/commands/config_sync/webdav_sync.rs create mode 100644 src-tauri/src/network/webdav.rs create mode 100644 src/components/settings/config-sync-settings.test.tsx create mode 100644 src/components/settings/config-sync-settings.tsx create mode 100644 src/lib/config-sync.test.ts create mode 100644 src/lib/config-sync.ts diff --git a/src-tauri/src/app_error.rs b/src-tauri/src/app_error.rs index c88b8c4881..4b31a98877 100644 --- a/src-tauri/src/app_error.rs +++ b/src-tauri/src/app_error.rs @@ -68,6 +68,35 @@ pub const BACKUP_I18N_KEY_CANCELLED: &str = "backup.error.cancelled"; /// A restore is already staged and awaiting restart; only one at a time. pub const BACKUP_I18N_KEY_ALREADY_PENDING: &str = "backup.restore.error.alreadyPending"; +// ─── Config sync i18n keys ─────────────────────────────────────────── +// +// Emitted by `commands::config_sync::*` and consumed by `ConfigSyncSettings` +// on the frontend. MUST stay in lockstep with the TS constants in +// `src/lib/config-sync.ts`. + +/// The snapshot declares a schema this binary cannot represent — the machine +/// that wrote it runs a newer codeg. Params: `snapshotVersion`, `appVersion`. +pub const CONFIG_SYNC_I18N_KEY_NEWER_SCHEMA: &str = "configSync.error.newerSchema"; +/// `config.json` did not match the size/sha256 the manifest recorded — a +/// truncated upload or a share that was written by two machines at once. +pub const CONFIG_SYNC_I18N_KEY_CHECKSUM: &str = "configSync.error.checksum"; +/// The file is not a codeg config snapshot, or its JSON is malformed. +pub const CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT: &str = "configSync.error.invalidSnapshot"; +/// The remote directory holds no snapshot yet (nothing was ever uploaded). +pub const CONFIG_SYNC_I18N_KEY_NO_REMOTE: &str = "configSync.error.noRemoteSnapshot"; +/// WebDAV rejected the credentials (401). +pub const CONFIG_SYNC_I18N_KEY_UNAUTHORIZED: &str = "configSync.error.unauthorized"; +/// WebDAV authenticated but refused the operation (403). +pub const CONFIG_SYNC_I18N_KEY_FORBIDDEN: &str = "configSync.error.forbidden"; +/// The configured remote directory does not exist and could not be created. +pub const CONFIG_SYNC_I18N_KEY_REMOTE_PATH: &str = "configSync.error.remotePath"; +/// The share is out of quota (507). +pub const CONFIG_SYNC_I18N_KEY_QUOTA: &str = "configSync.error.quota"; +/// The request never reached the server (DNS, TLS, timeout, offline). +pub const CONFIG_SYNC_I18N_KEY_NETWORK: &str = "configSync.error.network"; +/// The server answered with an unexpected status. Params: `status`. +pub const CONFIG_SYNC_I18N_KEY_SERVER: &str = "configSync.error.server"; + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AppErrorCode { diff --git a/src-tauri/src/commands/config_sync/auto_sync.rs b/src-tauri/src/commands/config_sync/auto_sync.rs new file mode 100644 index 0000000000..004ebbaff4 --- /dev/null +++ b/src-tauri/src/commands/config_sync/auto_sync.rs @@ -0,0 +1,210 @@ +//! The background half of config sync: a timer that uploads when — and only +//! when — the local configuration actually changed. +//! +//! ## Why a periodic hash compare instead of marking dirty on write +//! +//! The obvious design is to flag "config changed" at each write site and +//! debounce. That works when writes funnel through a handful of save +//! functions; codeg's configuration writes are spread across `model_provider`, +//! `custom_agents`, `quick_messages`, `work_task`, and a dozen settings +//! commands, so instrumenting them is both a wide change and one that every +//! new feature can silently forget to make — and a forgotten write site means +//! a setting that never syncs, which is invisible until a user loses it. +//! +//! Collecting a snapshot is a handful of small queries over tens of KB, so +//! comparing its hash every few minutes costs less than the bookkeeping would, +//! cannot be forgotten by a future feature, and sends zero network traffic +//! when nothing changed. +//! +//! ## Upload only +//! +//! This loop never downloads. Pulling remote configuration is always an +//! explicit user action, because an automatic pull is indistinguishable from +//! "another machine silently overwrote my settings". + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use sea_orm::DatabaseConnection; +use serde::Serialize; + +use super::webdav_sync::{load_settings, load_state, upload_snapshot_core}; +use crate::web::event_bridge::{emit_event, EventEmitter}; + +/// Emitted ONLY by this loop. A frontend receiving it knows the sync was +/// automatic; manual sync results come back as command return values, so the +/// UI never has to guess which action a status update belongs to. +pub const CONFIG_SYNC_STATUS_EVENT: &str = "config-sync://status"; + +/// Startup grace period. The first minutes after launch are the busiest — +/// migrations, agent probes, session scans — and a config upload is never +/// urgent. +const STARTUP_DELAY: Duration = Duration::from_secs(60); + +/// Consecutive-failure cap for the backoff exponent: 16× the interval. At the +/// default 5 minutes that is ~80 minutes between attempts for a share that is +/// simply offline. +const MAX_BACKOFF_EXPONENT: u32 = 4; + +/// How many suppression scopes are currently held. A counter, not a flag: +/// an import and a manual download can overlap, and a flag would let the first +/// one to finish re-enable syncing while the second is still writing. +static SUPPRESSION_DEPTH: AtomicUsize = AtomicUsize::new(0); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigSyncStatusPayload { + pub last_sync_at: Option, + pub last_error: Option, +} + +/// Held while local configuration is being rewritten from a remote snapshot. +/// Released on drop, including on an early return or a panic — which matters, +/// because a leaked suppression would silently stop syncing for the rest of +/// the session. +pub struct AutoSyncSuppression { + _private: (), +} + +impl Drop for AutoSyncSuppression { + fn drop(&mut self) { + SUPPRESSION_DEPTH.fetch_sub(1, Ordering::AcqRel); + } +} + +pub fn suppress_auto_sync() -> AutoSyncSuppression { + SUPPRESSION_DEPTH.fetch_add(1, Ordering::AcqRel); + AutoSyncSuppression { _private: () } +} + +pub fn is_auto_sync_suppressed() -> bool { + SUPPRESSION_DEPTH.load(Ordering::Acquire) > 0 +} + +/// `interval * 2^min(failures, 4)`, saturating. Pure so the schedule is +/// testable without waiting for wall-clock time. +pub fn next_delay(interval_minutes: u32, consecutive_failures: u32) -> Duration { + let interval = interval_minutes.max(1) as u64; + let exponent = consecutive_failures.min(MAX_BACKOFF_EXPONENT); + let minutes = interval.saturating_mul(1u64 << exponent); + Duration::from_secs(minutes.saturating_mul(60)) +} + +/// Whether this tick should attempt an upload at all. Split out from the loop +/// so the skip rules are testable. +pub fn should_attempt(enabled: bool, auto_sync: bool, suppressed: bool) -> bool { + enabled && auto_sync && !suppressed +} + +/// Runs until the process exits. Reads settings every tick on purpose: turning +/// sync off in the UI takes effect at the next tick without restarting the +/// loop or plumbing a cancellation channel. +pub async fn run_auto_sync_loop( + conn: DatabaseConnection, + emitter: Arc, + app_version: String, +) { + tokio::time::sleep(STARTUP_DELAY).await; + let mut consecutive_failures: u32 = 0; + + loop { + let settings = load_settings(&conn).await; + + if !should_attempt( + settings.enabled, + settings.auto_sync, + is_auto_sync_suppressed(), + ) { + // Still honour the configured interval so a user who re-enables + // sync does not wait out a stale backoff. + tokio::time::sleep(next_delay(settings.interval_minutes, 0)).await; + continue; + } + + match upload_snapshot_core(&conn, &app_version, false).await { + Ok(outcome) => { + consecutive_failures = 0; + // Only announce an upload that happened. A no-op tick would + // otherwise repaint "last synced" every few minutes with a + // timestamp nothing was written at. + if outcome.uploaded { + let state = load_state(&conn).await; + emit_status(&emitter, &state.last_sync_at, &None); + } + } + Err(err) => { + consecutive_failures = consecutive_failures.saturating_add(1); + tracing::warn!( + "[CONFIG-SYNC] auto sync failed ({consecutive_failures} in a row): {}", + err.message + ); + let state = load_state(&conn).await; + emit_status(&emitter, &state.last_sync_at, &Some(err.message)); + } + } + + let settings = load_settings(&conn).await; + tokio::time::sleep(next_delay(settings.interval_minutes, consecutive_failures)).await; + } +} + +fn emit_status( + emitter: &EventEmitter, + last_sync_at: &Option, + last_error: &Option, +) { + emit_event( + emitter, + CONFIG_SYNC_STATUS_EVENT, + ConfigSyncStatusPayload { + last_sync_at: last_sync_at.clone(), + last_error: last_error.clone(), + }, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_grows_then_stops_growing() { + assert_eq!(next_delay(5, 0), Duration::from_secs(5 * 60)); + assert_eq!(next_delay(5, 1), Duration::from_secs(10 * 60)); + assert_eq!(next_delay(5, 4), Duration::from_secs(80 * 60)); + // Capped: a share that has been offline for days must not schedule the + // next attempt a year out. + assert_eq!(next_delay(5, 99), next_delay(5, MAX_BACKOFF_EXPONENT)); + } + + #[test] + fn a_zero_interval_never_becomes_a_busy_loop() { + assert_eq!(next_delay(0, 0), Duration::from_secs(60)); + } + + #[test] + fn suppression_is_reference_counted_and_released_on_drop() { + assert!(!is_auto_sync_suppressed()); + { + let _outer = suppress_auto_sync(); + assert!(is_auto_sync_suppressed()); + { + let _inner = suppress_auto_sync(); + assert!(is_auto_sync_suppressed()); + } + // The inner scope ending must NOT re-enable syncing while the + // outer one is still applying a snapshot. + assert!(is_auto_sync_suppressed()); + } + assert!(!is_auto_sync_suppressed()); + } + + #[test] + fn every_reason_to_skip_a_tick_is_honoured() { + assert!(should_attempt(true, true, false)); + assert!(!should_attempt(false, true, false)); + assert!(!should_attempt(true, false, false)); + assert!(!should_attempt(true, true, true)); + } +} diff --git a/src-tauri/src/commands/config_sync/domains.rs b/src-tauri/src/commands/config_sync/domains.rs new file mode 100644 index 0000000000..b31f3da55f --- /dev/null +++ b/src-tauri/src/commands/config_sync/domains.rs @@ -0,0 +1,718 @@ +//! The single table of configuration domains a snapshot carries. +//! +//! Collection ([`super::snapshot::collect_snapshot_core`]) and application +//! ([`super::snapshot::apply_snapshot_core`]) both iterate [`CONFIG_DOMAINS`], +//! so a domain cannot be collected without an applier or vice versa — the +//! failure mode `commands/backup/sections.rs` documents (two hand-kept lists +//! drifting until a whole section silently stopped travelling) is structurally +//! impossible here. +//! +//! Two rules the DTOs below encode: +//! +//! 1. **Field denylists are types, not filters.** A snapshot is plaintext on +//! someone's WebDAV share the moment it is uploaded, so device-local fields +//! (`installed_version`, `skills_dir`) and identity columns (`id`, +//! `created_at`, `updated_at`) are simply absent from the DTO. There is no +//! "strip it on the way out" step that a future refactor can skip. +//! 2. **Rows are matched by natural key, never by id.** Applying a snapshot +//! upserts: matched rows are updated, unmatched rows are inserted, and rows +//! the snapshot does not mention are LEFT ALONE. Wiping the table would +//! renumber autoincrement ids and break `agent_setting.model_provider_id`, +//! and would turn "bring my config over" into "make this machine a clone". + +use chrono::Utc; +use futures::future::BoxFuture; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, DatabaseTransaction, + EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, +}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::portable_keys::{is_portable_key, PORTABLE_PREFERENCE_KEYS}; +use crate::app_error::AppCommandError; +use crate::db::entities::{ + agent_setting, app_metadata, custom_agent, model_provider, quick_message, work_task_template, +}; +use crate::db::service::app_metadata_service; + +pub const DOMAIN_MODEL_PROVIDERS: &str = "modelProviders"; +pub const DOMAIN_AGENT_SETTINGS: &str = "agentSettings"; +pub const DOMAIN_CUSTOM_AGENTS: &str = "customAgents"; +pub const DOMAIN_QUICK_MESSAGES: &str = "quickMessages"; +pub const DOMAIN_TASK_TEMPLATES: &str = "taskTemplates"; +pub const DOMAIN_PREFERENCES: &str = "preferences"; + +type CollectFn = + for<'a> fn(&'a DatabaseConnection) -> BoxFuture<'a, Result>; +type ApplyFn = for<'a> fn( + &'a DatabaseTransaction, + &'a Value, +) -> BoxFuture<'a, Result>; + +/// One configuration domain: how it is read out of the local database and how +/// it is written back in. +pub struct ConfigDomain { + /// Stable snapshot key. Never rename: older snapshots are matched by it. + pub id: &'static str, + pub collect: CollectFn, + pub apply: ApplyFn, +} + +/// Domain order IS application order. `modelProviders` must precede +/// `agentSettings`: an agent setting references its provider by natural key +/// and the applier resolves that to a local id, which only works once the +/// provider row exists. +pub const CONFIG_DOMAINS: &[ConfigDomain] = &[ + ConfigDomain { + id: DOMAIN_MODEL_PROVIDERS, + collect: collect_model_providers, + apply: apply_model_providers, + }, + ConfigDomain { + id: DOMAIN_AGENT_SETTINGS, + collect: collect_agent_settings, + apply: apply_agent_settings, + }, + ConfigDomain { + id: DOMAIN_CUSTOM_AGENTS, + collect: collect_custom_agents, + apply: apply_custom_agents, + }, + ConfigDomain { + id: DOMAIN_QUICK_MESSAGES, + collect: collect_quick_messages, + apply: apply_quick_messages, + }, + ConfigDomain { + id: DOMAIN_TASK_TEMPLATES, + collect: collect_task_templates, + apply: apply_task_templates, + }, + ConfigDomain { + id: DOMAIN_PREFERENCES, + collect: collect_preferences, + apply: apply_preferences, + }, +]; + +/// How many entries a collected domain value holds — array length for row +/// domains, key count for `preferences`. Used for the manifest's `counts` and +/// for the import confirmation dialog. +pub fn count_entries(value: &Value) -> usize { + match value { + Value::Array(items) => items.len(), + Value::Object(map) => map.len(), + _ => 0, + } +} + +fn db_err(err: sea_orm::DbErr) -> AppCommandError { + AppCommandError::db(crate::db::error::DbError::from(err)) +} + +fn encode(rows: Vec) -> Result { + serde_json::to_value(rows).map_err(|e| { + AppCommandError::task_execution_failed("Serialize config domain").with_detail(e.to_string()) + }) +} + +fn decode_rows(domain: &str, value: &Value) -> Result, AppCommandError> { + // A domain missing from an older snapshot is not an error; it simply + // carries nothing. + if value.is_null() { + return Ok(Vec::new()); + } + serde_json::from_value(value.clone()).map_err(|e| { + AppCommandError::invalid_input(format!("Snapshot domain '{domain}' is malformed")) + .with_detail(e.to_string()) + }) +} + +// ─── modelProviders ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelProviderDto { + pub name: String, + pub api_url: String, + pub api_key: String, + #[serde(default)] + pub agent_types_json: String, + pub agent_type: String, + #[serde(default)] + pub model: Option, +} + +fn collect_model_providers( + conn: &DatabaseConnection, +) -> BoxFuture<'_, Result> { + Box::pin(async move { + let rows = model_provider::Entity::find() + .order_by_asc(model_provider::Column::Id) + .all(conn) + .await + .map_err(db_err)?; + encode( + rows.into_iter() + .map(|m| ModelProviderDto { + name: m.name, + api_url: m.api_url, + api_key: m.api_key, + agent_types_json: m.agent_types_json, + agent_type: m.agent_type, + model: m.model, + }) + .collect::>(), + ) + }) +} + +/// `model_provider` has no unique index (verified against the migrations), so +/// `(agent_type, name)` can legitimately match more than one row. Updating the +/// lowest id and leaving the rest untouched is arbitrary but deterministic — +/// preferable to guessing which duplicate the user meant. +async fn find_provider( + tx: &DatabaseTransaction, + agent_type: &str, + name: &str, +) -> Result, AppCommandError> { + model_provider::Entity::find() + .filter(model_provider::Column::AgentType.eq(agent_type)) + .filter(model_provider::Column::Name.eq(name)) + .order_by_asc(model_provider::Column::Id) + .one(tx) + .await + .map_err(db_err) +} + +fn apply_model_providers<'a>( + tx: &'a DatabaseTransaction, + value: &'a Value, +) -> BoxFuture<'a, Result> { + Box::pin(async move { + let rows: Vec = decode_rows(DOMAIN_MODEL_PROVIDERS, value)?; + let now = Utc::now(); + let mut applied = 0usize; + for dto in rows { + match find_provider(tx, &dto.agent_type, &dto.name).await? { + Some(existing) => { + let mut active = existing.into_active_model(); + active.api_url = Set(dto.api_url); + active.api_key = Set(dto.api_key); + active.agent_types_json = Set(dto.agent_types_json); + active.model = Set(dto.model); + active.updated_at = Set(now); + active.update(tx).await.map_err(db_err)?; + } + None => { + model_provider::ActiveModel { + id: NotSet, + name: Set(dto.name), + api_url: Set(dto.api_url), + api_key: Set(dto.api_key), + agent_types_json: Set(dto.agent_types_json), + agent_type: Set(dto.agent_type), + model: Set(dto.model), + created_at: Set(now), + updated_at: Set(now), + } + .insert(tx) + .await + .map_err(db_err)?; + } + } + applied += 1; + } + Ok(applied) + }) +} + +// ─── agentSettings ──────────────────────────────────────────────────── + +/// A provider referenced by its natural key rather than its local row id — +/// ids are per-machine and would point at a different provider (or nothing) +/// after travelling. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderRefDto { + pub agent_type: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSettingDto { + pub agent_type: String, + #[serde(default)] + pub registry_id: String, + pub enabled: bool, + #[serde(default)] + pub sort_order: i32, + #[serde(default)] + pub env_json: Option, + /// Absent when the setting uses no provider, or when the referenced + /// provider row was gone at collection time. + #[serde(default)] + pub provider: Option, +} + +fn collect_agent_settings( + conn: &DatabaseConnection, +) -> BoxFuture<'_, Result> { + Box::pin(async move { + let rows = agent_setting::Entity::find() + .order_by_asc(agent_setting::Column::Id) + .all(conn) + .await + .map_err(db_err)?; + let providers = model_provider::Entity::find() + .all(conn) + .await + .map_err(db_err)?; + let mut dtos = Vec::with_capacity(rows.len()); + for row in rows { + let provider = row.model_provider_id.and_then(|pid| { + providers + .iter() + .find(|p| p.id == pid) + .map(|p| ProviderRefDto { + agent_type: p.agent_type.clone(), + name: p.name.clone(), + }) + }); + dtos.push(AgentSettingDto { + agent_type: row.agent_type, + registry_id: row.registry_id, + enabled: row.enabled, + sort_order: row.sort_order, + env_json: row.env_json, + provider, + }); + } + encode(dtos) + }) +} + +fn apply_agent_settings<'a>( + tx: &'a DatabaseTransaction, + value: &'a Value, +) -> BoxFuture<'a, Result> { + Box::pin(async move { + let rows: Vec = decode_rows(DOMAIN_AGENT_SETTINGS, value)?; + let now = Utc::now(); + let mut applied = 0usize; + for dto in rows { + // Remap the provider reference to a LOCAL id. An unresolvable + // reference degrades to "no provider" rather than failing the + // whole apply: the setting itself is still worth carrying over. + let mut provider_id = None; + if let Some(reference) = &dto.provider { + provider_id = find_provider(tx, &reference.agent_type, &reference.name) + .await? + .map(|p| p.id); + } + + let existing = agent_setting::Entity::find() + .filter(agent_setting::Column::AgentType.eq(dto.agent_type.clone())) + .one(tx) + .await + .map_err(db_err)?; + + match existing { + Some(existing) => { + let mut active = existing.into_active_model(); + if !dto.registry_id.is_empty() { + active.registry_id = Set(dto.registry_id); + } + active.enabled = Set(dto.enabled); + active.sort_order = Set(dto.sort_order); + active.env_json = Set(dto.env_json); + active.model_provider_id = Set(provider_id); + active.updated_at = Set(now); + active.update(tx).await.map_err(db_err)?; + } + None => { + let registry_id = if dto.registry_id.is_empty() { + dto.agent_type.clone() + } else { + dto.registry_id + }; + agent_setting::ActiveModel { + id: NotSet, + agent_type: Set(dto.agent_type), + registry_id: Set(registry_id), + enabled: Set(dto.enabled), + sort_order: Set(dto.sort_order), + // Device-local: what this machine actually has + // installed, discovered by the version probe. + installed_version: Set(None), + env_json: Set(dto.env_json), + model_provider_id: Set(provider_id), + created_at: Set(now), + updated_at: Set(now), + } + .insert(tx) + .await + .map_err(db_err)?; + } + } + applied += 1; + } + Ok(applied) + }) +} + +// ─── customAgents ───────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomAgentDto { + pub registry_id: String, + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub distribution_kind: String, + #[serde(default)] + pub spec_json: String, + #[serde(default)] + pub icon_url: Option, + #[serde(default)] + pub skills_shared_store: bool, + #[serde(default)] + pub source: String, + #[serde(default)] + pub version_probe: Option, + #[serde(default)] + pub supports_mcp: bool, +} + +fn collect_custom_agents( + conn: &DatabaseConnection, +) -> BoxFuture<'_, Result> { + Box::pin(async move { + let rows = custom_agent::Entity::find() + .order_by_asc(custom_agent::Column::Id) + .all(conn) + .await + .map_err(db_err)?; + encode( + rows.into_iter() + .map(|m| CustomAgentDto { + registry_id: m.registry_id, + name: m.name, + description: m.description, + version: m.version, + distribution_kind: m.distribution_kind, + spec_json: m.spec_json, + icon_url: m.icon_url, + skills_shared_store: m.skills_shared_store, + source: m.source, + version_probe: m.version_probe, + supports_mcp: m.supports_mcp, + }) + .collect::>(), + ) + }) +} + +fn apply_custom_agents<'a>( + tx: &'a DatabaseTransaction, + value: &'a Value, +) -> BoxFuture<'a, Result> { + Box::pin(async move { + let rows: Vec = decode_rows(DOMAIN_CUSTOM_AGENTS, value)?; + let now = Utc::now(); + let mut applied = 0usize; + for dto in rows { + let existing = custom_agent::Entity::find() + .filter(custom_agent::Column::RegistryId.eq(dto.registry_id.clone())) + .one(tx) + .await + .map_err(db_err)?; + match existing { + Some(existing) => { + let mut active = existing.into_active_model(); + active.name = Set(dto.name); + active.description = Set(dto.description); + active.version = Set(dto.version); + active.distribution_kind = Set(dto.distribution_kind); + active.spec_json = Set(dto.spec_json); + active.icon_url = Set(dto.icon_url); + active.skills_shared_store = Set(dto.skills_shared_store); + active.source = Set(dto.source); + active.version_probe = Set(dto.version_probe); + active.supports_mcp = Set(dto.supports_mcp); + // `skills_dir` is an absolute path on the machine that + // owns it; whatever this machine has stays. + active.updated_at = Set(now); + active.update(tx).await.map_err(db_err)?; + } + None => { + custom_agent::ActiveModel { + id: NotSet, + registry_id: Set(dto.registry_id), + name: Set(dto.name), + description: Set(dto.description), + version: Set(dto.version), + distribution_kind: Set(dto.distribution_kind), + spec_json: Set(dto.spec_json), + icon_url: Set(dto.icon_url), + skills_shared_store: Set(dto.skills_shared_store), + skills_dir: Set(None), + source: Set(dto.source), + version_probe: Set(dto.version_probe), + supports_mcp: Set(dto.supports_mcp), + created_at: Set(now), + updated_at: Set(now), + } + .insert(tx) + .await + .map_err(db_err)?; + } + } + applied += 1; + } + Ok(applied) + }) +} + +// ─── quickMessages ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuickMessageDto { + pub title: String, + pub content: String, + #[serde(default)] + pub sort_order: i32, +} + +fn collect_quick_messages( + conn: &DatabaseConnection, +) -> BoxFuture<'_, Result> { + Box::pin(async move { + let rows = quick_message::Entity::find() + .order_by_asc(quick_message::Column::Id) + .all(conn) + .await + .map_err(db_err)?; + encode( + rows.into_iter() + .map(|m| QuickMessageDto { + title: m.title, + content: m.content, + sort_order: m.sort_order, + }) + .collect::>(), + ) + }) +} + +fn apply_quick_messages<'a>( + tx: &'a DatabaseTransaction, + value: &'a Value, +) -> BoxFuture<'a, Result> { + Box::pin(async move { + let rows: Vec = decode_rows(DOMAIN_QUICK_MESSAGES, value)?; + let now = Utc::now(); + let mut applied = 0usize; + for dto in rows { + let existing = quick_message::Entity::find() + .filter(quick_message::Column::Title.eq(dto.title.clone())) + .order_by_asc(quick_message::Column::Id) + .one(tx) + .await + .map_err(db_err)?; + match existing { + Some(existing) => { + let mut active = existing.into_active_model(); + active.content = Set(dto.content); + active.sort_order = Set(dto.sort_order); + active.updated_at = Set(now); + active.update(tx).await.map_err(db_err)?; + } + None => { + quick_message::ActiveModel { + id: NotSet, + title: Set(dto.title), + content: Set(dto.content), + sort_order: Set(dto.sort_order), + created_at: Set(now), + updated_at: Set(now), + } + .insert(tx) + .await + .map_err(db_err)?; + } + } + applied += 1; + } + Ok(applied) + }) +} + +// ─── taskTemplates ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskTemplateDto { + pub name: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub config: String, +} + +fn collect_task_templates( + conn: &DatabaseConnection, +) -> BoxFuture<'_, Result> { + Box::pin(async move { + let rows = work_task_template::Entity::find() + .order_by_asc(work_task_template::Column::Id) + .all(conn) + .await + .map_err(db_err)?; + encode( + rows.into_iter() + .map(|m| TaskTemplateDto { + name: m.name, + title: m.title, + config: m.config, + }) + .collect::>(), + ) + }) +} + +fn apply_task_templates<'a>( + tx: &'a DatabaseTransaction, + value: &'a Value, +) -> BoxFuture<'a, Result> { + Box::pin(async move { + let rows: Vec = decode_rows(DOMAIN_TASK_TEMPLATES, value)?; + let now = Utc::now(); + let mut applied = 0usize; + for dto in rows { + let existing = work_task_template::Entity::find() + .filter(work_task_template::Column::Name.eq(dto.name.clone())) + .order_by_asc(work_task_template::Column::Id) + .one(tx) + .await + .map_err(db_err)?; + match existing { + Some(existing) => { + let mut active = existing.into_active_model(); + active.title = Set(dto.title); + active.config = Set(dto.config); + active.updated_at = Set(now); + active.update(tx).await.map_err(db_err)?; + } + None => { + work_task_template::ActiveModel { + id: NotSet, + name: Set(dto.name), + title: Set(dto.title), + config: Set(dto.config), + created_at: Set(now), + updated_at: Set(now), + } + .insert(tx) + .await + .map_err(db_err)?; + } + } + applied += 1; + } + Ok(applied) + }) +} + +// ─── preferences ────────────────────────────────────────────────────── + +fn collect_preferences(conn: &DatabaseConnection) -> BoxFuture<'_, Result> { + Box::pin(async move { + let rows = app_metadata::Entity::find() + .filter(app_metadata::Column::Key.is_in(PORTABLE_PREFERENCE_KEYS.iter().copied())) + .filter(app_metadata::Column::DeletedAt.is_null()) + .order_by_asc(app_metadata::Column::Key) + .all(conn) + .await + .map_err(db_err)?; + let mut map = Map::new(); + for row in rows { + map.insert(row.key, Value::String(row.value)); + } + Ok(Value::Object(map)) + }) +} + +fn apply_preferences<'a>( + tx: &'a DatabaseTransaction, + value: &'a Value, +) -> BoxFuture<'a, Result> { + Box::pin(async move { + let Some(map) = value.as_object() else { + return Ok(0); + }; + let mut applied = 0usize; + for (key, entry) in map { + // Re-check the allowlist on the way IN. The snapshot is a plain + // file a user can edit and a remote a user does not fully + // control; without this, a doctored snapshot could write + // `github_accounts` or the sync credentials themselves. + if !is_portable_key(key) { + tracing::warn!("[CONFIG-SYNC] ignoring non-portable preference key from snapshot"); + continue; + } + let Some(text) = entry.as_str() else { + continue; + }; + app_metadata_service::upsert_value(tx, key, text) + .await + .map_err(AppCommandError::db)?; + applied += 1; + } + Ok(applied) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn domain_ids_are_unique_and_non_empty() { + let mut seen = std::collections::HashSet::new(); + for domain in CONFIG_DOMAINS { + assert!(!domain.id.is_empty()); + assert!(seen.insert(domain.id), "duplicate domain id: {}", domain.id); + } + } + + /// `agentSettings` resolves its provider reference against rows the + /// `modelProviders` applier has already written. + #[test] + fn providers_are_applied_before_agent_settings() { + let providers = CONFIG_DOMAINS + .iter() + .position(|d| d.id == DOMAIN_MODEL_PROVIDERS) + .expect("modelProviders domain"); + let settings = CONFIG_DOMAINS + .iter() + .position(|d| d.id == DOMAIN_AGENT_SETTINGS) + .expect("agentSettings domain"); + assert!(providers < settings); + } + + #[test] + fn count_entries_handles_arrays_objects_and_junk() { + assert_eq!(count_entries(&serde_json::json!([1, 2, 3])), 3); + assert_eq!(count_entries(&serde_json::json!({ "a": "b" })), 1); + assert_eq!(count_entries(&Value::Null), 0); + } +} diff --git a/src-tauri/src/commands/config_sync/local_io.rs b/src-tauri/src/commands/config_sync/local_io.rs new file mode 100644 index 0000000000..7e31a25b0b --- /dev/null +++ b/src-tauri/src/commands/config_sync/local_io.rs @@ -0,0 +1,302 @@ +//! Export the local configuration to a single file, and import one back. +//! +//! The file is one self-contained JSON object — manifest and snapshot +//! together — because a user who picks "export" expects one file they can put +//! in a note-taking app or send to themselves, not a pair they must keep +//! together. The WebDAV path keeps them separate for a different reason (see +//! `snapshot.rs`: a two-file upload is how a half-finished transfer becomes +//! detectable), and the two formats deliberately share the same manifest type. +//! +//! Import also accepts a bare `config.json` — the exact file the sync writes +//! to WebDAV — so a user who fetches one out of their cloud drive's web UI can +//! feed it straight back in. +//! +//! The checksum is NOT enforced on import. It exists to catch a truncated +//! upload, which cannot happen to a local file the OS handed us whole; holding +//! a hand-edited export to a byte-exact hash would only punish the user for +//! reformatting their own file. Schema version, structure, and the preference +//! allowlist are still enforced. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; + +use super::snapshot::{ + apply_snapshot_core, build_manifest, collect_snapshot_core, rollback_dir, serialize_snapshot, + write_rollback_snapshot, ApplyReport, ConfigManifest, ConfigSnapshot, +}; +use crate::app_error::{AppCommandError, CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT}; + +/// Marker + version of the single-file export envelope. +pub const EXPORT_FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigExportFile { + /// Presence of this field is what distinguishes an export envelope from a + /// bare `config.json`. + pub codeg_config_export: u32, + pub manifest: ConfigManifest, + pub config: ConfigSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigExportSummary { + pub path: String, + pub counts: BTreeMap, +} + +/// What the confirmation dialog shows before anything is written. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigImportPreview { + pub manifest: ConfigManifest, + pub counts: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigImportResult { + pub applied: ApplyReport, + /// Where the pre-import state was saved. `None` means the safety net could + /// not be written — surfaced, but never a reason to refuse an import the + /// user explicitly asked for. + pub rollback_path: Option, +} + +pub async fn build_export_core( + conn: &DatabaseConnection, + app_version: &str, +) -> Result { + let snapshot = collect_snapshot_core(conn).await?; + let bytes = serialize_snapshot(&snapshot)?; + let manifest = build_manifest(&bytes, app_version, snapshot.counts()); + Ok(ConfigExportFile { + codeg_config_export: EXPORT_FORMAT_VERSION, + manifest, + config: snapshot, + }) +} + +pub async fn export_to_file_core( + conn: &DatabaseConnection, + app_version: &str, + dest: &Path, +) -> Result { + let export = build_export_core(conn, app_version).await?; + let bytes = serde_json::to_vec_pretty(&export).map_err(|e| { + AppCommandError::task_execution_failed("Serialize config export").with_detail(e.to_string()) + })?; + + if let Some(parent) = dest.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(AppCommandError::io)?; + } + } + std::fs::write(dest, &bytes).map_err(AppCommandError::io)?; + + Ok(ConfigExportSummary { + path: dest.to_string_lossy().to_string(), + counts: export.config.counts(), + }) +} + +/// Accepts either envelope shape. A bare `config.json` gets a synthesized +/// manifest so the preview dialog has something to show. +pub fn parse_export_bytes(bytes: &[u8]) -> Result { + let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| { + AppCommandError::invalid_input("Not a codeg config file") + .with_detail(e.to_string()) + .with_i18n(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, BTreeMap::new()) + })?; + + if value.get("codegConfigExport").is_some() { + let export: ConfigExportFile = serde_json::from_value(value).map_err(|e| { + AppCommandError::invalid_input("Malformed codeg config export") + .with_detail(e.to_string()) + .with_i18n(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, BTreeMap::new()) + })?; + // Reject a newer schema the same way the WebDAV path does, before any + // of it reaches the database. + let snapshot_bytes = serialize_snapshot(&export.config)?; + super::snapshot::parse_snapshot(&snapshot_bytes)?; + return Ok(export); + } + + let snapshot = super::snapshot::parse_snapshot(bytes)?; + let snapshot_bytes = serialize_snapshot(&snapshot)?; + let manifest = build_manifest(&snapshot_bytes, "unknown", snapshot.counts()); + Ok(ConfigExportFile { + codeg_config_export: EXPORT_FORMAT_VERSION, + manifest, + config: snapshot, + }) +} + +pub fn read_export_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(AppCommandError::io)?; + parse_export_bytes(&bytes) +} + +/// Read and validate without touching the database — what the UI calls to +/// populate "this file contains N providers, M agents…". +pub fn peek_import_core(path: &Path) -> Result { + let export = read_export_file(path)?; + Ok(ConfigImportPreview { + counts: export.config.counts(), + manifest: export.manifest, + }) +} + +pub async fn import_from_file_core( + conn: &DatabaseConnection, + path: &Path, +) -> Result { + // Parse before writing the rollback snapshot: a malformed file should cost + // the user nothing at all. + let export = read_export_file(path)?; + let rollback_path = save_rollback(conn).await; + let applied = apply_snapshot_core(conn, &export.config).await?; + Ok(ConfigImportResult { + applied, + rollback_path, + }) +} + +/// Capture "what this machine looked like before" so a surprising import is +/// undoable. Best effort by design — see [`ConfigImportResult::rollback_path`]. +pub async fn save_rollback(conn: &DatabaseConnection) -> Option { + let snapshot = match collect_snapshot_core(conn).await { + Ok(snapshot) => snapshot, + Err(err) => { + tracing::warn!("[CONFIG-SYNC] rollback snapshot not collected: {err}"); + return None; + } + }; + let dir: PathBuf = rollback_dir(); + match write_rollback_snapshot(&dir, &snapshot) { + Ok(path) => Some(path.to_string_lossy().to_string()), + Err(err) => { + tracing::warn!("[CONFIG-SYNC] rollback snapshot not written: {err}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::super::snapshot::SCHEMA_VERSION; + use super::*; + use crate::db::entities::quick_message; + use crate::db::test_helpers::fresh_in_memory_db; + use sea_orm::{ActiveModelTrait, ActiveValue::NotSet, EntityTrait, Set}; + + async fn seed_message(conn: &DatabaseConnection, title: &str) { + let now = chrono::Utc::now(); + quick_message::ActiveModel { + id: NotSet, + title: Set(title.to_string()), + content: Set("body".to_string()), + sort_order: Set(0), + created_at: Set(now), + updated_at: Set(now), + } + .insert(conn) + .await + .expect("seed"); + } + + #[tokio::test] + async fn exported_file_imports_into_another_machine() { + let source = fresh_in_memory_db().await; + seed_message(&source.conn, "Exported").await; + + let dir = tempfile::tempdir().expect("tempdir"); + let dest = dir.path().join("nested").join("codeg-config.json"); + let summary = export_to_file_core(&source.conn, "9.9.9", &dest) + .await + .expect("export"); + assert!(dest.exists()); + assert_eq!(summary.counts.get("quickMessages"), Some(&1)); + + let preview = peek_import_core(&dest).expect("peek"); + assert_eq!(preview.manifest.app_version, "9.9.9"); + assert_eq!(preview.counts.get("quickMessages"), Some(&1)); + + let target = fresh_in_memory_db().await; + let result = import_from_file_core(&target.conn, &dest) + .await + .expect("import"); + assert!(result.applied.total >= 1); + assert_eq!( + quick_message::Entity::find() + .all(&target.conn) + .await + .expect("messages") + .len(), + 1 + ); + } + + /// The file the WebDAV sync uploads must be importable as-is — a user who + /// pulls `config.json` out of their cloud drive's web UI should not have + /// to reshape it. + #[tokio::test] + async fn a_bare_remote_config_json_is_accepted() { + let source = fresh_in_memory_db().await; + seed_message(&source.conn, "Bare").await; + let snapshot = collect_snapshot_core(&source.conn).await.expect("collect"); + let bytes = serialize_snapshot(&snapshot).expect("bytes"); + + let export = parse_export_bytes(&bytes).expect("parse bare"); + assert_eq!(export.config.schema_version, SCHEMA_VERSION); + assert_eq!(export.manifest.app_version, "unknown"); + assert_eq!(export.config.counts().get("quickMessages"), Some(&1)); + } + + /// A reformatted export (different indentation, reordered keys) must still + /// import: the checksum guards transfers, not the user's text editor. + #[tokio::test] + async fn a_reformatted_export_still_imports() { + let source = fresh_in_memory_db().await; + seed_message(&source.conn, "Reformatted").await; + let export = build_export_core(&source.conn, "1.0.0").await.expect("build"); + + let compact = serde_json::to_vec(&export).expect("compact bytes"); + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("compact.json"); + std::fs::write(&path, compact).expect("write"); + + let target = fresh_in_memory_db().await; + import_from_file_core(&target.conn, &path) + .await + .expect("import compact"); + assert_eq!( + quick_message::Entity::find() + .all(&target.conn) + .await + .expect("messages") + .len(), + 1 + ); + } + + #[tokio::test] + async fn junk_files_are_rejected_before_anything_is_touched() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("notes.txt"); + std::fs::write(&path, b"just some notes").expect("write"); + + let target = fresh_in_memory_db().await; + let err = import_from_file_core(&target.conn, &path) + .await + .expect_err("must reject"); + assert_eq!( + err.i18n_key.as_deref(), + Some(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT) + ); + } +} diff --git a/src-tauri/src/commands/config_sync/mod.rs b/src-tauri/src/commands/config_sync/mod.rs new file mode 100644 index 0000000000..a5157c1abd --- /dev/null +++ b/src-tauri/src/commands/config_sync/mod.rs @@ -0,0 +1,159 @@ +//! Configuration sync: a small, config-only snapshot of this machine's +//! settings that can be exported to a file or pushed to the user's own WebDAV +//! share, and pulled back on another machine. +//! +//! Deliberately NOT the backup engine (`commands::backup`): that one packs +//! conversations, uploads, and transcripts into an encrypted archive measured +//! in gigabytes, which is the wrong unit for something that runs on a timer. +//! This snapshot is tens of KB of configuration and nothing else. +//! +//! ## Shape of the feature +//! +//! * [`domains`] — the single table of what a snapshot contains. +//! * [`portable_keys`] — which `app_metadata` preferences may travel. +//! * [`snapshot`] — collect / validate / apply, plus local rollback copies. +//! * [`local_io`] — single-file export and import. +//! * [`webdav_sync`] — settings, remote layout, upload/download. +//! * [`auto_sync`] — the periodic hash-compare uploader and its suppression. +//! +//! ## Two rules that shape everything else +//! +//! **Uploads are automatic; downloads never are.** A timer that pulled would +//! be indistinguishable from another machine silently overwriting local +//! settings, so every download is an explicit user action with a confirmation +//! that shows what is about to change. +//! +//! **The snapshot is plaintext.** The security boundary is the user's own +//! authenticated WebDAV endpoint. API keys therefore travel in the clear and +//! the sync's own credentials are excluded from the snapshot entirely — a +//! machine can never overwrite another machine's credentials, which is what +//! would turn two clients into a sync loop. +//! +//! Layering mirrors the backup engine: `*_core` functions take plain +//! references (`&DatabaseConnection`, `&EventEmitter`) so desktop commands, +//! the (future) Axum handlers, and the background scheduler share one +//! implementation. + +pub mod auto_sync; +pub mod domains; +pub mod local_io; +pub mod portable_keys; +pub mod snapshot; +pub mod webdav_sync; + +// ─── Desktop Tauri commands ────────────────────────────────────────────── +// +// Thin wrappers only. The frontend picks paths with the native file dialog +// and passes them in, exactly as the backup commands do. + +#[cfg(feature = "tauri-runtime")] +mod tauri_commands { + use std::path::Path; + + use tauri::State; + + use crate::app_error::AppCommandError; + use crate::db::AppDatabase; + + use super::local_io::{ + export_to_file_core, import_from_file_core, peek_import_core, ConfigExportSummary, + ConfigImportPreview, ConfigImportResult, + }; + use super::snapshot::ConfigManifest; + use super::webdav_sync::{ + download_and_apply_core, load_settings, load_state, merge_settings, peek_remote_core, + save_settings_core, test_connection_core, upload_snapshot_core, ConfigSyncSettingsInput, + ConfigSyncSettingsView, ConfigSyncState, DownloadOutcome, UploadOutcome, + }; + + const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + + #[tauri::command] + pub async fn config_sync_export_file( + dest_path: String, + db: State<'_, AppDatabase>, + ) -> Result { + export_to_file_core(&db.conn, APP_VERSION, Path::new(&dest_path)).await + } + + /// Read-only: powers the "this file contains…" confirmation before any + /// local data is touched. + #[tauri::command] + pub async fn config_sync_peek_file( + src_path: String, + ) -> Result { + peek_import_core(Path::new(&src_path)) + } + + /// Intentionally NOT suppressed: a configuration the user imported by hand + /// should propagate to their other machines like any other local change. + #[tauri::command] + pub async fn config_sync_import_file( + src_path: String, + db: State<'_, AppDatabase>, + ) -> Result { + import_from_file_core(&db.conn, Path::new(&src_path)).await + } + + #[tauri::command] + pub async fn config_sync_get_settings( + db: State<'_, AppDatabase>, + ) -> Result { + Ok(ConfigSyncSettingsView::from(&load_settings(&db.conn).await)) + } + + #[tauri::command] + pub async fn config_sync_update_settings( + settings: ConfigSyncSettingsInput, + db: State<'_, AppDatabase>, + ) -> Result { + save_settings_core(&db.conn, settings).await + } + + #[tauri::command] + pub async fn config_sync_get_state( + db: State<'_, AppDatabase>, + ) -> Result { + Ok(load_state(&db.conn).await) + } + + /// Tests what the form currently shows WITHOUT saving it, so a user can + /// verify credentials before committing them. An empty password field + /// falls back to the stored one, same as saving would. + #[tauri::command] + pub async fn config_sync_test_connection( + settings: ConfigSyncSettingsInput, + db: State<'_, AppDatabase>, + ) -> Result<(), AppCommandError> { + let existing = load_settings(&db.conn).await; + let candidate = merge_settings(&existing, settings)?; + test_connection_core(&candidate).await + } + + /// The manual "sync now" button: uploads even when the hash says nothing + /// changed, because the user pressing it usually means they suspect the + /// remote is out of date. + #[tauri::command] + pub async fn config_sync_upload_now( + db: State<'_, AppDatabase>, + ) -> Result { + upload_snapshot_core(&db.conn, APP_VERSION, true).await + } + + #[tauri::command] + pub async fn config_sync_peek_remote( + db: State<'_, AppDatabase>, + ) -> Result, AppCommandError> { + peek_remote_core(&db.conn).await + } + + #[tauri::command] + pub async fn config_sync_download_apply( + db: State<'_, AppDatabase>, + ) -> Result { + download_and_apply_core(&db.conn).await + } +} + +#[cfg(feature = "tauri-runtime")] +pub use tauri_commands::*; diff --git a/src-tauri/src/commands/config_sync/portable_keys.rs b/src-tauri/src/commands/config_sync/portable_keys.rs new file mode 100644 index 0000000000..3507b9cf7e --- /dev/null +++ b/src-tauri/src/commands/config_sync/portable_keys.rs @@ -0,0 +1,105 @@ +//! The allowlist of `app_metadata` keys a config snapshot may carry. +//! +//! `app_metadata` is a junk drawer: language, appearance, delegation toggles, +//! OAuth tokens, the local web-service port, and this feature's own WebDAV +//! credentials all share it. An allowlist — not a denylist — is the only safe +//! shape here: a key added by a future feature is device-local until somebody +//! deliberately puts it in this table, so nobody can leak a machine-bound +//! value to another machine by simply forgetting to exclude it. +//! +//! [`FORBIDDEN_PREFERENCE_KEYS`] names the keys that must NEVER travel, with +//! `allowlist_and_credential_keys_are_disjoint` guarding the intersection. + +/// Where the WebDAV sync configuration (including the password) is stored. +/// Deliberately NOT portable: shipping it inside a snapshot would let machine +/// A overwrite machine B's credentials and turn the two into a sync loop. +pub const CONFIG_SYNC_SETTINGS_KEY: &str = "config_sync_settings"; + +/// sha256 of the last snapshot successfully uploaded, persisted so a restart +/// does not re-upload an unchanged configuration. +pub const CONFIG_SYNC_LAST_UPLOAD_KEY: &str = "config_sync_last_upload"; + +/// `app_metadata` keys that are genuinely user preferences rather than +/// device-local state, and carry no credential. +pub const PORTABLE_PREFERENCE_KEYS: &[&str] = &[ + // Language / appearance. + "system_language_settings", + "appearance_mode", + "appearance_zoom_level", + // Close-button behavior. Safe to carry: the close path short-circuits on + // `can_hide_to_tray()` before it ever reads the preference, so pushing + // `minimize` to a machine without a tray cannot make the window unclosable. + "system_close_behavior_settings", + "logging.level", + // Sub-agent delegation. + "delegation.enabled", + "delegation.depth_limit", + "delegation.agent_defaults", + "delegation.completed_cache_max_mb", + // Agent-facing tool toggles. + "feedback.enabled", + "question.enabled", + "session_info.enabled", + "chat_authoring.automations_enabled", + "chat_authoring.work_tasks_enabled", + // Chat channel behavior that carries no credential. + "chat_command_prefix", + "chat_message_language", + "chat_event_filter", +]; + +/// Keys whose presence in a snapshot would leak a credential or clobber +/// device-local state. Not consulted at runtime — [`is_portable_key`] already +/// answers from the allowlist — but asserted against it in tests so a careless +/// addition to [`PORTABLE_PREFERENCE_KEYS`] fails loudly. +#[cfg(test)] +pub const FORBIDDEN_PREFERENCE_KEYS: &[&str] = &[ + CONFIG_SYNC_SETTINGS_KEY, + CONFIG_SYNC_LAST_UPLOAD_KEY, + "system_proxy_settings", + "system_terminal_settings", + "web_service_port", + "web_service_token", + "web_service_auto_start", + "github_accounts", + "chat_event_webhooks", + "git_settings", + "pet.config", + "forge_workbench_settings", + "canvas_revision", + "opened_tabs_version", + "token_usage_fact_schema_version", +]; + +/// Whether `key` may be collected into, and applied from, a snapshot. +/// +/// Applied on BOTH sides on purpose: collection keeps a device-local value out +/// of the uploaded file, and application keeps a hand-edited or hostile +/// snapshot from writing, say, `github_accounts` into this machine. +pub fn is_portable_key(key: &str) -> bool { + PORTABLE_PREFERENCE_KEYS.contains(&key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allowlist_has_no_duplicates_and_no_empty_keys() { + let mut seen = std::collections::HashSet::new(); + for key in PORTABLE_PREFERENCE_KEYS { + assert!(!key.is_empty()); + assert!(seen.insert(*key), "duplicate portable key: {key}"); + } + } + + #[test] + fn allowlist_and_credential_keys_are_disjoint() { + for key in FORBIDDEN_PREFERENCE_KEYS { + assert!( + !is_portable_key(key), + "{key} must never be synced but is in the allowlist" + ); + } + } +} diff --git a/src-tauri/src/commands/config_sync/snapshot.rs b/src-tauri/src/commands/config_sync/snapshot.rs new file mode 100644 index 0000000000..568b42cb9a --- /dev/null +++ b/src-tauri/src/commands/config_sync/snapshot.rs @@ -0,0 +1,674 @@ +//! Snapshot assembly, validation, and application. +//! +//! A snapshot is two files that always travel together: +//! +//! * `config.json` — `{ schemaVersion, domains: { : } }`, +//! produced by iterating [`CONFIG_DOMAINS`]. +//! * `manifest.json` — provenance (who wrote it, when, with which app +//! version) plus the size and sha256 of `config.json`. +//! +//! The manifest is what makes a half-finished upload detectable: WebDAV has no +//! multi-file transaction, so a reader that finds a `config.json` whose bytes +//! do not match the manifest checksum refuses it instead of applying a +//! truncated configuration. +//! +//! Application is one transaction over all domains. A domain that fails to +//! decode aborts the whole apply — half-applied settings ("providers moved +//! over but the agents still point at the old ones") are worse than no apply. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use sea_orm::{DatabaseConnection, TransactionTrait}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use super::domains::{count_entries, CONFIG_DOMAINS}; +use crate::app_error::{ + AppCommandError, CONFIG_SYNC_I18N_KEY_CHECKSUM, CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, + CONFIG_SYNC_I18N_KEY_NEWER_SCHEMA, +}; + +/// Bump only for a change older binaries cannot read. Adding a domain does not +/// qualify: unknown domains are ignored on read and missing domains decode as +/// empty, so both directions already degrade gracefully. +pub const SCHEMA_VERSION: u32 = 1; + +/// v1 uploads plaintext. The security boundary is the user's own +/// self-authenticated WebDAV endpoint; the field exists so a future encrypted +/// format is a value change rather than a format break, and so today's reader +/// refuses a file it cannot decrypt instead of misparsing it. +pub const ENCRYPTION_NONE: &str = "none"; + +pub const CONFIG_FILE_NAME: &str = "config.json"; +pub const MANIFEST_FILE_NAME: &str = "manifest.json"; + +/// How many pre-apply rollback snapshots to keep on disk. They are tens of KB +/// each; ten is roughly "the last few times I pulled config down". +const ROLLBACK_KEEP: usize = 10; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigSnapshot { + pub schema_version: u32, + /// Domain id → payload. A map, not a struct with one field per domain, so + /// [`CONFIG_DOMAINS`] stays the only place a domain is declared. + #[serde(default)] + pub domains: BTreeMap, +} + +impl ConfigSnapshot { + /// Entry count per domain, for the manifest and for the import preview. + pub fn counts(&self) -> BTreeMap { + self.domains + .iter() + .map(|(id, value)| (id.clone(), count_entries(value))) + .collect() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigFileMeta { + pub size: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigManifest { + pub schema_version: u32, + pub encryption: String, + /// RFC 3339, UTC. + pub created_at: String, + pub app_version: String, + /// Best-effort hostname, so a user staring at "last synced from" can tell + /// which machine wrote the snapshot. + pub source_device: String, + pub config: ConfigFileMeta, + #[serde(default)] + pub counts: BTreeMap, +} + +/// What an apply actually changed, per domain. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyReport { + pub domains: BTreeMap, + pub total: usize, +} + +/// Read every domain out of the local database. +pub async fn collect_snapshot_core( + conn: &DatabaseConnection, +) -> Result { + let mut domains = BTreeMap::new(); + for domain in CONFIG_DOMAINS { + let value = (domain.collect)(conn).await?; + domains.insert(domain.id.to_string(), value); + } + Ok(ConfigSnapshot { + schema_version: SCHEMA_VERSION, + domains, + }) +} + +/// Upsert every domain the snapshot carries, in [`CONFIG_DOMAINS`] order, in a +/// single transaction. Domains this binary does not know are ignored — a +/// snapshot from a newer build stays partially usable. +pub async fn apply_snapshot_core( + conn: &DatabaseConnection, + snapshot: &ConfigSnapshot, +) -> Result { + reject_newer_schema(snapshot.schema_version)?; + + for id in snapshot.domains.keys() { + if !CONFIG_DOMAINS.iter().any(|d| d.id == id) { + tracing::warn!("[CONFIG-SYNC] ignoring unknown snapshot domain '{id}'"); + } + } + + let tx = conn.begin().await.map_err(|e| { + AppCommandError::database_error("Begin config apply").with_detail(e.to_string()) + })?; + + let mut report = ApplyReport::default(); + for domain in CONFIG_DOMAINS { + let Some(value) = snapshot.domains.get(domain.id) else { + continue; + }; + match (domain.apply)(&tx, value).await { + Ok(applied) => { + report.total += applied; + report.domains.insert(domain.id.to_string(), applied); + } + Err(err) => { + // Roll back explicitly so the failure surfaces as the domain's + // error rather than as a dropped-transaction warning. + let _ = tx.rollback().await; + return Err(err); + } + } + } + + tx.commit().await.map_err(|e| { + AppCommandError::database_error("Commit config apply").with_detail(e.to_string()) + })?; + Ok(report) +} + +/// Pretty-printed on purpose: the file is the user's own configuration sitting +/// on their own storage, and a readable diff is worth the extra bytes. The +/// checksum is taken over exactly these bytes. +pub fn serialize_snapshot(snapshot: &ConfigSnapshot) -> Result, AppCommandError> { + serde_json::to_vec_pretty(snapshot).map_err(|e| { + AppCommandError::task_execution_failed("Serialize config snapshot") + .with_detail(e.to_string()) + }) +} + +pub fn parse_snapshot(bytes: &[u8]) -> Result { + let snapshot: ConfigSnapshot = serde_json::from_slice(bytes).map_err(|e| { + AppCommandError::invalid_input("Not a codeg config snapshot") + .with_detail(e.to_string()) + .with_i18n(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, BTreeMap::new()) + })?; + reject_newer_schema(snapshot.schema_version)?; + Ok(snapshot) +} + +pub fn parse_manifest(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| { + AppCommandError::invalid_input("Not a codeg config manifest") + .with_detail(e.to_string()) + .with_i18n(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, BTreeMap::new()) + }) +} + +pub fn build_manifest( + config_bytes: &[u8], + app_version: &str, + counts: BTreeMap, +) -> ConfigManifest { + ConfigManifest { + schema_version: SCHEMA_VERSION, + encryption: ENCRYPTION_NONE.to_string(), + created_at: Utc::now().to_rfc3339(), + app_version: app_version.to_string(), + source_device: source_device(), + config: ConfigFileMeta { + size: config_bytes.len() as u64, + sha256: sha256_hex(config_bytes), + }, + counts, + } +} + +/// Everything that must hold before a downloaded `config.json` is parsed: the +/// format is one we can read, and the bytes are the ones the writer finished +/// writing. +pub fn validate_manifest( + manifest: &ConfigManifest, + config_bytes: &[u8], +) -> Result<(), AppCommandError> { + reject_newer_schema(manifest.schema_version)?; + + if manifest.encryption != ENCRYPTION_NONE { + return Err(AppCommandError::invalid_input(format!( + "Unsupported snapshot encryption '{}'", + manifest.encryption + )) + .with_i18n(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, BTreeMap::new())); + } + + if manifest.config.size != config_bytes.len() as u64 { + return Err(checksum_error()); + } + if !manifest + .config + .sha256 + .eq_ignore_ascii_case(&sha256_hex(config_bytes)) + { + return Err(checksum_error()); + } + Ok(()) +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +fn checksum_error() -> AppCommandError { + AppCommandError::invalid_input("Config snapshot failed its checksum") + .with_i18n(CONFIG_SYNC_I18N_KEY_CHECKSUM, BTreeMap::new()) +} + +fn reject_newer_schema(schema_version: u32) -> Result<(), AppCommandError> { + if schema_version > SCHEMA_VERSION { + let mut params = BTreeMap::new(); + params.insert("snapshotVersion".to_string(), schema_version.to_string()); + params.insert("appVersion".to_string(), SCHEMA_VERSION.to_string()); + return Err(AppCommandError::invalid_input( + "Config snapshot was written by a newer version of codeg", + ) + .with_i18n(CONFIG_SYNC_I18N_KEY_NEWER_SCHEMA, params)); + } + Ok(()) +} + +/// Best-effort machine name. Deliberately env-only: a hostname lookup would +/// mean a new dependency for a label that is purely informational. +fn source_device() -> String { + for key in ["COMPUTERNAME", "HOSTNAME"] { + if let Ok(value) = std::env::var(key) { + let value = value.trim().to_string(); + if !value.is_empty() { + return value; + } + } + } + "unknown".to_string() +} + +/// Where pre-apply rollback snapshots live. +pub fn rollback_dir() -> PathBuf { + crate::paths::codeg_home_dir().join("config-snapshots") +} + +/// Write "what this machine looked like before the apply" next to the app's +/// own data, then prune to [`ROLLBACK_KEEP`]. Synchronous: the payload is tens +/// of KB, so the blocking write is shorter than the cost of a thread hop. +/// +/// Failure is reported to the caller but must never be treated as fatal by it +/// — losing the safety net is worse than not having one, but not as bad as +/// refusing to apply a configuration the user explicitly asked for. +pub fn write_rollback_snapshot( + dir: &Path, + snapshot: &ConfigSnapshot, +) -> Result { + std::fs::create_dir_all(dir).map_err(AppCommandError::io)?; + let bytes = serialize_snapshot(snapshot)?; + // Sortable, second-resolution + a millisecond suffix so two applies in the + // same second do not collide. + let name = format!("config-{}.json", Utc::now().format("%Y%m%dT%H%M%S%3f")); + let path = dir.join(name); + std::fs::write(&path, &bytes).map_err(AppCommandError::io)?; + prune_rollback_snapshots(dir, ROLLBACK_KEEP); + Ok(path) +} + +/// Newest first. +pub fn list_rollback_snapshots(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut files: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.is_file() + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("config-") && n.ends_with(".json")) + }) + .collect(); + // Names are timestamp-ordered, so lexicographic order IS chronological + // order — no filesystem mtime involved, which keeps this stable across + // copies and restores. + files.sort(); + files.reverse(); + files +} + +fn prune_rollback_snapshots(dir: &Path, keep: usize) { + for path in list_rollback_snapshots(dir).into_iter().skip(keep) { + if let Err(err) = std::fs::remove_file(&path) { + tracing::warn!("[CONFIG-SYNC] failed to prune rollback snapshot: {err}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::{agent_setting, custom_agent, model_provider, quick_message}; + use crate::db::service::app_metadata_service; + use crate::db::test_helpers::fresh_in_memory_db; + use sea_orm::{ActiveModelTrait, ActiveValue::NotSet, EntityTrait, Set}; + + async fn seed_source(conn: &DatabaseConnection) { + let now = Utc::now(); + let provider = model_provider::ActiveModel { + id: NotSet, + name: Set("DeepSeek".to_string()), + api_url: Set("https://api.deepseek.com".to_string()), + api_key: Set("sk-secret".to_string()), + agent_types_json: Set("[\"deepseek\"]".to_string()), + agent_type: Set("deepseek".to_string()), + model: Set(Some("deepseek-chat".to_string())), + created_at: Set(now), + updated_at: Set(now), + } + .insert(conn) + .await + .expect("insert provider"); + + agent_setting::ActiveModel { + id: NotSet, + agent_type: Set("deepseek".to_string()), + registry_id: Set("deepseek".to_string()), + enabled: Set(true), + sort_order: Set(3), + installed_version: Set(Some("1.2.3-local".to_string())), + env_json: Set(Some("{\"FOO\":\"bar\"}".to_string())), + model_provider_id: Set(Some(provider.id)), + created_at: Set(now), + updated_at: Set(now), + } + .insert(conn) + .await + .expect("insert agent setting"); + + quick_message::ActiveModel { + id: NotSet, + title: Set("Review".to_string()), + content: Set("Please review this diff".to_string()), + sort_order: Set(1), + created_at: Set(now), + updated_at: Set(now), + } + .insert(conn) + .await + .expect("insert quick message"); + + app_metadata_service::upsert_value(conn, "appearance_mode", "dark") + .await + .expect("portable pref"); + app_metadata_service::upsert_value(conn, "web_service_token", "device-local-token") + .await + .expect("device-local pref"); + } + + #[tokio::test] + async fn snapshot_round_trips_into_a_second_database() { + let source = fresh_in_memory_db().await; + seed_source(&source.conn).await; + + let snapshot = collect_snapshot_core(&source.conn).await.expect("collect"); + let bytes = serialize_snapshot(&snapshot).expect("serialize"); + let parsed = parse_snapshot(&bytes).expect("parse"); + + let target = fresh_in_memory_db().await; + let report = apply_snapshot_core(&target.conn, &parsed) + .await + .expect("apply"); + assert!(report.total >= 4, "unexpected apply report: {report:?}"); + + let providers = model_provider::Entity::find() + .all(&target.conn) + .await + .expect("providers"); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].api_key, "sk-secret"); + + let settings = agent_setting::Entity::find() + .all(&target.conn) + .await + .expect("settings"); + assert_eq!(settings.len(), 1); + // The provider reference was remapped to the TARGET machine's row id. + assert_eq!(settings[0].model_provider_id, Some(providers[0].id)); + // Device-local: the source machine's installed CLI version must not + // travel, or the target would skip its own install/upgrade prompt. + assert_eq!(settings[0].installed_version, None); + + let messages = quick_message::Entity::find() + .all(&target.conn) + .await + .expect("quick messages"); + assert_eq!(messages.len(), 1); + + assert_eq!( + app_metadata_service::get_value(&target.conn, "appearance_mode") + .await + .expect("pref"), + Some("dark".to_string()) + ); + } + + #[tokio::test] + async fn device_local_preferences_never_enter_a_snapshot() { + let source = fresh_in_memory_db().await; + seed_source(&source.conn).await; + + let snapshot = collect_snapshot_core(&source.conn).await.expect("collect"); + let prefs = snapshot.domains.get("preferences").expect("preferences"); + assert!(prefs.get("appearance_mode").is_some()); + assert!( + prefs.get("web_service_token").is_none(), + "device-local key leaked into the snapshot: {prefs}" + ); + } + + /// A hand-edited or hostile snapshot must not be able to write a key the + /// allowlist excludes — the collect-side filter is not the only guard. + #[tokio::test] + async fn applying_a_doctored_preference_key_is_ignored() { + let target = fresh_in_memory_db().await; + let snapshot = ConfigSnapshot { + schema_version: SCHEMA_VERSION, + domains: BTreeMap::from([( + "preferences".to_string(), + serde_json::json!({ + "appearance_mode": "light", + "web_service_token": "stolen", + "config_sync_settings": "{}" + }), + )]), + }; + + apply_snapshot_core(&target.conn, &snapshot) + .await + .expect("apply"); + + assert_eq!( + app_metadata_service::get_value(&target.conn, "appearance_mode") + .await + .expect("pref"), + Some("light".to_string()) + ); + for key in ["web_service_token", "config_sync_settings"] { + assert_eq!( + app_metadata_service::get_value(&target.conn, key) + .await + .expect("pref"), + None, + "{key} must not be writable from a snapshot" + ); + } + } + + /// Re-applying the same snapshot must converge, not duplicate: the whole + /// point of natural-key upserts. + #[tokio::test] + async fn applying_twice_updates_instead_of_duplicating() { + let source = fresh_in_memory_db().await; + seed_source(&source.conn).await; + let snapshot = collect_snapshot_core(&source.conn).await.expect("collect"); + + let target = fresh_in_memory_db().await; + apply_snapshot_core(&target.conn, &snapshot) + .await + .expect("first apply"); + apply_snapshot_core(&target.conn, &snapshot) + .await + .expect("second apply"); + + assert_eq!( + model_provider::Entity::find() + .all(&target.conn) + .await + .expect("providers") + .len(), + 1 + ); + assert_eq!( + quick_message::Entity::find() + .all(&target.conn) + .await + .expect("messages") + .len(), + 1 + ); + } + + /// Rows the snapshot does not mention are the user's local work; an apply + /// is an upsert, never a mirror. + #[tokio::test] + async fn local_only_rows_survive_an_apply() { + let target = fresh_in_memory_db().await; + let now = Utc::now(); + quick_message::ActiveModel { + id: NotSet, + title: Set("Local only".to_string()), + content: Set("keep me".to_string()), + sort_order: Set(9), + created_at: Set(now), + updated_at: Set(now), + } + .insert(&target.conn) + .await + .expect("seed local"); + + let source = fresh_in_memory_db().await; + seed_source(&source.conn).await; + let snapshot = collect_snapshot_core(&source.conn).await.expect("collect"); + apply_snapshot_core(&target.conn, &snapshot) + .await + .expect("apply"); + + let titles: Vec = quick_message::Entity::find() + .all(&target.conn) + .await + .expect("messages") + .into_iter() + .map(|m| m.title) + .collect(); + assert!(titles.contains(&"Local only".to_string())); + assert!(titles.contains(&"Review".to_string())); + } + + /// `skills_dir` is an absolute path on the machine that owns it. + #[tokio::test] + async fn custom_agent_skills_dir_stays_local() { + let target = fresh_in_memory_db().await; + let now = Utc::now(); + custom_agent::ActiveModel { + id: NotSet, + registry_id: Set("acme".to_string()), + name: Set("Acme".to_string()), + description: Set(String::new()), + version: Set("1.0.0".to_string()), + distribution_kind: Set("npm".to_string()), + spec_json: Set("{}".to_string()), + icon_url: Set(None), + skills_shared_store: Set(false), + skills_dir: Set(Some("D:/local/skills".to_string())), + source: Set("user".to_string()), + version_probe: Set(None), + supports_mcp: Set(false), + created_at: Set(now), + updated_at: Set(now), + } + .insert(&target.conn) + .await + .expect("seed agent"); + + let snapshot = ConfigSnapshot { + schema_version: SCHEMA_VERSION, + domains: BTreeMap::from([( + "customAgents".to_string(), + serde_json::json!([{ + "registryId": "acme", + "name": "Acme Renamed", + "version": "2.0.0", + "distributionKind": "npm", + "specJson": "{}", + "skillsDir": "/somewhere/else" + }]), + )]), + }; + apply_snapshot_core(&target.conn, &snapshot) + .await + .expect("apply"); + + let row = custom_agent::Entity::find() + .all(&target.conn) + .await + .expect("agents") + .remove(0); + assert_eq!(row.name, "Acme Renamed"); + assert_eq!(row.skills_dir, Some("D:/local/skills".to_string())); + } + + #[test] + fn manifest_validates_matching_bytes_and_rejects_tampering() { + let snapshot = ConfigSnapshot { + schema_version: SCHEMA_VERSION, + domains: BTreeMap::from([("preferences".to_string(), serde_json::json!({}))]), + }; + let bytes = serialize_snapshot(&snapshot).expect("serialize"); + let manifest = build_manifest(&bytes, "1.0.0", snapshot.counts()); + + validate_manifest(&manifest, &bytes).expect("matching bytes validate"); + + let mut tampered = bytes.clone(); + tampered.extend_from_slice(b"\n"); + let err = validate_manifest(&manifest, &tampered).expect_err("must reject"); + assert_eq!( + err.i18n_key.as_deref(), + Some(CONFIG_SYNC_I18N_KEY_CHECKSUM) + ); + } + + #[test] + fn a_newer_schema_is_refused_rather_than_guessed_at() { + let bytes = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": SCHEMA_VERSION + 1, + "domains": {} + })) + .expect("bytes"); + let err = parse_snapshot(&bytes).expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(CONFIG_SYNC_I18N_KEY_NEWER_SCHEMA) + ); + } + + #[test] + fn rollback_snapshots_are_pruned_newest_first() { + let dir = tempfile::tempdir().expect("tempdir"); + let snapshot = ConfigSnapshot { + schema_version: SCHEMA_VERSION, + domains: BTreeMap::new(), + }; + for index in 0..(ROLLBACK_KEEP + 3) { + // Distinct, monotonically increasing names without waiting on the + // wall clock. + let path = dir + .path() + .join(format!("config-20240101T00000{index:03}.json")); + std::fs::create_dir_all(dir.path()).expect("dir"); + std::fs::write(&path, serialize_snapshot(&snapshot).expect("bytes")).expect("write"); + } + prune_rollback_snapshots(dir.path(), ROLLBACK_KEEP); + let remaining = list_rollback_snapshots(dir.path()); + assert_eq!(remaining.len(), ROLLBACK_KEEP); + // Newest first, and the pruned ones are the oldest. + assert!(remaining[0] > remaining[remaining.len() - 1]); + } +} diff --git a/src-tauri/src/commands/config_sync/webdav_sync.rs b/src-tauri/src/commands/config_sync/webdav_sync.rs new file mode 100644 index 0000000000..0510f0a0da --- /dev/null +++ b/src-tauri/src/commands/config_sync/webdav_sync.rs @@ -0,0 +1,618 @@ +//! Sync settings, remote layout, and the upload/download choreography. +//! +//! ## Remote layout +//! +//! `{remoteDir}/v{PROTOCOL_VERSION}/{profile}/{config.json,manifest.json}` +//! +//! The `v1` level means a future incompatible protocol can land beside this +//! one instead of on top of it, and `{profile}` lets one share hold several +//! independent configurations (work vs personal) without extra accounts. +//! +//! ## Upload order is load-bearing +//! +//! `config.json` first, `manifest.json` second. WebDAV has no multi-file +//! transaction, so an interrupted upload leaves the OLD manifest pointing at +//! the NEW config — and the downloader's checksum check rejects that pair +//! instead of applying a half-written configuration. Writing the manifest +//! first would invert this into "looks valid, is truncated". + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use super::snapshot::{ + apply_snapshot_core, build_manifest, collect_snapshot_core, parse_manifest, parse_snapshot, + serialize_snapshot, sha256_hex, validate_manifest, ApplyReport, ConfigManifest, + CONFIG_FILE_NAME, MANIFEST_FILE_NAME, +}; +use crate::app_error::{AppCommandError, CONFIG_SYNC_I18N_KEY_NO_REMOTE}; +use crate::db::service::app_metadata_service; +use crate::network::webdav::{sanitize_path_segment, WebdavClient}; + +/// Credentials of this feature. NOT in `portable_keys`: if it travelled, one +/// machine's credentials would overwrite the other's and the two would sync +/// into each other in a loop. +pub const CONFIG_SYNC_SETTINGS_KEY: &str = "config_sync_settings"; +/// Last-uploaded hash and last result. Device-local by nature. +pub const CONFIG_SYNC_STATE_KEY: &str = "config_sync_state"; + +/// Remote layout version, independent of the snapshot's `schemaVersion`. +pub const PROTOCOL_VERSION: u32 = 1; + +pub const DEFAULT_REMOTE_DIR: &str = "codeg"; +pub const DEFAULT_PROFILE: &str = "default"; +pub const DEFAULT_INTERVAL_MINUTES: u32 = 5; +/// A day. Not a real limit, just a guard against a value that would overflow +/// the backoff multiplier or park the timer past the heat death of the laptop. +const MAX_INTERVAL_MINUTES: u32 = 1440; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ConfigSyncSettings { + pub enabled: bool, + pub server_url: String, + pub username: String, + /// Stored as given. See the module docs of `mod.rs` for why the snapshot + /// itself stays unencrypted; this value never leaves the local database. + pub password: String, + pub remote_dir: String, + pub profile: String, + pub auto_sync: bool, + pub interval_minutes: u32, +} + +impl Default for ConfigSyncSettings { + fn default() -> Self { + Self { + enabled: false, + server_url: String::new(), + username: String::new(), + password: String::new(), + remote_dir: DEFAULT_REMOTE_DIR.to_string(), + profile: DEFAULT_PROFILE.to_string(), + auto_sync: true, + interval_minutes: DEFAULT_INTERVAL_MINUTES, + } + } +} + +/// What the frontend sees. The password is replaced by "is one stored", so a +/// compromised renderer cannot read it back and the settings form has nothing +/// to accidentally re-submit. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigSyncSettingsView { + pub enabled: bool, + pub server_url: String, + pub username: String, + pub has_password: bool, + pub remote_dir: String, + pub profile: String, + pub auto_sync: bool, + pub interval_minutes: u32, +} + +impl From<&ConfigSyncSettings> for ConfigSyncSettingsView { + fn from(settings: &ConfigSyncSettings) -> Self { + Self { + enabled: settings.enabled, + server_url: settings.server_url.clone(), + username: settings.username.clone(), + has_password: !settings.password.is_empty(), + remote_dir: settings.remote_dir.clone(), + profile: settings.profile.clone(), + auto_sync: settings.auto_sync, + interval_minutes: settings.interval_minutes, + } + } +} + +/// Save payload. `password: None` (or empty) means "keep what is stored" — +/// the ONLY password mechanism, deliberately. +/// +/// The alternative, rendering a masked placeholder into the password field, +/// has a known failure mode: the form submits the mask verbatim and the mask +/// becomes the password, so the next sync fails to authenticate. There is also +/// no separate `passwordTouched` flag, because a flag plus a value is two +/// sources of truth that disagree exactly when the user clears the field. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigSyncSettingsInput { + pub enabled: bool, + pub server_url: String, + pub username: String, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub remote_dir: Option, + #[serde(default)] + pub profile: Option, + pub auto_sync: bool, + pub interval_minutes: u32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ConfigSyncState { + /// Hash of the last snapshot successfully uploaded. Persisted so a restart + /// does not re-upload an unchanged configuration just to rebuild an + /// in-memory baseline. + pub last_uploaded_sha256: Option, + pub last_sync_at: Option, + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadOutcome { + /// `false` means the snapshot was byte-identical to the last upload and + /// nothing was sent. + pub uploaded: bool, + pub sha256: String, + pub counts: BTreeMap, + pub synced_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadOutcome { + pub manifest: ConfigManifest, + pub applied: ApplyReport, + pub rollback_path: Option, +} + +/// Every remote read/write funnels through here. An upload is two PUTs; two +/// concurrent uploads would interleave into a manifest from one snapshot and a +/// config from another. +fn remote_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +// ─── settings persistence ───────────────────────────────────────────── + +/// Never fails: a row this build cannot parse degrades to defaults (sync off) +/// rather than breaking the settings page. +pub async fn load_settings(conn: &DatabaseConnection) -> ConfigSyncSettings { + let raw = match app_metadata_service::get_value(conn, CONFIG_SYNC_SETTINGS_KEY).await { + Ok(Some(raw)) => raw, + Ok(None) => return ConfigSyncSettings::default(), + Err(err) => { + tracing::warn!("[CONFIG-SYNC] failed to read sync settings: {err}"); + return ConfigSyncSettings::default(); + } + }; + match serde_json::from_str::(&raw) { + Ok(settings) => settings, + Err(err) => { + tracing::warn!("[CONFIG-SYNC] failed to parse sync settings: {err}"); + ConfigSyncSettings::default() + } + } +} + +pub async fn save_settings_core( + conn: &DatabaseConnection, + input: ConfigSyncSettingsInput, +) -> Result { + let existing = load_settings(conn).await; + let merged = merge_settings(&existing, input)?; + + let serialized = serde_json::to_string(&merged).map_err(|e| { + AppCommandError::invalid_input("Failed to serialize config sync settings") + .with_detail(e.to_string()) + })?; + app_metadata_service::upsert_value(conn, CONFIG_SYNC_SETTINGS_KEY, &serialized) + .await + .map_err(AppCommandError::db)?; + + Ok(ConfigSyncSettingsView::from(&merged)) +} + +/// Pure so the password-retention and path-validation rules are testable +/// without a database. +pub fn merge_settings( + existing: &ConfigSyncSettings, + input: ConfigSyncSettingsInput, +) -> Result { + let password = match input.password { + Some(value) if !value.is_empty() => value, + // Both `None` and `Some("")` keep the stored password. An empty field + // means "I did not retype it", which is what an empty password field + // means to every user who has ever seen one. + _ => existing.password.clone(), + }; + + let remote_dir = normalize_segment(input.remote_dir, &existing.remote_dir, DEFAULT_REMOTE_DIR)?; + let profile = normalize_segment(input.profile, &existing.profile, DEFAULT_PROFILE)?; + + Ok(ConfigSyncSettings { + enabled: input.enabled, + server_url: input.server_url.trim().to_string(), + username: input.username.trim().to_string(), + password, + remote_dir, + profile, + auto_sync: input.auto_sync, + interval_minutes: input + .interval_minutes + .clamp(1, MAX_INTERVAL_MINUTES), + }) +} + +fn normalize_segment( + incoming: Option, + existing: &str, + fallback: &str, +) -> Result { + let candidate = incoming.unwrap_or_else(|| existing.to_string()); + let candidate = if candidate.trim().is_empty() { + fallback.to_string() + } else { + candidate + }; + sanitize_path_segment(&candidate).ok_or_else(|| { + AppCommandError::invalid_input(format!("Invalid remote path segment: {candidate}")) + .with_i18n( + crate::app_error::CONFIG_SYNC_I18N_KEY_REMOTE_PATH, + BTreeMap::new(), + ) + }) +} + +pub async fn load_state(conn: &DatabaseConnection) -> ConfigSyncState { + match app_metadata_service::get_value(conn, CONFIG_SYNC_STATE_KEY).await { + Ok(Some(raw)) => serde_json::from_str(&raw).unwrap_or_default(), + _ => ConfigSyncState::default(), + } +} + +pub async fn save_state(conn: &DatabaseConnection, state: &ConfigSyncState) { + let Ok(serialized) = serde_json::to_string(state) else { + return; + }; + if let Err(err) = + app_metadata_service::upsert_value(conn, CONFIG_SYNC_STATE_KEY, &serialized).await + { + // Losing the hash baseline costs one redundant upload, not data. + tracing::warn!("[CONFIG-SYNC] failed to persist sync state: {err}"); + } +} + +// ─── remote paths ───────────────────────────────────────────────────── + +/// `{remoteDir}/v1/{profile}` — the directory the two files live in. +pub fn remote_dir_path(settings: &ConfigSyncSettings) -> Result { + let dir = normalize_segment(Some(settings.remote_dir.clone()), DEFAULT_REMOTE_DIR, DEFAULT_REMOTE_DIR)?; + let profile = normalize_segment(Some(settings.profile.clone()), DEFAULT_PROFILE, DEFAULT_PROFILE)?; + Ok(format!("{dir}/v{PROTOCOL_VERSION}/{profile}")) +} + +fn client_for(settings: &ConfigSyncSettings) -> Result { + WebdavClient::new(&settings.server_url, &settings.username, &settings.password) + .map_err(AppCommandError::from) +} + +// ─── operations ─────────────────────────────────────────────────────── + +/// Credentials + reachability, without writing anything. +pub async fn test_connection_core(settings: &ConfigSyncSettings) -> Result<(), AppCommandError> { + let client = client_for(settings)?; + let dir = remote_dir_path(settings)?; + let _guard = remote_lock().lock().await; + client.probe(&dir).await.map_err(AppCommandError::from) +} + +/// Collect → hash → skip-if-unchanged → upload. +/// +/// `force` bypasses only the hash comparison (the manual "sync now" button); +/// it never bypasses validation. +pub async fn upload_snapshot_core( + conn: &DatabaseConnection, + app_version: &str, + force: bool, +) -> Result { + let settings = load_settings(conn).await; + let snapshot = collect_snapshot_core(conn).await?; + let bytes = serialize_snapshot(&snapshot)?; + let hash = sha256_hex(&bytes); + let counts = snapshot.counts(); + + let mut state = load_state(conn).await; + if !force && state.last_uploaded_sha256.as_deref() == Some(hash.as_str()) { + // The common case on a timer: nothing changed, so nothing is sent and + // no request is made at all. + return Ok(UploadOutcome { + uploaded: false, + sha256: hash, + counts, + synced_at: state.last_sync_at.clone().unwrap_or_default(), + }); + } + + let client = client_for(&settings)?; + let dir = remote_dir_path(&settings)?; + let manifest = build_manifest(&bytes, app_version, counts.clone()); + let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| { + AppCommandError::task_execution_failed("Serialize manifest").with_detail(e.to_string()) + })?; + + let result = async { + let _guard = remote_lock().lock().await; + client.ensure_dir(&dir).await?; + client.put(&format!("{dir}/{CONFIG_FILE_NAME}"), bytes).await?; + client + .put(&format!("{dir}/{MANIFEST_FILE_NAME}"), manifest_bytes) + .await?; + Ok::<(), crate::network::webdav::WebdavError>(()) + } + .await; + + let synced_at = chrono::Utc::now().to_rfc3339(); + match result { + Ok(()) => { + state.last_uploaded_sha256 = Some(hash.clone()); + state.last_sync_at = Some(synced_at.clone()); + state.last_error = None; + save_state(conn, &state).await; + Ok(UploadOutcome { + uploaded: true, + sha256: hash, + counts, + synced_at, + }) + } + Err(err) => { + let app_error = AppCommandError::from(err); + state.last_error = Some(app_error.message.clone()); + save_state(conn, &state).await; + Err(app_error) + } + } +} + +/// Fetch the remote pair, verify it, and apply it locally. +/// +/// Always explicit: nothing here runs on a timer. Automatic download would +/// mean a machine silently overwriting local configuration with whatever +/// another machine last pushed. +pub async fn download_and_apply_core( + conn: &DatabaseConnection, +) -> Result { + let settings = load_settings(conn).await; + let client = client_for(&settings)?; + let dir = remote_dir_path(&settings)?; + + let (manifest_bytes, config_bytes) = { + let _guard = remote_lock().lock().await; + let manifest_bytes = client + .get(&format!("{dir}/{MANIFEST_FILE_NAME}")) + .await + .map_err(AppCommandError::from)?; + let config_bytes = client + .get(&format!("{dir}/{CONFIG_FILE_NAME}")) + .await + .map_err(AppCommandError::from)?; + (manifest_bytes, config_bytes) + }; + + let (Some(manifest_bytes), Some(config_bytes)) = (manifest_bytes, config_bytes) else { + return Err( + AppCommandError::invalid_input("No config snapshot on the remote yet") + .with_i18n(CONFIG_SYNC_I18N_KEY_NO_REMOTE, BTreeMap::new()), + ); + }; + + let manifest = parse_manifest(&manifest_bytes)?; + // Checksum first: an interrupted upload must never reach the database. + validate_manifest(&manifest, &config_bytes)?; + let snapshot = parse_snapshot(&config_bytes)?; + + // Hold the suppression guard across the apply. Applying rewrites local + // configuration, and an auto-sync tick landing mid-apply would push a + // half-merged state straight back to the remote. + let _suppression = super::auto_sync::suppress_auto_sync(); + let rollback_path = super::local_io::save_rollback(conn).await; + let applied = apply_snapshot_core(conn, &snapshot).await?; + + Ok(DownloadOutcome { + manifest, + applied, + rollback_path, + }) +} + +/// Read the remote manifest without applying anything — powers "the remote has +/// a snapshot from DESKTOP-42, 3 providers, 2 hours ago". +pub async fn peek_remote_core( + conn: &DatabaseConnection, +) -> Result, AppCommandError> { + let settings = load_settings(conn).await; + let client = client_for(&settings)?; + let dir = remote_dir_path(&settings)?; + + let bytes = { + let _guard = remote_lock().lock().await; + client + .get(&format!("{dir}/{MANIFEST_FILE_NAME}")) + .await + .map_err(AppCommandError::from)? + }; + match bytes { + Some(bytes) => Ok(Some(parse_manifest(&bytes)?)), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::fresh_in_memory_db; + + fn input() -> ConfigSyncSettingsInput { + ConfigSyncSettingsInput { + enabled: true, + server_url: " https://dav.example.com/dav ".to_string(), + username: " alice ".to_string(), + password: Some("app-password".to_string()), + remote_dir: Some("codeg".to_string()), + profile: Some("work".to_string()), + auto_sync: true, + interval_minutes: 5, + } + } + + #[test] + fn an_empty_password_field_keeps_the_stored_one() { + let existing = ConfigSyncSettings { + password: "stored".to_string(), + ..Default::default() + }; + + for submitted in [None, Some(String::new())] { + let merged = merge_settings( + &existing, + ConfigSyncSettingsInput { + password: submitted.clone(), + ..input() + }, + ) + .expect("merge"); + assert_eq!( + merged.password, "stored", + "submitted {submitted:?} must not clear the password" + ); + } + + let merged = merge_settings(&existing, input()).expect("merge"); + assert_eq!(merged.password, "app-password"); + } + + #[test] + fn urls_and_usernames_are_trimmed_and_intervals_clamped() { + let merged = merge_settings( + &ConfigSyncSettings::default(), + ConfigSyncSettingsInput { + interval_minutes: 0, + ..input() + }, + ) + .expect("merge"); + assert_eq!(merged.server_url, "https://dav.example.com/dav"); + assert_eq!(merged.username, "alice"); + assert_eq!(merged.interval_minutes, 1); + + let merged = merge_settings( + &ConfigSyncSettings::default(), + ConfigSyncSettingsInput { + interval_minutes: u32::MAX, + ..input() + }, + ) + .expect("merge"); + assert_eq!(merged.interval_minutes, MAX_INTERVAL_MINUTES); + } + + #[test] + fn traversal_in_a_path_segment_is_refused() { + for bad in ["..", "a/b", "a\\b"] { + let err = merge_settings( + &ConfigSyncSettings::default(), + ConfigSyncSettingsInput { + remote_dir: Some(bad.to_string()), + ..input() + }, + ) + .expect_err("must reject"); + assert_eq!( + err.i18n_key.as_deref(), + Some(crate::app_error::CONFIG_SYNC_I18N_KEY_REMOTE_PATH) + ); + } + } + + #[test] + fn blank_segments_fall_back_to_defaults() { + let merged = merge_settings( + &ConfigSyncSettings::default(), + ConfigSyncSettingsInput { + remote_dir: Some(" ".to_string()), + profile: None, + ..input() + }, + ) + .expect("merge"); + assert_eq!(merged.remote_dir, DEFAULT_REMOTE_DIR); + assert_eq!(merged.profile, DEFAULT_PROFILE); + } + + #[test] + fn remote_path_carries_the_protocol_version_and_profile() { + let settings = ConfigSyncSettings { + remote_dir: "backups".to_string(), + profile: "work".to_string(), + ..Default::default() + }; + assert_eq!(remote_dir_path(&settings).expect("path"), "backups/v1/work"); + } + + #[tokio::test] + async fn the_view_never_carries_the_password() { + let db = fresh_in_memory_db().await; + let view = save_settings_core(&db.conn, input()).await.expect("save"); + assert!(view.has_password); + + let serialized = serde_json::to_string(&view).expect("serialize"); + assert!( + !serialized.contains("app-password"), + "password leaked to the frontend: {serialized}" + ); + + // And it round-trips through the database untouched. + let stored = load_settings(&db.conn).await; + assert_eq!(stored.password, "app-password"); + assert_eq!(stored.profile, "work"); + } + + #[tokio::test] + async fn sync_state_survives_a_reload() { + let db = fresh_in_memory_db().await; + let state = ConfigSyncState { + last_uploaded_sha256: Some("abc".to_string()), + last_sync_at: Some("2026-01-01T00:00:00Z".to_string()), + last_error: None, + }; + save_state(&db.conn, &state).await; + let loaded = load_state(&db.conn).await; + assert_eq!(loaded.last_uploaded_sha256.as_deref(), Some("abc")); + } + + /// An unchanged configuration must not touch the network — this is what + /// makes a 5-minute timer acceptable. + #[tokio::test] + async fn an_unchanged_snapshot_skips_the_upload_entirely() { + let db = fresh_in_memory_db().await; + let snapshot = collect_snapshot_core(&db.conn).await.expect("collect"); + let hash = sha256_hex(&serialize_snapshot(&snapshot).expect("bytes")); + save_state( + &db.conn, + &ConfigSyncState { + last_uploaded_sha256: Some(hash.clone()), + last_sync_at: Some("2026-01-01T00:00:00Z".to_string()), + last_error: None, + }, + ) + .await; + + // No server is configured, so reaching the transport at all would + // surface as an error rather than a skip. + let outcome = upload_snapshot_core(&db.conn, "1.0.0", false) + .await + .expect("skip without network"); + assert!(!outcome.uploaded); + assert_eq!(outcome.sha256, hash); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 833397b1b4..c6abfccb28 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod backup; pub mod canvas; pub mod chat_authoring; pub mod chat_channel; +pub mod config_sync; pub mod conversations; pub mod custom_agents; pub mod custom_skills; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b33af7db67..47956232e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -82,6 +82,7 @@ mod tauri_app { automation as automation_commands, background as background_commands, backup, canvas as canvas_commands, chat_authoring as chat_authoring_commands, chat_channel as chat_channel_commands, + config_sync, conversations, custom_skills as custom_skills_commands, deepseek_settings as deepseek_settings_commands, delegation as delegation_commands, @@ -781,6 +782,26 @@ mod tauri_app { }); } + // Start the config-sync uploader. Background and detached: + // it sleeps a minute before its first hash compare, reads its + // settings every tick (so toggling sync in the UI takes effect + // without a restart), and does nothing at all until the user + // configures a WebDAV endpoint. + { + let db_for_sync = app.state::().conn.clone(); + let emitter = std::sync::Arc::new(web::event_bridge::EventEmitter::Tauri( + app.handle().clone(), + )); + tauri::async_runtime::spawn(async move { + crate::commands::config_sync::auto_sync::run_auto_sync_loop( + db_for_sync, + emitter, + env!("CARGO_PKG_VERSION").to_string(), + ) + .await; + }); + } + // Label worktree folders registered before aliases were seeded at // creation with the branch they have checked out, so the sidebar // names them by branch rather than by their (long, derived) @@ -1842,6 +1863,16 @@ mod tauri_app { notification::open_system_notification_settings, file_io::save_binary_file, file_io::save_text_file, + config_sync::config_sync_export_file, + config_sync::config_sync_peek_file, + config_sync::config_sync_import_file, + config_sync::config_sync_get_settings, + config_sync::config_sync_update_settings, + config_sync::config_sync_get_state, + config_sync::config_sync_test_connection, + config_sync::config_sync_upload_now, + config_sync::config_sync_peek_remote, + config_sync::config_sync_download_apply, backup::backup_create, backup::backup_prepare_source, backup::backup_release_source, diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index 44dcc92d61..769d9025c6 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -1 +1,2 @@ pub mod proxy; +pub mod webdav; diff --git a/src-tauri/src/network/webdav.rs b/src-tauri/src/network/webdav.rs new file mode 100644 index 0000000000..e975171237 --- /dev/null +++ b/src-tauri/src/network/webdav.rs @@ -0,0 +1,448 @@ +//! A minimal WebDAV client: exactly the four verbs config sync needs. +//! +//! Deliberately does NOT parse WebDAV XML. `PROPFIND` is used only as a +//! "are these credentials good and does this path exist" probe and `MKCOL` +//! only as "make sure this directory exists", so status codes carry all the +//! information we need — and skipping the bodies means no XML dependency for +//! a feature that moves two JSON files. +//! +//! ## Secrets never reach a log line +//! +//! The base URL can embed a username, and `Authorization` carries the +//! password. [`WebdavError`]'s `Display` is therefore built only from the +//! method, the RELATIVE path, and the status code — never from the URL, the +//! headers, or a `reqwest::Error` (whose own `Display` includes the full URL). +//! A test asserts a password cannot appear in a rendered error. + +use std::fmt; +use std::time::Duration; + +use reqwest::{Client, Method, Response, StatusCode, Url}; + +use crate::app_error::{ + AppCommandError, CONFIG_SYNC_I18N_KEY_FORBIDDEN, CONFIG_SYNC_I18N_KEY_NETWORK, + CONFIG_SYNC_I18N_KEY_QUOTA, CONFIG_SYNC_I18N_KEY_REMOTE_PATH, CONFIG_SYNC_I18N_KEY_SERVER, + CONFIG_SYNC_I18N_KEY_UNAUTHORIZED, +}; + +/// Credentials and reachability: short, because a wrong URL should fail while +/// the user is still looking at the settings page. +const PROBE_TIMEOUT: Duration = Duration::from_secs(15); +/// Transfers: generous. The payload is tens of KB, so this is tolerance for +/// slow consumer cloud drives, not for size. +const TRANSFER_TIMEOUT: Duration = Duration::from_secs(120); +/// Hard cap on a downloaded body. A config snapshot is tens of KB; anything +/// near this is a misconfigured path or a hostile endpoint, and we refuse it +/// rather than buffering it into memory. +const MAX_DOWNLOAD_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WebdavError { + /// The server URL is not a usable http(s) URL. + InvalidUrl, + /// 401 — on most consumer drives this means "you used your login password + /// where an app password is required", which the UI says explicitly. + Unauthorized, + /// 403 — authenticated, but not allowed to touch this path. + Forbidden, + /// 404/409 on a write — the remote directory is missing and could not be + /// created. + RemotePathMissing, + /// 507 / 413 — the share is full. + InsufficientStorage, + /// The response body exceeded [`MAX_DOWNLOAD_BYTES`]. + ResponseTooLarge, + /// Never reached the server: DNS, TLS, timeout, offline. + Network, + /// Reached the server, got something we do not handle. + Server(u16), +} + +impl fmt::Display for WebdavError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidUrl => write!(f, "invalid WebDAV server URL"), + Self::Unauthorized => write!(f, "WebDAV authentication failed (401)"), + Self::Forbidden => write!(f, "WebDAV access denied (403)"), + Self::RemotePathMissing => write!(f, "WebDAV remote directory unavailable"), + Self::InsufficientStorage => write!(f, "WebDAV storage quota exceeded"), + Self::ResponseTooLarge => write!(f, "WebDAV response exceeded the size limit"), + Self::Network => write!(f, "WebDAV request did not reach the server"), + Self::Server(status) => write!(f, "WebDAV server returned status {status}"), + } + } +} + +impl std::error::Error for WebdavError {} + +impl From for AppCommandError { + fn from(err: WebdavError) -> Self { + let message = err.to_string(); + match err { + WebdavError::InvalidUrl => AppCommandError::invalid_input(message) + .with_i18n(CONFIG_SYNC_I18N_KEY_REMOTE_PATH, Default::default()), + WebdavError::Unauthorized => AppCommandError::network(message) + .with_i18n(CONFIG_SYNC_I18N_KEY_UNAUTHORIZED, Default::default()), + WebdavError::Forbidden => AppCommandError::network(message) + .with_i18n(CONFIG_SYNC_I18N_KEY_FORBIDDEN, Default::default()), + WebdavError::RemotePathMissing => AppCommandError::network(message) + .with_i18n(CONFIG_SYNC_I18N_KEY_REMOTE_PATH, Default::default()), + WebdavError::InsufficientStorage => AppCommandError::network(message) + .with_i18n(CONFIG_SYNC_I18N_KEY_QUOTA, Default::default()), + WebdavError::Network | WebdavError::ResponseTooLarge => { + AppCommandError::network(message) + .with_i18n(CONFIG_SYNC_I18N_KEY_NETWORK, Default::default()) + } + WebdavError::Server(status) => { + let mut params = std::collections::BTreeMap::new(); + params.insert("status".to_string(), status.to_string()); + AppCommandError::network(message) + .with_i18n(CONFIG_SYNC_I18N_KEY_SERVER, params) + } + } + } +} + +pub struct WebdavClient { + http: Client, + /// Always ends in `/` so relative segments append rather than replace the + /// last path component. + base: Url, + username: String, + password: String, +} + +impl WebdavClient { + pub fn new( + server_url: &str, + username: &str, + password: &str, + ) -> Result { + let trimmed = server_url.trim(); + if trimmed.is_empty() { + return Err(WebdavError::InvalidUrl); + } + let mut base = Url::parse(trimmed).map_err(|_| WebdavError::InvalidUrl)?; + if !matches!(base.scheme(), "http" | "https") { + return Err(WebdavError::InvalidUrl); + } + // `Url::join`-style appending drops the final segment unless the path + // ends in a slash, which would silently write into the parent of the + // directory the user configured. + if !base.path().ends_with('/') { + let path = format!("{}/", base.path()); + base.set_path(&path); + } + + let http = Client::builder() + .timeout(TRANSFER_TIMEOUT) + .build() + .map_err(|_| WebdavError::Network)?; + + Ok(Self { + http, + base, + username: username.to_string(), + password: password.to_string(), + }) + } + + /// Percent-encodes each segment, so a profile named `my drive` or a + /// non-ASCII directory works without the caller pre-encoding anything. + fn url_for(&self, rel: &str) -> Result { + let mut url = self.base.clone(); + { + let mut segments = url + .path_segments_mut() + .map_err(|_| WebdavError::InvalidUrl)?; + // `base` always ends in `/`, i.e. a trailing empty segment; + // pushing onto it without dropping that would yield `/dav//codeg`. + segments.pop_if_empty(); + for part in rel.split('/').filter(|s| !s.is_empty()) { + segments.push(part); + } + } + Ok(url) + } + + async fn send( + &self, + method: Method, + rel: &str, + timeout: Duration, + body: Option>, + depth_zero: bool, + ) -> Result { + let url = self.url_for(rel)?; + let mut request = self + .http + .request(method.clone(), url) + .basic_auth(&self.username, Some(&self.password)) + .timeout(timeout); + if depth_zero { + request = request.header("Depth", "0"); + } + if let Some(body) = body { + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); + } + + // The reqwest error is dropped on purpose: its Display carries the + // full URL, which may embed the username. + request.send().await.map_err(|err| { + tracing::warn!( + "[WEBDAV] {} /{} failed before a response (timeout: {})", + method, + rel, + err.is_timeout() + ); + WebdavError::Network + }) + } + + /// Credentials + reachability check against the configured base URL. + pub async fn probe(&self, rel: &str) -> Result<(), WebdavError> { + let response = self + .send(propfind_method(), rel, PROBE_TIMEOUT, None, true) + .await?; + let status = response.status(); + log_status("PROPFIND", rel, status); + if status.is_success() || status == StatusCode::MULTI_STATUS { + return Ok(()); + } + // A missing directory is not a probe failure: `ensure_dir` creates it + // on the first upload. Only credentials and reachability matter here. + if status == StatusCode::NOT_FOUND { + return Ok(()); + } + Err(classify(status)) + } + + /// Creates every level of `rel`, tolerating levels that already exist. + pub async fn ensure_dir(&self, rel: &str) -> Result<(), WebdavError> { + let mut prefix = String::new(); + for part in rel.split('/').filter(|s| !s.is_empty()) { + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(part); + + let response = self + .send(mkcol_method(), &prefix, PROBE_TIMEOUT, None, false) + .await?; + let status = response.status(); + log_status("MKCOL", &prefix, status); + // 405 = already a collection. Some servers answer 301 for an + // existing directory addressed without a trailing slash. + if status.is_success() + || status == StatusCode::METHOD_NOT_ALLOWED + || status.is_redirection() + { + continue; + } + return Err(classify(status)); + } + Ok(()) + } + + pub async fn put(&self, rel: &str, body: Vec) -> Result<(), WebdavError> { + let response = self + .send(Method::PUT, rel, TRANSFER_TIMEOUT, Some(body), false) + .await?; + let status = response.status(); + log_status("PUT", rel, status); + if status.is_success() { + return Ok(()); + } + Err(classify(status)) + } + + /// `Ok(None)` means "not there yet" — the normal state of a share nobody + /// has uploaded to, not an error. + pub async fn get(&self, rel: &str) -> Result>, WebdavError> { + let response = self + .send(Method::GET, rel, TRANSFER_TIMEOUT, None, false) + .await?; + let status = response.status(); + log_status("GET", rel, status); + if status == StatusCode::NOT_FOUND { + return Ok(None); + } + if !status.is_success() { + return Err(classify(status)); + } + + // Check the declared length first (cheap), then enforce the cap while + // streaming, because Content-Length is advisory. + if let Some(len) = response.content_length() { + if len > MAX_DOWNLOAD_BYTES as u64 { + return Err(WebdavError::ResponseTooLarge); + } + } + + let mut response = response; + let mut buffer: Vec = Vec::new(); + loop { + let chunk = response.chunk().await.map_err(|_| WebdavError::Network)?; + let Some(chunk) = chunk else { break }; + if buffer.len() + chunk.len() > MAX_DOWNLOAD_BYTES { + return Err(WebdavError::ResponseTooLarge); + } + buffer.extend_from_slice(&chunk); + } + Ok(Some(buffer)) + } +} + +fn propfind_method() -> Method { + Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid method token") +} + +fn mkcol_method() -> Method { + Method::from_bytes(b"MKCOL").expect("MKCOL is a valid method token") +} + +/// Only `{method} {rel} -> {status}`: no URL, no headers, no credentials. +fn log_status(method: &str, rel: &str, status: StatusCode) { + tracing::debug!("[WEBDAV] {method} /{rel} -> {}", status.as_u16()); +} + +fn classify(status: StatusCode) -> WebdavError { + match status { + StatusCode::UNAUTHORIZED => WebdavError::Unauthorized, + StatusCode::FORBIDDEN => WebdavError::Forbidden, + // 409 on a write means the parent collection is missing; from the + // user's point of view that is the same problem as a missing remote + // directory. + StatusCode::NOT_FOUND | StatusCode::CONFLICT => WebdavError::RemotePathMissing, + StatusCode::INSUFFICIENT_STORAGE | StatusCode::PAYLOAD_TOO_LARGE => { + WebdavError::InsufficientStorage + } + other => WebdavError::Server(other.as_u16()), + } +} + +/// One path segment of a user-configured remote location. Rejects anything +/// that could climb out of the configured directory or split into extra +/// levels. +pub fn sanitize_path_segment(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed == "." + || trimmed == ".." + || trimmed.contains('/') + || trimmed.contains('\\') + || trimmed.contains('\0') + { + return None; + } + Some(trimmed.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn client() -> WebdavClient { + WebdavClient::new("https://dav.example.com/dav", "user@example.com", "hunter2") + .expect("client") + } + + #[test] + fn base_url_without_trailing_slash_still_appends() { + let url = client().url_for("codeg/v1/default/config.json").expect("url"); + assert_eq!( + url.as_str(), + "https://dav.example.com/dav/codeg/v1/default/config.json" + ); + } + + /// Users paste the base URL either way; both must hit the same path. + #[test] + fn trailing_slash_in_the_configured_url_changes_nothing() { + let with_slash = WebdavClient::new("https://dav.example.com/dav/", "u", "p") + .expect("client") + .url_for("codeg/v1/default/config.json") + .expect("url"); + assert_eq!( + with_slash.as_str(), + "https://dav.example.com/dav/codeg/v1/default/config.json" + ); + } + + #[test] + fn segments_are_percent_encoded() { + let url = client().url_for("codeg/my drive/config.json").expect("url"); + assert!(url.as_str().contains("my%20drive"), "got {url}"); + } + + #[test] + fn non_http_schemes_are_refused() { + // `unwrap_err` is unavailable on purpose: the client holds the + // password and must never gain a `Debug` impl that could print it. + for raw in ["ftp://example.com", " "] { + match WebdavClient::new(raw, "u", "p") { + Err(err) => assert_eq!(err, WebdavError::InvalidUrl, "for {raw:?}"), + Ok(_) => panic!("{raw:?} should not be accepted as a WebDAV base URL"), + } + } + } + + /// The whole point of hand-rolling these errors instead of wrapping + /// `reqwest::Error`. + #[test] + fn rendered_errors_never_contain_credentials_or_host() { + let variants = [ + WebdavError::InvalidUrl, + WebdavError::Unauthorized, + WebdavError::Forbidden, + WebdavError::RemotePathMissing, + WebdavError::InsufficientStorage, + WebdavError::ResponseTooLarge, + WebdavError::Network, + WebdavError::Server(500), + ]; + for variant in variants { + let rendered = variant.to_string(); + assert!(!rendered.contains("hunter2"), "leaked password: {rendered}"); + assert!( + !rendered.contains("dav.example.com"), + "leaked host: {rendered}" + ); + assert!( + !rendered.contains("user@example.com"), + "leaked username: {rendered}" + ); + + let app_error: AppCommandError = variant.into(); + assert!(app_error.i18n_key.is_some(), "every variant needs a message"); + assert!(!app_error.message.contains("hunter2")); + } + } + + #[test] + fn status_codes_map_to_actionable_errors() { + assert_eq!(classify(StatusCode::UNAUTHORIZED), WebdavError::Unauthorized); + assert_eq!(classify(StatusCode::CONFLICT), WebdavError::RemotePathMissing); + assert_eq!( + classify(StatusCode::INSUFFICIENT_STORAGE), + WebdavError::InsufficientStorage + ); + assert_eq!( + classify(StatusCode::INTERNAL_SERVER_ERROR), + WebdavError::Server(500) + ); + } + + #[test] + fn path_segments_cannot_escape_the_configured_directory() { + assert_eq!(sanitize_path_segment(" codeg "), Some("codeg".to_string())); + for bad in ["", "..", ".", "a/b", "a\\b", "\0"] { + assert!( + sanitize_path_segment(bad).is_none(), + "{bad:?} should be rejected" + ); + } + } +} diff --git a/src/components/settings/config-sync-settings.test.tsx b/src/components/settings/config-sync-settings.test.tsx new file mode 100644 index 0000000000..92704f12b5 --- /dev/null +++ b/src/components/settings/config-sync-settings.test.tsx @@ -0,0 +1,294 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +// Flipped per-test: the panel is desktop-only and must disappear entirely on +// web / remote-desktop rather than render disabled controls. +const env = vi.hoisted(() => ({ + desktop: true, + remoteId: null as string | null, +})) + +vi.mock("@/lib/platform", () => ({ + isDesktop: () => env.desktop, + isLocalDesktop: () => env.desktop, + openUrl: vi.fn(), +})) + +vi.mock("@/lib/transport", () => ({ + getTransport: () => ({ call: vi.fn(), subscribe: vi.fn() }), + isDesktop: () => env.desktop, + isRemoteDesktopMode: () => env.remoteId !== null, + getActiveRemoteConnectionId: () => env.remoteId, +})) + +// Captured so a test can push a background-sync status frame. +let statusHandler: ((e: unknown) => void) | null = null + +vi.mock("@/lib/config-sync", async () => { + const actual = + await vi.importActual( + "@/lib/config-sync" + ) + return { + ...actual, + getConfigSyncSettings: vi.fn(), + getConfigSyncState: vi.fn(), + updateConfigSyncSettings: vi.fn(), + testConfigSyncConnection: vi.fn(), + uploadConfigNow: vi.fn(), + peekRemoteConfig: vi.fn(), + downloadAndApplyConfig: vi.fn(), + exportConfigToFile: vi.fn(), + pickConfigFileToImport: vi.fn(), + importConfigFromFile: vi.fn(), + listenConfigSyncStatus: vi.fn(async (handler: (e: unknown) => void) => { + statusHandler = handler + return () => {} + }), + } +}) + +const toastError = vi.fn() +const toastSuccess = vi.fn() +vi.mock("sonner", () => ({ + toast: { + success: (m: string) => toastSuccess(m), + error: (m: string) => toastError(m), + message: vi.fn(), + }, +})) + +import { ConfigSyncSettings } from "./config-sync-settings" +import enMessages from "@/i18n/messages/en.json" +import { + downloadAndApplyConfig, + getConfigSyncSettings, + getConfigSyncState, + importConfigFromFile, + peekRemoteConfig, + pickConfigFileToImport, + updateConfigSyncSettings, +} from "@/lib/config-sync" + +const t = enMessages.ConfigSyncSettings + +const SAVED = { + enabled: true, + serverUrl: "https://dav.example.com/dav/", + username: "alice", + hasPassword: true, + remoteDir: "codeg", + profile: "default", + autoSync: true, + intervalMinutes: 5, +} + +function manifest(overrides: Record = {}) { + return { + schemaVersion: 1, + encryption: "none", + createdAt: "2026-06-06T10:00:00Z", + appVersion: "0.30.0", + sourceDevice: "work-laptop", + config: { size: 2048, sha256: "abc" }, + counts: { modelProviders: 3, preferences: 7 }, + ...overrides, + } +} + +function renderPanel() { + return render( + + + + ) +} + +/** Render and wait until the saved settings have populated the form. */ +async function renderLoaded() { + const view = renderPanel() + await screen.findByRole("button", { name: t.saveButton }) + return view +} + +beforeEach(() => { + vi.clearAllMocks() + statusHandler = null + env.desktop = true + env.remoteId = null + vi.mocked(getConfigSyncSettings).mockResolvedValue({ ...SAVED }) + vi.mocked(getConfigSyncState).mockResolvedValue({ + lastUploadedSha256: null, + lastSyncAt: null, + lastError: null, + }) + vi.mocked(updateConfigSyncSettings).mockResolvedValue({ ...SAVED }) +}) + +describe("ConfigSyncSettings — availability", () => { + it("renders nothing on web", () => { + env.desktop = false + const { container } = renderPanel() + expect(container).toBeEmptyDOMElement() + expect(getConfigSyncSettings).not.toHaveBeenCalled() + }) + + it("renders nothing for a remote-desktop window", () => { + env.remoteId = "remote-1" + const { container } = renderPanel() + expect(container).toBeEmptyDOMElement() + }) + + it("shows the file actions even before WebDAV is set up", async () => { + vi.mocked(getConfigSyncSettings).mockResolvedValue({ + ...SAVED, + enabled: false, + }) + renderPanel() + await screen.findByRole("button", { name: t.exportButton }) + expect( + screen.queryByRole("button", { name: t.uploadButton }) + ).not.toBeInTheDocument() + }) +}) + +describe("ConfigSyncSettings — credentials", () => { + it("sends a null password when the field is untouched, keeping the stored one", async () => { + await renderLoaded() + fireEvent.click(screen.getByRole("button", { name: t.saveButton })) + await waitFor(() => expect(updateConfigSyncSettings).toHaveBeenCalled()) + expect(vi.mocked(updateConfigSyncSettings).mock.calls[0][0]).toMatchObject({ + username: "alice", + password: null, + }) + }) + + it("sends a typed password and then clears the field", async () => { + const { container } = await renderLoaded() + const password = container.querySelector( + "#config-sync-password" + ) as HTMLInputElement + fireEvent.change(password, { target: { value: "s3cret" } }) + fireEvent.click(screen.getByRole("button", { name: t.saveButton })) + await waitFor(() => expect(updateConfigSyncSettings).toHaveBeenCalled()) + expect(vi.mocked(updateConfigSyncSettings).mock.calls[0][0]).toMatchObject({ + password: "s3cret", + }) + // Cleared so a second save does not resend a value the user cannot see. + await waitFor(() => expect(password.value).toBe("")) + }) + + it("disables the remote actions until a server and user are filled in", async () => { + vi.mocked(getConfigSyncSettings).mockResolvedValue({ + ...SAVED, + serverUrl: "", + username: "", + hasPassword: false, + }) + await renderLoaded() + expect(screen.getByRole("button", { name: t.saveButton })).toBeDisabled() + expect(screen.getByRole("button", { name: t.uploadButton })).toBeDisabled() + }) +}) + +describe("ConfigSyncSettings — restore from remote", () => { + it("never downloads without a confirmation naming the source snapshot", async () => { + vi.mocked(peekRemoteConfig).mockResolvedValue(manifest()) + await renderLoaded() + fireEvent.click(screen.getByRole("button", { name: t.restoreButton })) + await screen.findByText(t.restoreConfirmTitle) + expect(downloadAndApplyConfig).not.toHaveBeenCalled() + expect(screen.getByText(/work-laptop/)).toBeInTheDocument() + + vi.mocked(downloadAndApplyConfig).mockResolvedValue({ + manifest: manifest(), + applied: { domains: { modelProviders: 3 }, total: 3 }, + rollbackPath: null, + }) + fireEvent.click( + screen.getByRole("button", { name: t.restoreConfirmAction }) + ) + await waitFor(() => expect(downloadAndApplyConfig).toHaveBeenCalled()) + }) + + it("reports an empty remote instead of opening the dialog", async () => { + vi.mocked(peekRemoteConfig).mockResolvedValue(null) + await renderLoaded() + fireEvent.click(screen.getByRole("button", { name: t.restoreButton })) + await waitFor(() => + expect(toastError).toHaveBeenCalledWith(t.noRemoteSnapshot) + ) + expect(screen.queryByText(t.restoreConfirmTitle)).not.toBeInTheDocument() + }) +}) + +describe("ConfigSyncSettings — file import", () => { + it("previews the file and applies it only after confirmation", async () => { + vi.mocked(pickConfigFileToImport).mockResolvedValue({ + path: "/tmp/config.json", + preview: { manifest: manifest(), importable: true, blockedReason: null }, + }) + vi.mocked(importConfigFromFile).mockResolvedValue({ + manifest: manifest(), + applied: { domains: { modelProviders: 3 }, total: 3 }, + rollbackPath: null, + }) + await renderLoaded() + fireEvent.click(screen.getByRole("button", { name: t.importButton })) + await screen.findByText(t.importConfirmTitle) + expect(importConfigFromFile).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole("button", { name: t.importConfirmAction })) + await waitFor(() => + expect(importConfigFromFile).toHaveBeenCalledWith("/tmp/config.json") + ) + }) + + it("blocks importing a snapshot the backend rejected", async () => { + vi.mocked(pickConfigFileToImport).mockResolvedValue({ + path: "/tmp/future.json", + preview: { + manifest: manifest({ schemaVersion: 99 }), + importable: false, + blockedReason: "newer schema", + }, + }) + await renderLoaded() + fireEvent.click(screen.getByRole("button", { name: t.importButton })) + await screen.findByText(t.importBlocked) + expect( + screen.getByRole("button", { name: t.importConfirmAction }) + ).toBeDisabled() + }) + + it("stays quiet when the file dialog is dismissed", async () => { + vi.mocked(pickConfigFileToImport).mockResolvedValue(null) + await renderLoaded() + fireEvent.click(screen.getByRole("button", { name: t.importButton })) + await waitFor(() => expect(pickConfigFileToImport).toHaveBeenCalled()) + expect(screen.queryByText(t.importConfirmTitle)).not.toBeInTheDocument() + expect(toastError).not.toHaveBeenCalled() + }) +}) + +describe("ConfigSyncSettings — background status", () => { + it("reflects an upload performed by the periodic loop", async () => { + await renderLoaded() + expect(screen.getByText(t.neverSynced)).toBeInTheDocument() + act(() => { + statusHandler?.({ lastSyncAt: "2026-06-06T12:00:00Z", lastError: null }) + }) + await waitFor(() => + expect(screen.queryByText(t.neverSynced)).not.toBeInTheDocument() + ) + }) + + it("surfaces the last background failure", async () => { + await renderLoaded() + act(() => { + statusHandler?.({ lastSyncAt: null, lastError: "connection refused" }) + }) + await screen.findByText(/connection refused/) + }) +}) diff --git a/src/components/settings/config-sync-settings.tsx b/src/components/settings/config-sync-settings.tsx new file mode 100644 index 0000000000..c458256289 --- /dev/null +++ b/src/components/settings/config-sync-settings.tsx @@ -0,0 +1,790 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { + Check, + CloudUpload, + FileDown, + FileUp, + Loader2, + RefreshCw, + ShieldAlert, + X, +} from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + toLocalizedErrorMessage, + type AppErrorTranslator, +} from "@/lib/app-error" +import { isDesktop } from "@/lib/platform" +import { getActiveRemoteConnectionId } from "@/lib/transport" +import { + downloadAndApplyConfig, + exportConfigToFile, + getConfigSyncSettings, + getConfigSyncState, + importConfigFromFile, + listenConfigSyncStatus, + peekRemoteConfig, + pickConfigFileToImport, + summarizeCounts, + testConfigSyncConnection, + updateConfigSyncSettings, + uploadConfigNow, + type ConfigImportPreview, + type ConfigManifest, + type ConfigSyncSettingsInput, + type DomainCounts, +} from "@/lib/config-sync" + +/** Fixed choices instead of a free number field: the interval only has to be + * "how stale may the remote copy be", and an open input invites 0 or 99999. */ +const INTERVAL_OPTIONS = [5, 15, 30, 60] as const + +const SCOPE_INCLUDED = [ + "providers", + "agentSettings", + "customAgents", + "quickMessages", + "taskTemplates", + "preferences", +] as const + +const SCOPE_EXCLUDED = [ + "conversations", + "uploads", + "workspaces", + "mcp", + "credentials", +] as const + +/// Address templates only. A preset never changes how the client talks to the +/// server, so adding one is a translation change, not a protocol change. +const WEBDAV_PRESETS = [ + { id: "jianguoyun", url: "https://dav.jianguoyun.com/dav/" }, + { + id: "nextcloud", + url: "https://example.com/remote.php/dav/files/username/", + }, + { id: "synology", url: "http://192.168.1.10:5005/" }, + { id: "custom", url: null }, +] as const + +type PresetId = (typeof WEBDAV_PRESETS)[number]["id"] + +/// Recognises a saved URL so reopening settings keeps the preset highlighted. +function presetFromUrl(url: string): PresetId { + const value = url.trim().toLowerCase() + if (value.includes("dav.jianguoyun.com")) return "jianguoyun" + if (value.includes("/remote.php/dav")) return "nextcloud" + if (/:(5005|5006)(\/|$)/.test(value)) return "synology" + return "custom" +} + +type PendingImport = { path: string; preview: ConfigImportPreview } + +function formatTimestamp(value: string | null): string | null { + if (!value) return null + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString() +} + +export function ConfigSyncSettings() { + const t = useTranslations("ConfigSyncSettings") + // Root translator so backend errors carrying `configSync.error.*` keys + // localize; falls back to the English message when the key is unknown. + const tRoot = useTranslations() + const localize = useCallback( + (err: unknown) => + toLocalizedErrorMessage(err, tRoot as unknown as AppErrorTranslator), + [tRoot] + ) + + // Native dialogs plus Tauri-only commands: a remote-desktop window points at + // another machine's server, where neither applies. + const desktop = isDesktop() && getActiveRemoteConnectionId() === null + + const [loaded, setLoaded] = useState(false) + const [enabled, setEnabled] = useState(false) + const [serverUrl, setServerUrl] = useState("") + const [preset, setPreset] = useState("custom") + const [username, setUsername] = useState("") + // Always starts empty. Submitting an empty field means "keep the stored + // password"; rendering a mask here would risk saving the mask itself. + const [password, setPassword] = useState("") + const [hasPassword, setHasPassword] = useState(false) + const [remoteDir, setRemoteDir] = useState("codeg") + const [profile, setProfile] = useState("default") + const [autoSync, setAutoSync] = useState(true) + const [intervalMinutes, setIntervalMinutes] = useState(5) + + const [lastSyncAt, setLastSyncAt] = useState(null) + const [lastError, setLastError] = useState(null) + + const [busy, setBusy] = useState< + null | "save" | "test" | "upload" | "download" | "export" | "import" + >(null) + const [pendingImport, setPendingImport] = useState(null) + const [remoteManifest, setRemoteManifest] = useState( + null + ) + const [restoreOpen, setRestoreOpen] = useState(false) + + // Guards against a late `setState` when the settings page unmounts during a + // slow WebDAV round-trip. + const mounted = useRef(true) + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) + + useEffect(() => { + if (!desktop) return + let cancelled = false + void (async () => { + try { + const [settings, state] = await Promise.all([ + getConfigSyncSettings(), + getConfigSyncState(), + ]) + if (cancelled) return + setEnabled(settings.enabled) + setServerUrl(settings.serverUrl) + setPreset(presetFromUrl(settings.serverUrl)) + setUsername(settings.username) + setHasPassword(settings.hasPassword) + setRemoteDir(settings.remoteDir) + setProfile(settings.profile) + setAutoSync(settings.autoSync) + setIntervalMinutes(settings.intervalMinutes) + setLastSyncAt(state.lastSyncAt) + setLastError(state.lastError) + } catch (err) { + console.error("[config-sync] failed to load settings", err) + } finally { + if (!cancelled) setLoaded(true) + } + })() + return () => { + cancelled = true + } + }, [desktop]) + + // The background uploader reports here; without this the panel would show a + // stale "last synced" until the page is reopened. + useEffect(() => { + if (!desktop) return + let unlisten: (() => void) | null = null + let disposed = false + void listenConfigSyncStatus((event) => { + setLastSyncAt(event.lastSyncAt) + setLastError(event.lastError) + }).then((fn) => { + if (disposed) fn() + else unlisten = fn + }) + return () => { + disposed = true + unlisten?.() + } + }, [desktop]) + + const currentInput = useCallback( + (): ConfigSyncSettingsInput => ({ + enabled, + serverUrl: serverUrl.trim(), + username: username.trim(), + password: password.length > 0 ? password : null, + remoteDir: remoteDir.trim(), + profile: profile.trim(), + autoSync, + intervalMinutes, + }), + [ + enabled, + serverUrl, + username, + password, + remoteDir, + profile, + autoSync, + intervalMinutes, + ] + ) + + const handleSave = useCallback(async () => { + setBusy("save") + try { + const saved = await updateConfigSyncSettings(currentInput()) + if (!mounted.current) return + setHasPassword(saved.hasPassword) + setRemoteDir(saved.remoteDir) + setProfile(saved.profile) + setIntervalMinutes(saved.intervalMinutes) + // Clear the field once it is stored, so a second save does not re-send + // a value the user cannot see. + setPassword("") + toast.success(t("saved")) + } catch (err) { + toast.error(localize(err)) + } finally { + if (mounted.current) setBusy(null) + } + }, [currentInput, localize, t]) + + const handleTest = useCallback(async () => { + setBusy("test") + try { + await testConfigSyncConnection(currentInput()) + if (mounted.current) toast.success(t("testSucceeded")) + } catch (err) { + toast.error(localize(err)) + } finally { + if (mounted.current) setBusy(null) + } + }, [currentInput, localize, t]) + + const handleUpload = useCallback(async () => { + setBusy("upload") + try { + const outcome = await uploadConfigNow() + if (!mounted.current) return + setLastSyncAt(outcome.syncedAt) + setLastError(null) + toast.success(t("uploaded")) + } catch (err) { + const message = localize(err) + if (mounted.current) setLastError(message) + toast.error(message) + } finally { + if (mounted.current) setBusy(null) + } + }, [localize, t]) + + /** Look before overwriting: the confirmation names the machine and time the + * remote snapshot came from. */ + const handleOpenRestore = useCallback(async () => { + setBusy("download") + try { + const manifest = await peekRemoteConfig() + if (!mounted.current) return + if (!manifest) { + toast.error(t("noRemoteSnapshot")) + return + } + setRemoteManifest(manifest) + setRestoreOpen(true) + } catch (err) { + toast.error(localize(err)) + } finally { + if (mounted.current) setBusy(null) + } + }, [localize, t]) + + const handleConfirmRestore = useCallback(async () => { + setRestoreOpen(false) + setBusy("download") + try { + const outcome = await downloadAndApplyConfig() + if (!mounted.current) return + toast.success(t("restored", { count: outcome.applied.total })) + } catch (err) { + toast.error(localize(err)) + } finally { + if (mounted.current) setBusy(null) + } + }, [localize, t]) + + const handleExport = useCallback(async () => { + setBusy("export") + try { + const summary = await exportConfigToFile() + // `null` = the user dismissed the save dialog, which is not an error. + if (summary && mounted.current) toast.success(t("exported")) + } catch (err) { + toast.error(localize(err)) + } finally { + if (mounted.current) setBusy(null) + } + }, [localize, t]) + + const handlePickImport = useCallback(async () => { + setBusy("import") + try { + const picked = await pickConfigFileToImport() + if (picked && mounted.current) setPendingImport(picked) + } catch (err) { + toast.error(localize(err)) + } finally { + if (mounted.current) setBusy(null) + } + }, [localize]) + + const handleConfirmImport = useCallback(async () => { + if (!pendingImport) return + const path = pendingImport.path + setPendingImport(null) + setBusy("import") + try { + const result = await importConfigFromFile(path) + if (mounted.current) { + toast.success(t("imported", { count: result.applied.total })) + } + } catch (err) { + toast.error(localize(err)) + } finally { + if (mounted.current) setBusy(null) + } + }, [pendingImport, localize, t]) + + if (!desktop) return null + + const syncedLabel = formatTimestamp(lastSyncAt) + // Only the hosted services need a setup note; "custom" has nothing to say. + const presetHint = preset === "custom" ? null : t(`presetHint.${preset}`) + const remoteBusy = busy === "upload" || busy === "download" + const credentialsIncomplete = + serverUrl.trim().length === 0 || username.trim().length === 0 + + return ( +
+
+ +

{t("title")}

+
+ +

+ {t("description")} +

+ + {/* The two columns exist so nobody reads "sync" as "backs up everything". */} +
+
+ +
    + {SCOPE_INCLUDED.map((key) => ( +
  • + + {t(`scopeIncluded.${key}`)} +
  • + ))} +
+
+
+ +
    + {SCOPE_EXCLUDED.map((key) => ( +
  • + + {t(`scopeExcluded.${key}`)} +
  • + ))} +
+
+
+ + {/* Local file transfer works with no server at all, so it comes first. */} +
+ +
+ + +
+

{t("fileHint")}

+
+ +
+
+
+ +

{t("webdavHint")}

+
+ +
+ + {enabled ? ( +
+ {/* Pure address templates — no provider-specific logic anywhere. */} +
+ +
+ {WEBDAV_PRESETS.map((item) => ( + + ))} +
+ {presetHint ? ( +

{presetHint}

+ ) : null} +
+ +
+ + { + setServerUrl(e.target.value) + setPreset(presetFromUrl(e.target.value)) + }} + placeholder="https://dav.example.com/dav/" + autoComplete="off" + spellCheck={false} + /> +
+ +
+
+ + setUsername(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+
+ + setPassword(e.target.value)} + placeholder={ + hasPassword ? t("passwordKeep") : t("passwordPlaceholder") + } + autoComplete="new-password" + /> +
+
+ +
+
+ + setRemoteDir(e.target.value)} + spellCheck={false} + /> +
+
+ + setProfile(e.target.value)} + spellCheck={false} + /> +
+
+

{t("profileHint")}

+ +
+
+ +

+ {t("autoSyncHint")} +

+
+ +
+ +
+ + +
+ +
+ + + + +
+ +
+

+ {syncedLabel + ? t("lastSyncAt", { time: syncedLabel }) + : t("neverSynced")} +

+ {lastError ? ( +

+ {t("lastError", { message: lastError })} +

+ ) : null} + {remoteBusy ?

{t("working")}

: null} +
+ +
+ +

+ {t("plaintextWarning")} +

+
+
+ ) : null} +
+ + { + if (!open) setPendingImport(null) + }} + > + + + {t("importConfirmTitle")} + +
+

+ {pendingImport?.preview.blockedReason + ? t("importBlocked") + : t("importConfirmBody")} +

+ {pendingImport ? ( + + ) : null} +
+
+
+ + {t("cancel")} + { + e.preventDefault() + void handleConfirmImport() + }} + disabled={!pendingImport?.preview.importable} + > + {t("importConfirmAction")} + + +
+
+ + + + + {t("restoreConfirmTitle")} + +
+

+ {t("restoreConfirmBody", { + device: remoteManifest?.sourceDevice ?? "", + time: + formatTimestamp(remoteManifest?.createdAt ?? null) ?? "", + })} +

+ {remoteManifest ? ( + + ) : null} +
+
+
+ + {t("cancel")} + { + e.preventDefault() + void handleConfirmRestore() + }} + > + {t("restoreConfirmAction")} + + +
+
+
+ ) +} + +/** "3 providers · 2 agents · 12 preferences" — what is actually about to be + * written, so a confirmation is more than a shrug. */ +function CountsSummary({ counts }: { counts: DomainCounts }) { + const t = useTranslations("ConfigSyncSettings") + const entries = summarizeCounts(counts) + if (entries.length === 0) { + return ( +

{t("emptySnapshot")}

+ ) + } + return ( +
    + {entries.map((entry) => ( +
  • + {t(`domain.${entry.id}`, { count: entry.count })} +
  • + ))} +
+ ) +} diff --git a/src/components/settings/system-network-settings.tsx b/src/components/settings/system-network-settings.tsx index 4daa919dda..18bd749f49 100644 --- a/src/components/settings/system-network-settings.tsx +++ b/src/components/settings/system-network-settings.tsx @@ -15,6 +15,7 @@ import { useLocale, useTranslations } from "next-intl" import { toast } from "sonner" import { useAppI18n } from "@/components/i18n-provider" import { BackupSettings } from "@/components/settings/backup-settings" +import { ConfigSyncSettings } from "@/components/settings/config-sync-settings" import { ReleaseNotes } from "@/components/settings/release-notes" import { SettingsSection } from "@/components/shared/settings-section" import { @@ -780,6 +781,8 @@ export function SystemNetworkSettings() { + + { + it("is filesystem-safe and sorts chronologically", () => { + const name = defaultExportFileName(new Date("2026-05-04T11:32:07.456Z")) + expect(name).toBe( + `codeg-config-2026-05-04-11-32-07.${CONFIG_EXPORT_EXTENSION}` + ) + // Windows rejects ':' in file names — the timestamp must not smuggle one in. + expect(name).not.toMatch(/[:]/) + const earlier = defaultExportFileName(new Date("2026-05-04T11:32:06.000Z")) + expect([name, earlier].sort()).toEqual([earlier, name]) + }) +}) + +describe("summarizeCounts", () => { + it("keeps the fixed domain order and hides empty domains", () => { + const [first, second] = CONFIG_DOMAIN_IDS + const summary = summarizeCounts({ [second]: 2, [first]: 3 } as never) + expect(summary).toEqual([ + { id: first, count: 3 }, + { id: second, count: 2 }, + ]) + }) + + it("treats a missing domain as zero rather than crashing", () => { + expect(summarizeCounts({} as never)).toEqual([]) + }) +}) diff --git a/src/lib/config-sync.ts b/src/lib/config-sync.ts new file mode 100644 index 0000000000..61cd7821fa --- /dev/null +++ b/src/lib/config-sync.ts @@ -0,0 +1,247 @@ +/** + * Frontend surface of configuration sync. + * + * Mirrors `src-tauri/src/commands/config_sync/`: a config-only snapshot + * (providers, agent settings, custom agents, quick messages, task templates, + * whitelisted preferences) that can be written to a single file or pushed to + * the user's own WebDAV share. + * + * Kept out of `api.ts` on purpose — that module is already thousands of lines + * and this feature has its own vocabulary. + * + * Desktop-only in this version: file paths come from native dialogs and the + * commands are registered on the Tauri runtime, so the settings UI gates on + * `isDesktop()` rather than degrading here. + */ + +import { getTransport } from "./transport" + +/** Stable domain ids, matching `domains::CONFIG_DOMAINS` in Rust. */ +export const CONFIG_DOMAIN_IDS = [ + "modelProviders", + "agentSettings", + "customAgents", + "quickMessages", + "taskTemplates", + "preferences", +] as const + +export type ConfigDomainId = (typeof CONFIG_DOMAIN_IDS)[number] + +/** Row counts per domain. A domain missing from an older snapshot is absent, + * not zero — the UI must treat `undefined` as "not present in this file". */ +export type DomainCounts = Partial> & + Record + +export interface ConfigFileMeta { + size: number + sha256: string +} + +export interface ConfigManifest { + schemaVersion: number + encryption: string + createdAt: string + appVersion: string + /** Hostname of the machine that produced the snapshot, so "overwrite with + * the remote copy" can say whose copy it is. */ + sourceDevice: string + config: ConfigFileMeta + counts: DomainCounts +} + +export interface ApplyReport { + domains: DomainCounts + total: number +} + +export interface ConfigExportSummary { + path: string + manifest: ConfigManifest +} + +export interface ConfigImportPreview { + manifest: ConfigManifest + /** False when the file is from a newer schema or fails its checksum; the + * import button stays disabled and `blockedReason` explains why. */ + importable: boolean + blockedReason: string | null +} + +export interface ConfigImportResult { + manifest: ConfigManifest + applied: ApplyReport + rollbackPath: string | null +} + +export interface ConfigSyncSettingsView { + enabled: boolean + serverUrl: string + username: string + /** The password itself never crosses the bridge. */ + hasPassword: boolean + remoteDir: string + profile: string + autoSync: boolean + intervalMinutes: number +} + +export interface ConfigSyncSettingsInput { + enabled: boolean + serverUrl: string + username: string + /** `null` or `""` keeps the stored password. Never send a placeholder — + * the backend would store the placeholder verbatim. */ + password: string | null + remoteDir: string + profile: string + autoSync: boolean + intervalMinutes: number +} + +export interface ConfigSyncState { + lastUploadedSha256: string | null + lastSyncAt: string | null + lastError: string | null +} + +export interface ConfigSyncStatusEvent { + lastSyncAt: string | null + lastError: string | null +} + +export interface UploadOutcome { + /** False means the snapshot was identical to the last upload and nothing + * was sent — a success, not a failure. */ + uploaded: boolean + sha256: string + counts: DomainCounts + syncedAt: string +} + +export interface DownloadOutcome { + manifest: ConfigManifest + applied: ApplyReport + rollbackPath: string | null +} + +/** Emitted only by the background uploader; manual actions return their + * result directly, so the UI never has to guess what a status refers to. */ +export const CONFIG_SYNC_STATUS_EVENT = "config-sync://status" + +export const CONFIG_EXPORT_EXTENSION = "codegcfg.json" + +/** `codeg-config-2026-05-04-11-32-07.codegcfg.json` — sortable, and obvious + * in a downloads folder six months later. */ +export function defaultExportFileName(now: Date = new Date()): string { + const stamp = now.toISOString().slice(0, 19).replace(/[:T]/g, "-") + return `codeg-config-${stamp}.${CONFIG_EXPORT_EXTENSION}` +} + +/** `null` when the user dismissed the save dialog. */ +export async function exportConfigToFile(): Promise { + const { save } = await import("@tauri-apps/plugin-dialog") + const destPath = await save({ + defaultPath: defaultExportFileName(), + filters: [{ name: "Codeg config", extensions: ["json"] }], + }) + if (!destPath) return null + return getTransport().call("config_sync_export_file", { + destPath, + }) +} + +/** Opens a file picker and inspects the choice WITHOUT applying it. `null` + * when the dialog was dismissed. */ +export async function pickConfigFileToImport(): Promise<{ + path: string + preview: ConfigImportPreview +} | null> { + const { open } = await import("@tauri-apps/plugin-dialog") + const picked = await open({ + multiple: false, + directory: false, + filters: [{ name: "Codeg config", extensions: ["json"] }], + }) + const srcPath = typeof picked === "string" ? picked : null + if (!srcPath) return null + const preview = await getTransport().call( + "config_sync_peek_file", + { srcPath } + ) + return { path: srcPath, preview } +} + +export async function importConfigFromFile( + srcPath: string +): Promise { + return getTransport().call("config_sync_import_file", { + srcPath, + }) +} + +export async function getConfigSyncSettings(): Promise { + return getTransport().call( + "config_sync_get_settings", + {} + ) +} + +export async function updateConfigSyncSettings( + settings: ConfigSyncSettingsInput +): Promise { + return getTransport().call( + "config_sync_update_settings", + { settings } + ) +} + +export async function getConfigSyncState(): Promise { + return getTransport().call("config_sync_get_state", {}) +} + +/** Verifies the form's credentials without saving them. */ +export async function testConfigSyncConnection( + settings: ConfigSyncSettingsInput +): Promise { + await getTransport().call("config_sync_test_connection", { settings }) +} + +/** Manual "sync now": uploads even when the hash is unchanged. */ +export async function uploadConfigNow(): Promise { + return getTransport().call("config_sync_upload_now", {}) +} + +/** `null` when the remote has no snapshot yet. */ +export async function peekRemoteConfig(): Promise { + return getTransport().call( + "config_sync_peek_remote", + {} + ) +} + +/** Explicit, never automatic: overwrites local configuration with the remote + * snapshot after the user confirms. */ +export async function downloadAndApplyConfig(): Promise { + return getTransport().call("config_sync_download_apply", {}) +} + +export async function listenConfigSyncStatus( + handler: (event: ConfigSyncStatusEvent) => void +): Promise<() => void> { + return getTransport().subscribe( + CONFIG_SYNC_STATUS_EVENT, + handler + ) +} + +/** Domains with at least one row, in the fixed display order. Used by both + * the import preview and the post-apply summary so they read alike. */ +export function summarizeCounts(counts: DomainCounts): { + id: ConfigDomainId + count: number +}[] { + return CONFIG_DOMAIN_IDS.map((id) => ({ id, count: counts[id] ?? 0 })).filter( + (entry) => entry.count > 0 + ) +} From 3d612ddc5bd9d87c89e6435a5b0b88085faf6b0c Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 09:33:21 +0800 Subject: [PATCH 2/9] fix(config-sync): repair the import contract, the off switch, and credential scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review of #747 turned up two defects that make half the feature unusable and one that leaks a credential: - `ConfigImportPreview` promised `importable`/`blockedReason`, which the Rust command never sends. `disabled={!preview.importable}` therefore read `undefined` on every real file and the import confirm button was permanently disabled. There is no "previewed but not importable" state — `peek` runs the same parser and schema check the import does, so an unreadable file already comes back as a rejected promise with a `configSync.error.*` key. Drop the phantom fields (and the now-unused `importBlocked` string), and show the backend's recount instead of the file's self-reported `manifest.counts`. - The master WebDAV switch only moved local state, and every other control including Save lives inside the block it hides — so sync could be turned on but never off. It now persists on click, like the proxy and launch-at-login switches in the same settings page. - An empty password field meant "keep the stored one" unconditionally, so editing the URL alone was enough to send a saved app-password to another server. The password is now bound to the account it was typed for; changing host or user requires retyping it, and the field's hint stops offering to keep it. Also: reset the upload hash baseline when the remote target changes (otherwise retargeting left the new location empty until some unrelated setting changed), skip auto-sync ticks until a server URL exists, read the real host name on unix (`HOSTNAME` is never exported, so every snapshot from a unix desktop was signed "unknown"), guard the forbidden key list against the key the code actually writes, and tell the user a restart is needed for the parts of a restore the running window caches. `ConfigExportSummary` and `ConfigImportResult` were also declared with fields the backend does not return; corrected to match. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/commands/config_sync/auto_sync.rs | 45 ++++- .../src/commands/config_sync/portable_keys.rs | 18 +- .../src/commands/config_sync/snapshot.rs | 108 ++++++++++- .../src/commands/config_sync/webdav_sync.rs | 169 +++++++++++++++++- .../settings/config-sync-settings.test.tsx | 81 +++++++-- .../settings/config-sync-settings.tsx | 94 ++++++++-- src/i18n/messages/ar.json | 2 +- src/i18n/messages/de.json | 2 +- src/i18n/messages/en.json | 2 +- src/i18n/messages/es.json | 2 +- src/i18n/messages/fr.json | 2 +- src/i18n/messages/ja.json | 2 +- src/i18n/messages/ko.json | 2 +- src/i18n/messages/pt.json | 2 +- src/i18n/messages/zh-CN.json | 2 +- src/i18n/messages/zh-TW.json | 2 +- src/lib/config-sync.ts | 20 ++- 17 files changed, 485 insertions(+), 70 deletions(-) diff --git a/src-tauri/src/commands/config_sync/auto_sync.rs b/src-tauri/src/commands/config_sync/auto_sync.rs index 004ebbaff4..2e1dd6f6c4 100644 --- a/src-tauri/src/commands/config_sync/auto_sync.rs +++ b/src-tauri/src/commands/config_sync/auto_sync.rs @@ -93,8 +93,15 @@ pub fn next_delay(interval_minutes: u32, consecutive_failures: u32) -> Duration /// Whether this tick should attempt an upload at all. Split out from the loop /// so the skip rules are testable. -pub fn should_attempt(enabled: bool, auto_sync: bool, suppressed: bool) -> bool { - enabled && auto_sync && !suppressed +/// +/// `configured` is separate from `enabled` because the two are set at +/// different moments: the switch is flipped on to reveal the credential form, +/// so "enabled with no server URL" is a state every user passes through. +/// Attempting it would fail on the empty URL, and the failure would be written +/// to `last_error` and shown in the panel as if the user's server had rejected +/// something. +pub fn should_attempt(enabled: bool, auto_sync: bool, configured: bool, suppressed: bool) -> bool { + enabled && auto_sync && configured && !suppressed } /// Runs until the process exits. Reads settings every tick on purpose: turning @@ -114,6 +121,7 @@ pub async fn run_auto_sync_loop( if !should_attempt( settings.enabled, settings.auto_sync, + settings.is_configured(), is_auto_sync_suppressed(), ) { // Still honour the configured interval so a user who re-enables @@ -202,9 +210,34 @@ mod tests { #[test] fn every_reason_to_skip_a_tick_is_honoured() { - assert!(should_attempt(true, true, false)); - assert!(!should_attempt(false, true, false)); - assert!(!should_attempt(true, false, false)); - assert!(!should_attempt(true, true, true)); + assert!(should_attempt(true, true, true, false)); + assert!(!should_attempt(false, true, true, false)); + assert!(!should_attempt(true, false, true, false)); + assert!(!should_attempt(true, true, true, true)); + // Switched on but never filled in: the state between flipping the + // toggle and saving credentials must stay silent, not fail every + // interval against an empty URL. + assert!(!should_attempt(true, true, false, false)); + } + + #[test] + fn a_settings_row_without_a_server_is_not_configured() { + use super::super::webdav_sync::ConfigSyncSettings; + + let blank = ConfigSyncSettings { + enabled: true, + ..Default::default() + }; + assert!(!blank.is_configured()); + assert!(!ConfigSyncSettings { + server_url: " ".to_string(), + ..blank.clone() + } + .is_configured()); + assert!(ConfigSyncSettings { + server_url: "https://dav.example.com/dav/".to_string(), + ..blank + } + .is_configured()); } } diff --git a/src-tauri/src/commands/config_sync/portable_keys.rs b/src-tauri/src/commands/config_sync/portable_keys.rs index 3507b9cf7e..eac2202810 100644 --- a/src-tauri/src/commands/config_sync/portable_keys.rs +++ b/src-tauri/src/commands/config_sync/portable_keys.rs @@ -10,15 +10,6 @@ //! [`FORBIDDEN_PREFERENCE_KEYS`] names the keys that must NEVER travel, with //! `allowlist_and_credential_keys_are_disjoint` guarding the intersection. -/// Where the WebDAV sync configuration (including the password) is stored. -/// Deliberately NOT portable: shipping it inside a snapshot would let machine -/// A overwrite machine B's credentials and turn the two into a sync loop. -pub const CONFIG_SYNC_SETTINGS_KEY: &str = "config_sync_settings"; - -/// sha256 of the last snapshot successfully uploaded, persisted so a restart -/// does not re-upload an unchanged configuration. -pub const CONFIG_SYNC_LAST_UPLOAD_KEY: &str = "config_sync_last_upload"; - /// `app_metadata` keys that are genuinely user preferences rather than /// device-local state, and carry no credential. pub const PORTABLE_PREFERENCE_KEYS: &[&str] = &[ @@ -52,10 +43,15 @@ pub const PORTABLE_PREFERENCE_KEYS: &[&str] = &[ /// device-local state. Not consulted at runtime — [`is_portable_key`] already /// answers from the allowlist — but asserted against it in tests so a careless /// addition to [`PORTABLE_PREFERENCE_KEYS`] fails loudly. +/// +/// The sync's own two keys come from `webdav_sync` rather than being spelled +/// again here: a second copy of the literal would let the guard keep passing +/// against a key nothing writes (which is exactly what it did while a stale +/// `config_sync_last_upload` stood in for the real `config_sync_state`). #[cfg(test)] pub const FORBIDDEN_PREFERENCE_KEYS: &[&str] = &[ - CONFIG_SYNC_SETTINGS_KEY, - CONFIG_SYNC_LAST_UPLOAD_KEY, + super::webdav_sync::CONFIG_SYNC_SETTINGS_KEY, + super::webdav_sync::CONFIG_SYNC_STATE_KEY, "system_proxy_settings", "system_terminal_settings", "web_service_port", diff --git a/src-tauri/src/commands/config_sync/snapshot.rs b/src-tauri/src/commands/config_sync/snapshot.rs index 568b42cb9a..e906d39a49 100644 --- a/src-tauri/src/commands/config_sync/snapshot.rs +++ b/src-tauri/src/commands/config_sync/snapshot.rs @@ -260,18 +260,66 @@ fn reject_newer_schema(schema_version: u32) -> Result<(), AppCommandError> { Ok(()) } -/// Best-effort machine name. Deliberately env-only: a hostname lookup would -/// mean a new dependency for a label that is purely informational. +/// Best-effort machine name, for the "restore the snapshot from {device}?" +/// confirmation. +/// +/// `COMPUTERNAME` is genuinely set for every Windows process, but its unix +/// counterpart `HOSTNAME` is a shell variable that is never exported, so an +/// env-only lookup answers "unknown" on every macOS and Linux desktop — i.e. +/// exactly where the label is supposed to earn its keep. `gethostname(2)` is +/// one already-vendored `libc` call away (`libc` is a unix-only dependency of +/// this crate) and needs no new crate. fn source_device() -> String { + resolve_device_name(env_device_name(), host_name()) +} + +/// Pure, so the precedence and the fallback are testable without touching the +/// process environment (which other tests in this crate mutate). +fn resolve_device_name(from_env: Option, from_host: Option) -> String { + from_env + .or(from_host) + .unwrap_or_else(|| "unknown".to_string()) +} + +#[cfg(unix)] +fn host_name() -> Option { + unix_hostname() +} + +/// Windows has no `gethostname` in the crate's dependency set and does not +/// need one: `COMPUTERNAME` is set for every process there. +#[cfg(not(unix))] +fn host_name() -> Option { + None +} + +fn env_device_name() -> Option { for key in ["COMPUTERNAME", "HOSTNAME"] { if let Ok(value) = std::env::var(key) { let value = value.trim().to_string(); if !value.is_empty() { - return value; + return Some(value); } } } - "unknown".to_string() + None +} + +/// `gethostname` truncates silently and is not required to NUL-terminate when +/// it does, so the buffer is over-sized (POSIX caps `HOST_NAME_MAX` far below +/// this) and the result is read up to the first NUL rather than assuming one. +#[cfg(unix)] +fn unix_hostname() -> Option { + let mut buffer = vec![0u8; 256]; + // SAFETY: `gethostname` writes at most `len` bytes into `buffer`, which + // owns that many. + let rc = unsafe { libc::gethostname(buffer.as_mut_ptr() as *mut libc::c_char, buffer.len()) }; + if rc != 0 { + return None; + } + let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len()); + let name = String::from_utf8_lossy(&buffer[..end]).trim().to_string(); + (!name.is_empty()).then_some(name) } /// Where pre-apply rollback snapshots live. @@ -649,6 +697,58 @@ mod tests { ); } + #[test] + fn the_source_device_label_is_never_empty_or_ragged() { + let device = source_device(); + assert!(!device.is_empty()); + assert!(!device.contains('\0'), "raw buffer leaked: {device:?}"); + assert_eq!(device.trim(), device); + } + + /// Regression: the label was read from `COMPUTERNAME`/`HOSTNAME` only. + /// `HOSTNAME` is a shell variable that is never exported, so on every + /// macOS and Linux desktop the lookup fell through and each snapshot was + /// signed "unknown" — the one fact the restore confirmation exists to + /// tell the user. + #[test] + fn the_host_name_is_used_when_the_environment_is_silent() { + assert_eq!( + resolve_device_name(None, Some("work-laptop".to_string())), + "work-laptop" + ); + // Env still wins where it is actually populated (Windows). + assert_eq!( + resolve_device_name(Some("DESKTOP-42".to_string()), Some("other".to_string())), + "DESKTOP-42" + ); + assert_eq!(resolve_device_name(None, None), "unknown"); + } + + /// The wiring half of the same regression: the pure resolver above proves + /// the precedence, this proves `source_device` is actually plugged into a + /// host-name source. No env is mutated — the assertion simply stands down + /// on the (rare) shell that does export `HOSTNAME`, where the env branch + /// is the one under test anyway. + #[cfg(unix)] + #[test] + fn a_unix_desktop_is_not_signed_unknown() { + if env_device_name().is_some() { + return; + } + assert_ne!(source_device(), "unknown"); + } + + #[cfg(unix)] + #[test] + fn gethostname_answers_and_is_cut_at_the_nul() { + let name = unix_hostname().expect("gethostname must answer on a unix host"); + assert!(!name.is_empty()); + assert!( + !name.contains('\0'), + "buffer was not cut at the NUL: {name:?}" + ); + } + #[test] fn rollback_snapshots_are_pruned_newest_first() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/commands/config_sync/webdav_sync.rs b/src-tauri/src/commands/config_sync/webdav_sync.rs index 0510f0a0da..1407e9bbb8 100644 --- a/src-tauri/src/commands/config_sync/webdav_sync.rs +++ b/src-tauri/src/commands/config_sync/webdav_sync.rs @@ -64,6 +64,29 @@ pub struct ConfigSyncSettings { pub interval_minutes: u32, } +impl ConfigSyncSettings { + /// Whether there is an endpoint to talk to at all. The `enabled` switch on + /// its own is not enough: it is flipped on to REVEAL the form, so between + /// that click and the first save there is a persisted `enabled: true` with + /// no server URL, and a background tick that honoured only `enabled` would + /// spend every interval failing on an empty URL and overwriting + /// `last_error` with it. + pub fn is_configured(&self) -> bool { + !self.server_url.trim().is_empty() + } + + /// The remote location this configuration points at. Two settings that + /// agree here write the same two files; a change to any part of it means + /// the previously uploaded snapshot says nothing about the new location. + fn remote_identity(&self) -> (&str, &str, &str) { + ( + self.server_url.as_str(), + self.remote_dir.as_str(), + self.profile.as_str(), + ) + } +} + impl Default for ConfigSyncSettings { fn default() -> Self { Self { @@ -209,6 +232,20 @@ pub async fn save_settings_core( .await .map_err(AppCommandError::db)?; + // Pointing at a different server, folder, or profile invalidates the + // "already uploaded this" baseline: the hash describes local + // configuration, not where a copy of it landed, so keeping it would let + // the timer decide the NEW location is already up to date and leave it + // empty until some unrelated setting changes. + if existing.remote_identity() != merged.remote_identity() { + let mut state = load_state(conn).await; + if state.last_uploaded_sha256.is_some() || state.last_error.is_some() { + state.last_uploaded_sha256 = None; + state.last_error = None; + save_state(conn, &state).await; + } + } + Ok(ConfigSyncSettingsView::from(&merged)) } @@ -218,12 +255,22 @@ pub fn merge_settings( existing: &ConfigSyncSettings, input: ConfigSyncSettingsInput, ) -> Result { + let server_url = input.server_url.trim().to_string(); + let username = input.username.trim().to_string(); + // The stored password belongs to the account it was typed for. Carrying + // it over to a different host or user would mean an edit to the URL field + // alone is enough to make the next request hand that password to another + // server — by accident (repointing Jianguoyun at Nextcloud) or on purpose. + // Changing the folder or profile is not a change of credential, so those + // are deliberately not part of the comparison. + let same_account = server_url == existing.server_url && username == existing.username; let password = match input.password { Some(value) if !value.is_empty() => value, // Both `None` and `Some("")` keep the stored password. An empty field // means "I did not retype it", which is what an empty password field // means to every user who has ever seen one. - _ => existing.password.clone(), + _ if same_account => existing.password.clone(), + _ => String::new(), }; let remote_dir = normalize_segment(input.remote_dir, &existing.remote_dir, DEFAULT_REMOTE_DIR)?; @@ -231,8 +278,8 @@ pub fn merge_settings( Ok(ConfigSyncSettings { enabled: input.enabled, - server_url: input.server_url.trim().to_string(), - username: input.username.trim().to_string(), + server_url, + username, password, remote_dir, profile, @@ -465,12 +512,19 @@ mod tests { } } - #[test] - fn an_empty_password_field_keeps_the_stored_one() { - let existing = ConfigSyncSettings { + /// Same account as `input()`, with a password already on file. + fn stored() -> ConfigSyncSettings { + ConfigSyncSettings { + server_url: "https://dav.example.com/dav".to_string(), + username: "alice".to_string(), password: "stored".to_string(), ..Default::default() - }; + } + } + + #[test] + fn an_empty_password_field_keeps_the_stored_one() { + let existing = stored(); for submitted in [None, Some(String::new())] { let merged = merge_settings( @@ -489,6 +543,58 @@ mod tests { let merged = merge_settings(&existing, input()).expect("merge"); assert_eq!(merged.password, "app-password"); + + // Moving the same account to another folder/profile is not a change + // of credential. + let merged = merge_settings( + &existing, + ConfigSyncSettingsInput { + password: None, + profile: Some("personal".to_string()), + ..input() + }, + ) + .expect("merge"); + assert_eq!(merged.password, "stored"); + } + + /// A password is bound to the account it was typed for. Repointing the URL + /// (or the user) while leaving the field blank must NOT quietly hand the + /// saved credential to the new server — that turns one edited text field + /// into credential exfiltration, and gets the "I switched providers" + /// mistake wrong the same way. + #[test] + fn a_stored_password_does_not_follow_a_changed_account() { + for changed in [ + ConfigSyncSettingsInput { + password: None, + server_url: "https://dav.attacker.example/dav".to_string(), + ..input() + }, + ConfigSyncSettingsInput { + password: None, + username: "mallory".to_string(), + ..input() + }, + ] { + let merged = merge_settings(&stored(), changed).expect("merge"); + assert_eq!( + merged.password, "", + "the saved password must not travel to another account" + ); + } + + // Retyping it is all it takes to point the sync somewhere new. + let merged = merge_settings( + &stored(), + ConfigSyncSettingsInput { + password: Some("new-app-password".to_string()), + server_url: "https://dav.other.example/dav".to_string(), + ..input() + }, + ) + .expect("merge"); + assert_eq!(merged.password, "new-app-password"); } #[test] @@ -577,6 +683,55 @@ mod tests { assert_eq!(stored.profile, "work"); } + /// Retargeting the sync must not leave the new location empty. The hash + /// says "this configuration was uploaded", not "uploaded HERE", so it + /// stops meaning anything the moment the destination changes. + #[tokio::test] + async fn changing_the_remote_target_clears_the_upload_baseline() { + let db = fresh_in_memory_db().await; + save_settings_core(&db.conn, input()).await.expect("save"); + save_state( + &db.conn, + &ConfigSyncState { + last_uploaded_sha256: Some("previous".to_string()), + last_sync_at: Some("2026-01-01T00:00:00Z".to_string()), + last_error: Some("stale failure".to_string()), + }, + ) + .await; + + // Same target, unrelated field: the baseline is still valid. + save_settings_core( + &db.conn, + ConfigSyncSettingsInput { + interval_minutes: 30, + ..input() + }, + ) + .await + .expect("save"); + assert_eq!( + load_state(&db.conn).await.last_uploaded_sha256.as_deref(), + Some("previous") + ); + + save_settings_core( + &db.conn, + ConfigSyncSettingsInput { + profile: Some("personal".to_string()), + ..input() + }, + ) + .await + .expect("save"); + let state = load_state(&db.conn).await; + assert_eq!(state.last_uploaded_sha256, None); + assert_eq!(state.last_error, None); + // "When we last uploaded" is history, not a decision input — it stays + // so the panel does not claim the machine has never synced. + assert_eq!(state.last_sync_at.as_deref(), Some("2026-01-01T00:00:00Z")); + } + #[tokio::test] async fn sync_state_survives_a_reload() { let db = fresh_in_memory_db().await; diff --git a/src/components/settings/config-sync-settings.test.tsx b/src/components/settings/config-sync-settings.test.tsx index 92704f12b5..0f9dc708d1 100644 --- a/src/components/settings/config-sync-settings.test.tsx +++ b/src/components/settings/config-sync-settings.test.tsx @@ -179,6 +179,48 @@ describe("ConfigSyncSettings — credentials", () => { await waitFor(() => expect(password.value).toBe("")) }) + /// Regression: every control including Save lives inside the block the + /// switch hides, so a toggle that only moved local state could be turned on + /// but never off — the uploader kept running against settings the panel + /// showed as disabled. + it("persists the master switch on click, so sync can be turned off", async () => { + vi.mocked(updateConfigSyncSettings).mockResolvedValue({ + ...SAVED, + enabled: false, + }) + await renderLoaded() + // The master switch is the first one; the second is "upload automatically". + fireEvent.click(screen.getAllByRole("switch")[0]) + await waitFor(() => expect(updateConfigSyncSettings).toHaveBeenCalled()) + expect(vi.mocked(updateConfigSyncSettings).mock.calls[0][0]).toMatchObject({ + enabled: false, + }) + // The credential form (and with it the Save button) is gone; the change + // must already be stored. + await waitFor(() => + expect( + screen.queryByRole("button", { name: t.saveButton }) + ).not.toBeInTheDocument() + ) + }) + + /// The backend refuses to send a saved password to an account it was not + /// typed for, so the field must stop offering to reuse it. + it("asks for the password again once the account is edited", async () => { + const { container } = await renderLoaded() + const password = container.querySelector( + "#config-sync-password" + ) as HTMLInputElement + expect(password.placeholder).toBe(t.passwordKeep) + + fireEvent.change(container.querySelector("#config-sync-url")!, { + target: { value: "https://dav.other.example/dav/" }, + }) + await waitFor(() => + expect(password.placeholder).toBe(t.passwordPlaceholder) + ) + }) + it("disables the remote actions until a server and user are filled in", async () => { vi.mocked(getConfigSyncSettings).mockResolvedValue({ ...SAVED, @@ -227,10 +269,9 @@ describe("ConfigSyncSettings — file import", () => { it("previews the file and applies it only after confirmation", async () => { vi.mocked(pickConfigFileToImport).mockResolvedValue({ path: "/tmp/config.json", - preview: { manifest: manifest(), importable: true, blockedReason: null }, + preview: { manifest: manifest(), counts: { modelProviders: 3 } }, }) vi.mocked(importConfigFromFile).mockResolvedValue({ - manifest: manifest(), applied: { domains: { modelProviders: 3 }, total: 3 }, rollbackPath: null, }) @@ -239,27 +280,43 @@ describe("ConfigSyncSettings — file import", () => { await screen.findByText(t.importConfirmTitle) expect(importConfigFromFile).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole("button", { name: t.importConfirmAction })) + // Regression: the confirm button used to be gated on a `preview.importable` + // field the backend never sends, so it was `undefined` on every real file + // and importing was impossible. + const confirm = screen.getByRole("button", { + name: t.importConfirmAction, + }) + expect(confirm).toBeEnabled() + fireEvent.click(confirm) await waitFor(() => expect(importConfigFromFile).toHaveBeenCalledWith("/tmp/config.json") ) }) - it("blocks importing a snapshot the backend rejected", async () => { + /// `peek` recounts the payload; the file's own manifest is just a claim. + it("counts what will be applied, not what the file says about itself", async () => { vi.mocked(pickConfigFileToImport).mockResolvedValue({ - path: "/tmp/future.json", + path: "/tmp/config.json", preview: { - manifest: manifest({ schemaVersion: 99 }), - importable: false, - blockedReason: "newer schema", + manifest: manifest({ counts: { modelProviders: 99 } }), + counts: { modelProviders: 2 }, }, }) await renderLoaded() fireEvent.click(screen.getByRole("button", { name: t.importButton })) - await screen.findByText(t.importBlocked) - expect( - screen.getByRole("button", { name: t.importConfirmAction }) - ).toBeDisabled() + await screen.findByText(t.importConfirmTitle) + expect(screen.getByText("2 model providers")).toBeInTheDocument() + expect(screen.queryByText("99 model providers")).not.toBeInTheDocument() + }) + + it("reports a file the backend refused instead of opening a dialog", async () => { + vi.mocked(pickConfigFileToImport).mockRejectedValue( + new Error("Not a codeg config snapshot") + ) + await renderLoaded() + fireEvent.click(screen.getByRole("button", { name: t.importButton })) + await waitFor(() => expect(toastError).toHaveBeenCalled()) + expect(screen.queryByText(t.importConfirmTitle)).not.toBeInTheDocument() }) it("stays quiet when the file dialog is dismissed", async () => { diff --git a/src/components/settings/config-sync-settings.tsx b/src/components/settings/config-sync-settings.tsx index c458256289..eabe69efc6 100644 --- a/src/components/settings/config-sync-settings.tsx +++ b/src/components/settings/config-sync-settings.tsx @@ -136,6 +136,14 @@ export function ConfigSyncSettings() { // password"; rendering a mask here would risk saving the mask itself. const [password, setPassword] = useState("") const [hasPassword, setHasPassword] = useState(false) + // The account the stored password belongs to. The backend drops that + // password rather than sending it to a host or user it was not typed for, + // so the "leave empty to keep it" hint has to stop claiming otherwise the + // moment either field is edited. + const [savedAccount, setSavedAccount] = useState({ + serverUrl: "", + username: "", + }) const [remoteDir, setRemoteDir] = useState("codeg") const [profile, setProfile] = useState("default") const [autoSync, setAutoSync] = useState(true) @@ -178,6 +186,10 @@ export function ConfigSyncSettings() { setPreset(presetFromUrl(settings.serverUrl)) setUsername(settings.username) setHasPassword(settings.hasPassword) + setSavedAccount({ + serverUrl: settings.serverUrl, + username: settings.username, + }) setRemoteDir(settings.remoteDir) setProfile(settings.profile) setAutoSync(settings.autoSync) @@ -215,7 +227,9 @@ export function ConfigSyncSettings() { }, [desktop]) const currentInput = useCallback( - (): ConfigSyncSettingsInput => ({ + ( + overrides?: Partial + ): ConfigSyncSettingsInput => ({ enabled, serverUrl: serverUrl.trim(), username: username.trim(), @@ -224,6 +238,7 @@ export function ConfigSyncSettings() { profile: profile.trim(), autoSync, intervalMinutes, + ...overrides, }), [ enabled, @@ -243,6 +258,10 @@ export function ConfigSyncSettings() { const saved = await updateConfigSyncSettings(currentInput()) if (!mounted.current) return setHasPassword(saved.hasPassword) + setSavedAccount({ + serverUrl: saved.serverUrl, + username: saved.username, + }) setRemoteDir(saved.remoteDir) setProfile(saved.profile) setIntervalMinutes(saved.intervalMinutes) @@ -257,6 +276,39 @@ export function ConfigSyncSettings() { } }, [currentInput, localize, t]) + /** + * The master switch saves on click, like the proxy and launch-at-login + * switches in the sections above. It has to: every other control — Save + * included — lives inside the block this flag hides, so a toggle that only + * moved local state could be flipped ON but never OFF, and the background + * uploader would keep running against settings the panel says are off. + */ + const handleToggleEnabled = useCallback( + async (next: boolean) => { + const previous = enabled + setEnabled(next) + setBusy("save") + try { + const saved = await updateConfigSyncSettings( + currentInput({ enabled: next }) + ) + if (!mounted.current) return + setHasPassword(saved.hasPassword) + setSavedAccount({ + serverUrl: saved.serverUrl, + username: saved.username, + }) + setPassword("") + } catch (err) { + if (mounted.current) setEnabled(previous) + toast.error(localize(err)) + } finally { + if (mounted.current) setBusy(null) + } + }, + [currentInput, enabled, localize] + ) + const handleTest = useCallback(async () => { setBusy("test") try { @@ -312,7 +364,12 @@ export function ConfigSyncSettings() { try { const outcome = await downloadAndApplyConfig() if (!mounted.current) return - toast.success(t("restored", { count: outcome.applied.total })) + // Providers, appearance, and language are read once at launch, so the + // window the user is looking at keeps showing the old values. Saying so + // is the difference between "it worked" and "it did nothing". + toast.success(t("restored", { count: outcome.applied.total }), { + description: t("restartHint"), + }) } catch (err) { toast.error(localize(err)) } finally { @@ -353,7 +410,9 @@ export function ConfigSyncSettings() { try { const result = await importConfigFromFile(path) if (mounted.current) { - toast.success(t("imported", { count: result.applied.total })) + toast.success(t("imported", { count: result.applied.total }), { + description: t("restartHint"), + }) } } catch (err) { toast.error(localize(err)) @@ -370,6 +429,13 @@ export function ConfigSyncSettings() { const remoteBusy = busy === "upload" || busy === "download" const credentialsIncomplete = serverUrl.trim().length === 0 || username.trim().length === 0 + // Editing either half of the account orphans the stored password: saving + // from here stores an empty one, so the field must ask for a real value + // instead of offering to keep something that will be dropped. + const keepsStoredPassword = + hasPassword && + serverUrl.trim() === savedAccount.serverUrl && + username.trim() === savedAccount.username return (
@@ -459,7 +525,7 @@ export function ConfigSyncSettings() { void handleToggleEnabled(next)} disabled={!loaded || busy !== null} /> @@ -542,7 +608,9 @@ export function ConfigSyncSettings() { value={password} onChange={(e) => setPassword(e.target.value)} placeholder={ - hasPassword ? t("passwordKeep") : t("passwordPlaceholder") + keepsStoredPassword + ? t("passwordKeep") + : t("passwordPlaceholder") } autoComplete="new-password" /> @@ -704,16 +772,14 @@ export function ConfigSyncSettings() { {t("importConfirmTitle")}
-

- {pendingImport?.preview.blockedReason - ? t("importBlocked") - : t("importConfirmBody")} -

+

{t("importConfirmBody")}

+ {/* The backend's recount, not the file's own `manifest.counts` + — a hand-edited export can disagree with its payload, and + the confirmation has to name what will really be written. */} {pendingImport ? ( - + ) : null} +

{t("restartHint")}

@@ -724,7 +790,6 @@ export function ConfigSyncSettings() { e.preventDefault() void handleConfirmImport() }} - disabled={!pendingImport?.preview.importable} > {t("importConfirmAction")} @@ -748,6 +813,7 @@ export function ConfigSyncSettings() { {remoteManifest ? ( ) : null} +

{t("restartHint")}

diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index b012741242..859d446933 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "تُرفع اللقطة دون تشفير وتتضمن مفاتيح API، لذا استخدم خادم WebDAV لا يصل إليه سواك. أما بيانات اعتماد المزامنة نفسها فلا تُدرج أبدًا في اللقطة.", "importConfirmTitle": "استيراد الإعدادات؟", "importConfirmBody": "ستُضاف عناصر الملف أو تُحدّث على هذا الجهاز، وتبقى العناصر الموجودة غير المذكورة في الملف.", - "importBlocked": "لا يمكن استيراد هذا الملف.", + "restartHint": "تسري بعض التغييرات — المظهر واللغة وقائمة المزوّدين — بعد إعادة تشغيل codeg.", "importConfirmAction": "استيراد", "restoreConfirmTitle": "الاستعادة من الخادم؟", "restoreConfirmBody": "ستُطبّق على هذا الجهاز اللقطة المأخوذة من {device} بتاريخ {time}، وتبقى العناصر الموجودة غير المذكورة في اللقطة.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index ba71da4439..5b49e8e7ca 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "Der Snapshot wird unverschlüsselt hochgeladen und enthält API-Schlüssel. Nutze daher einen WebDAV-Server, auf den nur du zugreifen kannst. Die Zugangsdaten der Synchronisierung selbst sind nie Teil des Snapshots.", "importConfirmTitle": "Konfiguration importieren?", "importConfirmBody": "Einträge aus der Datei werden auf diesem Gerät hinzugefügt oder aktualisiert. Vorhandene Einträge, die in der Datei nicht vorkommen, bleiben erhalten.", - "importBlocked": "Diese Datei kann nicht importiert werden.", + "restartHint": "Einige Änderungen – Darstellung, Sprache und die Anbieterliste – werden erst nach einem Neustart von codeg wirksam.", "importConfirmAction": "Importieren", "restoreConfirmTitle": "Vom Server wiederherstellen?", "restoreConfirmBody": "Der Snapshot von {device} vom {time} wird auf dieses Gerät angewendet. Vorhandene Einträge, die im Snapshot fehlen, bleiben erhalten.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9f9fac8741..a8f10c5cf7 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "The snapshot is uploaded unencrypted and includes API keys, so use a WebDAV server only you can reach. The sync credentials themselves are never part of the snapshot.", "importConfirmTitle": "Import configuration?", "importConfirmBody": "Entries from this file will be added or updated on this machine. Existing entries that the file does not mention are kept.", - "importBlocked": "This file cannot be imported.", + "restartHint": "Some changes — appearance, language, and the provider list — take effect after restarting codeg.", "importConfirmAction": "Import", "restoreConfirmTitle": "Restore from remote?", "restoreConfirmBody": "The snapshot from {device}, taken {time}, will be applied to this machine. Existing entries that the snapshot does not mention are kept.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e26667dc42..3f15e7f834 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "La instantánea se sube sin cifrar e incluye claves de API, así que usa un servidor WebDAV al que solo tú tengas acceso. Las credenciales de sincronización nunca forman parte de la instantánea.", "importConfirmTitle": "¿Importar la configuración?", "importConfirmBody": "Las entradas del archivo se añadirán o actualizarán en este equipo. Las entradas existentes que el archivo no menciona se conservan.", - "importBlocked": "Este archivo no se puede importar.", + "restartHint": "Algunos cambios (apariencia, idioma y la lista de proveedores) se aplican al reiniciar codeg.", "importConfirmAction": "Importar", "restoreConfirmTitle": "¿Restaurar desde el servidor?", "restoreConfirmBody": "Se aplicará en este equipo la instantánea de {device} creada el {time}. Las entradas existentes que la instantánea no menciona se conservan.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index ed7f0d2734..12f21a2a4c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "L’instantané est envoyé sans chiffrement et contient des clés d’API : utilisez un serveur WebDAV auquel vous seul avez accès. Les identifiants de synchronisation, eux, n’y figurent jamais.", "importConfirmTitle": "Importer la configuration ?", "importConfirmBody": "Les entrées du fichier seront ajoutées ou mises à jour sur cette machine. Les entrées existantes absentes du fichier sont conservées.", - "importBlocked": "Ce fichier ne peut pas être importé.", + "restartHint": "Certains changements (apparence, langue et liste des fournisseurs) prennent effet après le redémarrage de codeg.", "importConfirmAction": "Importer", "restoreConfirmTitle": "Restaurer depuis le serveur ?", "restoreConfirmBody": "L’instantané de {device} pris le {time} sera appliqué à cette machine. Les entrées existantes absentes de l’instantané sont conservées.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 4b8a89a215..74f93a2d4c 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "スナップショットは暗号化せずに送信され、API キーも含まれます。自分だけがアクセスできる WebDAV サーバーを使ってください。同期用の認証情報自体は含まれません。", "importConfirmTitle": "設定を読み込みますか?", "importConfirmBody": "ファイルの項目がこのマシンに追加・更新されます。ファイルにない既存項目はそのまま残ります。", - "importBlocked": "このファイルは読み込めません。", + "restartHint": "外観・言語・プロバイダー一覧などの一部の変更は、codeg を再起動すると反映されます。", "importConfirmAction": "読み込む", "restoreConfirmTitle": "リモートから復元しますか?", "restoreConfirmBody": "{device} が {time} に作成したスナップショットをこのマシンに適用します。スナップショットにない既存項目は残ります。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 0db44712b2..967e6b5608 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "스냅샷은 암호화되지 않은 상태로 업로드되며 API 키를 포함합니다. 본인만 접근할 수 있는 WebDAV 서버를 사용하세요. 동기화 자격 증명 자체는 스냅샷에 포함되지 않습니다.", "importConfirmTitle": "설정을 가져올까요?", "importConfirmBody": "파일의 항목이 이 기기에 추가되거나 업데이트됩니다. 파일에 없는 기존 항목은 유지됩니다.", - "importBlocked": "이 파일은 가져올 수 없습니다.", + "restartHint": "모양, 언어, 공급자 목록 등 일부 변경 사항은 codeg를 다시 시작한 뒤에 적용됩니다.", "importConfirmAction": "가져오기", "restoreConfirmTitle": "원격에서 복원할까요?", "restoreConfirmBody": "{device}에서 {time}에 만든 스냅샷을 이 기기에 적용합니다. 스냅샷에 없는 기존 항목은 유지됩니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index a05d4d64cb..d6377ea2aa 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "O instantâneo é enviado sem criptografia e contém chaves de API, então use um servidor WebDAV que só você acesse. As credenciais de sincronização nunca fazem parte do instantâneo.", "importConfirmTitle": "Importar configuração?", "importConfirmBody": "Os itens do arquivo serão adicionados ou atualizados nesta máquina. Os itens existentes que o arquivo não menciona são mantidos.", - "importBlocked": "Este arquivo não pode ser importado.", + "restartHint": "Algumas alterações (aparência, idioma e a lista de provedores) só têm efeito depois de reiniciar o codeg.", "importConfirmAction": "Importar", "restoreConfirmTitle": "Restaurar do servidor?", "restoreConfirmBody": "O instantâneo de {device}, criado em {time}, será aplicado a esta máquina. Os itens existentes que o instantâneo não menciona são mantidos.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 7bd08f3b91..d4fa9b86d7 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "快照以明文上传且包含 API 密钥,请只使用只有你能访问的 WebDAV 服务器。同步凭据本身不会写入快照。", "importConfirmTitle": "导入配置?", "importConfirmBody": "文件中的条目将在本机新增或更新,文件未涉及的本地条目会保留。", - "importBlocked": "这个文件无法导入。", + "restartHint": "部分改动(外观、语言、服务商列表)需要重启 codeg 后才会生效。", "importConfirmAction": "导入", "restoreConfirmTitle": "从远程恢复?", "restoreConfirmBody": "将把 {device} 在 {time} 上传的快照应用到本机,快照未涉及的本地条目会保留。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 44be4b3d3c..de33aef081 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4763,7 +4763,7 @@ "plaintextWarning": "快照以明文上傳且包含 API 金鑰,請只使用只有你能存取的 WebDAV 伺服器。同步憑證本身不會寫入快照。", "importConfirmTitle": "匯入設定?", "importConfirmBody": "檔案中的項目將在本機新增或更新,檔案未涉及的本機項目會保留。", - "importBlocked": "這個檔案無法匯入。", + "restartHint": "部分變更(外觀、語言、服務商列表)需要重新啟動 codeg 後才會生效。", "importConfirmAction": "匯入", "restoreConfirmTitle": "從遠端還原?", "restoreConfirmBody": "將把 {device} 於 {time} 上傳的快照套用到本機,快照未涉及的本機項目會保留。", diff --git a/src/lib/config-sync.ts b/src/lib/config-sync.ts index 61cd7821fa..723f5af9ed 100644 --- a/src/lib/config-sync.ts +++ b/src/lib/config-sync.ts @@ -57,19 +57,27 @@ export interface ApplyReport { export interface ConfigExportSummary { path: string - manifest: ConfigManifest + counts: DomainCounts } +/** + * What `config_sync_peek_file` answers for a file the user picked. + * + * There is no "previewed but not importable" state: the peek runs the same + * parser and schema check the import does, so an unreadable file — malformed + * JSON, a newer schema — comes back as a rejected promise carrying a + * `configSync.error.*` key, and the caller shows that instead of a dialog. + * Reaching a preview at all means the file can be applied. + */ export interface ConfigImportPreview { manifest: ConfigManifest - /** False when the file is from a newer schema or fails its checksum; the - * import button stays disabled and `blockedReason` explains why. */ - importable: boolean - blockedReason: string | null + /** Recomputed from the payload, NOT read back from `manifest.counts`: a + * hand-edited file can claim anything there, and the confirmation has to + * state what will actually be written. */ + counts: DomainCounts } export interface ConfigImportResult { - manifest: ConfigManifest applied: ApplyReport rollbackPath: string | null } From 0aeadcfe828c26cab75ab91832a4086d685035bf Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 10:33:12 +0800 Subject: [PATCH 3/9] fix(config-sync): stamp the upload baseline with its destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing `last_uploaded_sha256` inside `save_settings_core` closed the "retargeting leaves the new remote empty" hole only for a clean, serial save. It is a second write, so it can fail silently (`save_state` only warns), be interrupted between the two writes, or be undone by a background upload that was already in flight against the OLD target and writes its hash back afterwards. Each of those leaves the new destination suppressed and permanently empty — the bug the reset was there to fix. Record the destination alongside the hash instead. Suppression now needs both halves to match, so a changed target simply stops matching and there is nothing to reset, nothing to interleave with, and no second write to lose. A state row from before the field existed reads as `None` and suppresses nothing, costing one redundant upload. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/commands/config_sync/webdav_sync.rs | 202 +++++++++++------- .../settings/config-sync-settings.test.tsx | 1 + src/lib/config-sync.ts | 4 + 3 files changed, 134 insertions(+), 73 deletions(-) diff --git a/src-tauri/src/commands/config_sync/webdav_sync.rs b/src-tauri/src/commands/config_sync/webdav_sync.rs index 1407e9bbb8..5fe1b8b168 100644 --- a/src-tauri/src/commands/config_sync/webdav_sync.rs +++ b/src-tauri/src/commands/config_sync/webdav_sync.rs @@ -75,14 +75,18 @@ impl ConfigSyncSettings { !self.server_url.trim().is_empty() } - /// The remote location this configuration points at. Two settings that - /// agree here write the same two files; a change to any part of it means - /// the previously uploaded snapshot says nothing about the new location. - fn remote_identity(&self) -> (&str, &str, &str) { - ( - self.server_url.as_str(), - self.remote_dir.as_str(), - self.profile.as_str(), + /// The remote location this configuration points at, as a value that can + /// be stored alongside the upload hash. Two settings that agree here write + /// the same two files. + /// + /// A NUL separator rather than a slash: every part is user-typed, and a + /// `/` would let `{dir: "a/b", profile: "c"}` and `{dir: "a", profile: + /// "b/c"}` produce the same key. (`sanitize_path_segment` rejects both + /// today; the separator is what keeps that from becoming load-bearing.) + fn remote_target(&self) -> String { + format!( + "{}\u{0}{}\u{0}{}", + self.server_url, self.remote_dir, self.profile ) } } @@ -164,6 +168,13 @@ pub struct ConfigSyncState { /// does not re-upload an unchanged configuration just to rebuild an /// in-memory baseline. pub last_uploaded_sha256: Option, + /// Which remote the hash above was uploaded TO + /// ([`ConfigSyncSettings::remote_target`]). Without it the hash reads as + /// "this configuration is already up there" and suppresses the first + /// upload to a newly configured server, leaving it permanently empty. + /// `None` on a row written before this field existed — which costs one + /// redundant upload, the safe direction to be wrong in. + pub last_uploaded_target: Option, pub last_sync_at: Option, pub last_error: Option, } @@ -232,20 +243,13 @@ pub async fn save_settings_core( .await .map_err(AppCommandError::db)?; - // Pointing at a different server, folder, or profile invalidates the - // "already uploaded this" baseline: the hash describes local - // configuration, not where a copy of it landed, so keeping it would let - // the timer decide the NEW location is already up to date and leave it - // empty until some unrelated setting changes. - if existing.remote_identity() != merged.remote_identity() { - let mut state = load_state(conn).await; - if state.last_uploaded_sha256.is_some() || state.last_error.is_some() { - state.last_uploaded_sha256 = None; - state.last_error = None; - save_state(conn, &state).await; - } - } - + // Note there is deliberately no "clear the upload baseline" step here. + // Pointing at a different server, folder, or profile does invalidate the + // baseline — but a second write that the save path has to remember to + // make is a write that can fail silently, crash in between, or be undone + // by an upload that was already in flight against the OLD target. The + // baseline records its own target instead (see `upload_snapshot_core`), + // so it simply stops matching; nothing has to be reset. Ok(ConfigSyncSettingsView::from(&merged)) } @@ -368,8 +372,14 @@ pub async fn upload_snapshot_core( let hash = sha256_hex(&bytes); let counts = snapshot.counts(); + // The baseline suppresses an upload only when BOTH halves match: the same + // bytes AND the same destination. A hash on its own would say "already + // uploaded" about a server that has never been written to. + let target = settings.remote_target(); let mut state = load_state(conn).await; - if !force && state.last_uploaded_sha256.as_deref() == Some(hash.as_str()) { + let already_there = state.last_uploaded_sha256.as_deref() == Some(hash.as_str()) + && state.last_uploaded_target.as_deref() == Some(target.as_str()); + if !force && already_there { // The common case on a timer: nothing changed, so nothing is sent and // no request is made at all. return Ok(UploadOutcome { @@ -402,6 +412,7 @@ pub async fn upload_snapshot_core( match result { Ok(()) => { state.last_uploaded_sha256 = Some(hash.clone()); + state.last_uploaded_target = Some(target); state.last_sync_at = Some(synced_at.clone()); state.last_error = None; save_state(conn, &state).await; @@ -683,53 +694,22 @@ mod tests { assert_eq!(stored.profile, "work"); } - /// Retargeting the sync must not leave the new location empty. The hash - /// says "this configuration was uploaded", not "uploaded HERE", so it - /// stops meaning anything the moment the destination changes. - #[tokio::test] - async fn changing_the_remote_target_clears_the_upload_baseline() { - let db = fresh_in_memory_db().await; - save_settings_core(&db.conn, input()).await.expect("save"); + /// Seed "this exact configuration is already on the currently configured + /// remote", which is the state the timer spends most of its life in. + async fn seed_uploaded_baseline(db: &crate::db::AppDatabase) -> String { + let snapshot = collect_snapshot_core(&db.conn).await.expect("collect"); + let hash = sha256_hex(&serialize_snapshot(&snapshot).expect("bytes")); save_state( &db.conn, &ConfigSyncState { - last_uploaded_sha256: Some("previous".to_string()), + last_uploaded_sha256: Some(hash.clone()), + last_uploaded_target: Some(load_settings(&db.conn).await.remote_target()), last_sync_at: Some("2026-01-01T00:00:00Z".to_string()), - last_error: Some("stale failure".to_string()), + last_error: None, }, ) .await; - - // Same target, unrelated field: the baseline is still valid. - save_settings_core( - &db.conn, - ConfigSyncSettingsInput { - interval_minutes: 30, - ..input() - }, - ) - .await - .expect("save"); - assert_eq!( - load_state(&db.conn).await.last_uploaded_sha256.as_deref(), - Some("previous") - ); - - save_settings_core( - &db.conn, - ConfigSyncSettingsInput { - profile: Some("personal".to_string()), - ..input() - }, - ) - .await - .expect("save"); - let state = load_state(&db.conn).await; - assert_eq!(state.last_uploaded_sha256, None); - assert_eq!(state.last_error, None); - // "When we last uploaded" is history, not a decision input — it stays - // so the panel does not claim the machine has never synced. - assert_eq!(state.last_sync_at.as_deref(), Some("2026-01-01T00:00:00Z")); + hash } #[tokio::test] @@ -737,30 +717,49 @@ mod tests { let db = fresh_in_memory_db().await; let state = ConfigSyncState { last_uploaded_sha256: Some("abc".to_string()), + last_uploaded_target: Some("https://dav.example.com/dav\u{0}codeg\u{0}work".to_string()), last_sync_at: Some("2026-01-01T00:00:00Z".to_string()), last_error: None, }; save_state(&db.conn, &state).await; let loaded = load_state(&db.conn).await; assert_eq!(loaded.last_uploaded_sha256.as_deref(), Some("abc")); + assert_eq!( + loaded.last_uploaded_target.as_deref(), + state.last_uploaded_target.as_deref() + ); } - /// An unchanged configuration must not touch the network — this is what - /// makes a 5-minute timer acceptable. + /// A row written before the target was recorded must not read as "already + /// uploaded" — being wrong in the other direction costs one extra upload, + /// being wrong this way costs an empty remote forever. #[tokio::test] - async fn an_unchanged_snapshot_skips_the_upload_entirely() { + async fn a_baseline_from_an_older_build_does_not_suppress_anything() { let db = fresh_in_memory_db().await; let snapshot = collect_snapshot_core(&db.conn).await.expect("collect"); let hash = sha256_hex(&serialize_snapshot(&snapshot).expect("bytes")); - save_state( + app_metadata_service::upsert_value( &db.conn, - &ConfigSyncState { - last_uploaded_sha256: Some(hash.clone()), - last_sync_at: Some("2026-01-01T00:00:00Z".to_string()), - last_error: None, - }, + CONFIG_SYNC_STATE_KEY, + &format!(r#"{{"lastUploadedSha256":"{hash}"}}"#), ) - .await; + .await + .expect("seed legacy row"); + + let state = load_state(&db.conn).await; + assert_eq!(state.last_uploaded_sha256.as_deref(), Some(hash.as_str())); + assert_eq!(state.last_uploaded_target, None); + upload_snapshot_core(&db.conn, "1.0.0", false) + .await + .expect_err("an unstamped baseline must not skip the upload"); + } + + /// An unchanged configuration must not touch the network — this is what + /// makes a 5-minute timer acceptable. + #[tokio::test] + async fn an_unchanged_snapshot_skips_the_upload_entirely() { + let db = fresh_in_memory_db().await; + let hash = seed_uploaded_baseline(&db).await; // No server is configured, so reaching the transport at all would // surface as an error rather than a skip. @@ -770,4 +769,61 @@ mod tests { assert!(!outcome.uploaded); assert_eq!(outcome.sha256, hash); } + + /// Retargeting the sync must not leave the new location empty. The hash + /// alone says "this configuration was uploaded", not "uploaded HERE", so + /// the baseline records its destination and simply stops matching. + /// + /// Recorded rather than reset on save, because a reset is a second write: + /// it can fail silently, be interrupted, or be overwritten by an upload + /// that was already in flight against the old target. A self-describing + /// baseline has no such window. + #[tokio::test] + async fn a_baseline_does_not_carry_over_to_a_new_remote() { + let db = fresh_in_memory_db().await; + save_settings_core(&db.conn, input()).await.expect("save"); + let hash = seed_uploaded_baseline(&db).await; + + // Same target, unrelated field: still suppressed, no network. + save_settings_core( + &db.conn, + ConfigSyncSettingsInput { + interval_minutes: 30, + ..input() + }, + ) + .await + .expect("save"); + let outcome = upload_snapshot_core(&db.conn, "1.0.0", false) + .await + .expect("same target, same bytes: skip"); + assert!(!outcome.uploaded); + assert_eq!(outcome.sha256, hash); + + // New profile, byte-identical configuration: the suppression must not + // apply. The configured URL is unreachable, so an attempt surfaces as + // an error — which is the proof that an attempt was made at all. + for retarget in [ + ConfigSyncSettingsInput { + profile: Some("personal".to_string()), + ..input() + }, + ConfigSyncSettingsInput { + remote_dir: Some("elsewhere".to_string()), + ..input() + }, + ConfigSyncSettingsInput { + server_url: " ".to_string(), + ..input() + }, + ] { + let db = fresh_in_memory_db().await; + save_settings_core(&db.conn, input()).await.expect("save"); + seed_uploaded_baseline(&db).await; + save_settings_core(&db.conn, retarget).await.expect("save"); + upload_snapshot_core(&db.conn, "1.0.0", false) + .await + .expect_err("a new target must be uploaded to, not skipped"); + } + } } diff --git a/src/components/settings/config-sync-settings.test.tsx b/src/components/settings/config-sync-settings.test.tsx index 0f9dc708d1..65d8958073 100644 --- a/src/components/settings/config-sync-settings.test.tsx +++ b/src/components/settings/config-sync-settings.test.tsx @@ -120,6 +120,7 @@ beforeEach(() => { vi.mocked(getConfigSyncSettings).mockResolvedValue({ ...SAVED }) vi.mocked(getConfigSyncState).mockResolvedValue({ lastUploadedSha256: null, + lastUploadedTarget: null, lastSyncAt: null, lastError: null, }) diff --git a/src/lib/config-sync.ts b/src/lib/config-sync.ts index 723f5af9ed..68cb87d631 100644 --- a/src/lib/config-sync.ts +++ b/src/lib/config-sync.ts @@ -109,6 +109,10 @@ export interface ConfigSyncSettingsInput { export interface ConfigSyncState { lastUploadedSha256: string | null + /** Which remote the hash above went to. The pair is what suppresses a + * redundant upload; the hash alone would also suppress the FIRST upload to + * a newly configured server. */ + lastUploadedTarget: string | null lastSyncAt: string | null lastError: string | null } From 8855016324c1605fbc1b504864532ed983f403a0 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 14:33:14 +0800 Subject: [PATCH 4/9] feat(config-sync): server mode, reachable rollback, keyring secrets, encryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review left four design-level gaps open. This closes all four. **It only ran on the desktop.** The commands were `#[tauri::command]`-only and the panel returned `null` outside Tauri, so a feature whose entire point is moving configuration between machines was unavailable on the half of the product that runs on a server. Every entry point now has an Axum handler over the same `_core` function, and `codeg-server` spawns the auto-sync loop. File transfer goes by content rather than by path — a snapshot is tens of KB, so `backup.rs`'s multipart upload, download tickets and temp-file reaping would be machinery with nothing to carry. **The rollback snapshot was written and then unreachable.** Every import saved one and returned its `rollbackPath`, and nothing could list or apply it. There are now list/apply commands behind an opaque id that is validated against an alphabet before it is ever joined to a path, a panel section that appears only when there is something to undo, and an undo that saves its own rollback point first. **The WebDAV password sat in plaintext in `app_metadata`** — and so in every backup archive that database is packed into — while the snapshot itself had a reserved `encryption` field that nothing ever set. Both secrets moved to the store this codebase already uses for credentials (OS keyring on desktop, the 0600 token file on a server), with a migration that retries until the row is clean. Encryption is opt-in: AES-256-GCM under an Argon2id key, in a JSON envelope so the remote file keeps its `config.json` name and the import path can recognise it from the value it already parses. The upload baseline is stamped with the protection in force, so turning encryption on forces a re-upload instead of leaving the plaintext copy sitting on the remote. **`peek` decoded the manifest but not the payload**, so a hand-edited file previewed as valid and then aborted mid-apply. Each domain now carries a `validate` beside its `collect`/`apply`, called from `parse_snapshot` — the one door every snapshot enters through — with a test asserting validate and apply agree on every domain, and another proving that agreement is not vacuous. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/app_error.rs | 18 + src-tauri/src/bin/codeg_server.rs | 16 + .../src/commands/config_sync/credentials.rs | 208 ++++++ src-tauri/src/commands/config_sync/crypto.rs | 398 +++++++++++ src-tauri/src/commands/config_sync/domains.rs | 108 ++- .../src/commands/config_sync/local_io.rs | 294 +++++++- src-tauri/src/commands/config_sync/mod.rs | 75 +- .../src/commands/config_sync/snapshot.rs | 267 ++++++- .../src/commands/config_sync/webdav_sync.rs | 674 +++++++++++++++++- src-tauri/src/keyring_store.rs | 94 ++- src-tauri/src/lib.rs | 5 + src-tauri/src/web/handlers/config_sync.rs | 147 ++++ src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/router.rs | 54 ++ .../settings/config-sync-settings.test.tsx | 219 +++++- .../settings/config-sync-settings.tsx | 274 ++++++- src/i18n/messages/ar.json | 25 +- src/i18n/messages/de.json | 25 +- src/i18n/messages/en.json | 25 +- src/i18n/messages/es.json | 25 +- src/i18n/messages/fr.json | 25 +- src/i18n/messages/ja.json | 25 +- src/i18n/messages/ko.json | 25 +- src/i18n/messages/pt.json | 25 +- src/i18n/messages/zh-CN.json | 25 +- src/i18n/messages/zh-TW.json | 25 +- src/lib/config-sync.ts | 151 +++- 27 files changed, 3114 insertions(+), 139 deletions(-) create mode 100644 src-tauri/src/commands/config_sync/credentials.rs create mode 100644 src-tauri/src/commands/config_sync/crypto.rs create mode 100644 src-tauri/src/web/handlers/config_sync.rs diff --git a/src-tauri/src/app_error.rs b/src-tauri/src/app_error.rs index 4b31a98877..468c874c75 100644 --- a/src-tauri/src/app_error.rs +++ b/src-tauri/src/app_error.rs @@ -96,6 +96,24 @@ pub const CONFIG_SYNC_I18N_KEY_QUOTA: &str = "configSync.error.quota"; pub const CONFIG_SYNC_I18N_KEY_NETWORK: &str = "configSync.error.network"; /// The server answered with an unexpected status. Params: `status`. pub const CONFIG_SYNC_I18N_KEY_SERVER: &str = "configSync.error.server"; +/// The snapshot is encrypted but no passphrase is stored on this machine. +pub const CONFIG_SYNC_I18N_KEY_PASSPHRASE_REQUIRED: &str = "configSync.error.passphraseRequired"; +/// The stored passphrase did not decrypt the snapshot (or its bytes are +/// corrupt — GCM cannot tell those apart, and neither can we). +pub const CONFIG_SYNC_I18N_KEY_BAD_PASSPHRASE: &str = "configSync.error.badPassphrase"; +/// A domain payload inside an otherwise well-formed snapshot does not decode. +/// Params: `domain`. +pub const CONFIG_SYNC_I18N_KEY_BAD_DOMAIN: &str = "configSync.error.badDomain"; +/// The named rollback snapshot is not on this machine (pruned, or a stale id +/// from a list the UI has not refreshed). +pub const CONFIG_SYNC_I18N_KEY_NO_ROLLBACK: &str = "configSync.error.noRollback"; +/// The OS keyring (or the server's token file) would not open, so the stored +/// credentials can be neither read nor safely rewritten. +pub const CONFIG_SYNC_I18N_KEY_CREDENTIALS_UNREADABLE: &str = + "configSync.error.credentialsUnreadable"; +/// Encryption is on locally but the remote copy is not encrypted. Accepting it +/// would let anyone who can write to the share undo the setting. +pub const CONFIG_SYNC_I18N_KEY_NOT_ENCRYPTED: &str = "configSync.error.notEncrypted"; #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 244fc6ed4d..8bac04c947 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -526,6 +526,22 @@ async fn async_main() -> ExitCode { tokio::spawn(codeg_lib::work_task::run_task_engine(engine)); } + // Config-sync uploader (mirrors lib.rs setup): sleeps a minute, then + // compares the configuration's hash every interval and uploads only when + // it changed. Does nothing at all until a WebDAV endpoint is configured. + { + let db_for_sync = state.db.conn.clone(); + let emitter = std::sync::Arc::new(state.emitter.clone()); + tokio::spawn(async move { + codeg_lib::commands::config_sync::auto_sync::run_auto_sync_loop( + db_for_sync, + emitter, + codeg_lib::commands::config_sync::APP_VERSION.to_string(), + ) + .await; + }); + } + // Label worktree folders registered before aliases were seeded at creation // with the branch they have checked out (mirrors lib.rs setup). Background; // changed folders are broadcast, so a browser that already fetched its diff --git a/src-tauri/src/commands/config_sync/credentials.rs b/src-tauri/src/commands/config_sync/credentials.rs new file mode 100644 index 0000000000..7769ff31e0 --- /dev/null +++ b/src-tauri/src/commands/config_sync/credentials.rs @@ -0,0 +1,208 @@ +//! The two secrets config sync owns, kept out of the settings row. +//! +//! The WebDAV password and the (optional) snapshot passphrase used to live in +//! the same `app_metadata` JSON blob as the rest of the settings — plaintext in +//! the SQLite file, and therefore inside every backup archive that file is +//! packed into. Neither is portable and neither belongs to a snapshot, so they +//! go where this codebase already puts device-local credentials: the OS keyring +//! on desktop, the `0600` token store on a server ([`crate::keyring_store`]). +//! +//! Failures are surfaced, never swallowed. A password that silently failed to +//! save would present as "the server rejected your credentials" at the next +//! tick, minutes later and nowhere near the action that caused it. + +use crate::app_error::AppCommandError; + +/// Keyring entry names. Stable: renaming one orphans the stored secret. +pub const WEBDAV_PASSWORD: &str = "config-sync-webdav-password"; +pub const SNAPSHOT_PASSPHRASE: &str = "config-sync-snapshot-passphrase"; + +/// Missing and unreadable collapse to "no secret". That is right for a caller +/// that is about to USE the secret — the next step, ask the user to type it +/// again, is the same either way — and wrong for one that is about to write it +/// back, which must call [`read`] instead. See [`ensure_readable`]. +pub fn load(name: &str) -> String { + read(name).ok().flatten().unwrap_or_default() +} + +/// `Ok(None)` is "nothing stored"; `Err` is "the store would not open". +pub fn read(name: &str) -> Result, AppCommandError> { + store::get(name).map_err(|e| { + AppCommandError::io_error("Failed to read the config sync credentials") + .with_detail(e) + .with_i18n( + crate::app_error::CONFIG_SYNC_I18N_KEY_CREDENTIALS_UNREADABLE, + std::collections::BTreeMap::new(), + ) + }) +} + +/// Refuse to go on when the secret store cannot be read. +/// +/// The save path reads the stored secrets, merges the user's edits over them, +/// and writes the result back — and an empty value means "delete this entry" on +/// the way back. So a store that reads as empty only because it would not open +/// turns any unrelated save (nudging the sync interval) into a permanent +/// erasure of the passphrase the snapshot already on the remote is encrypted +/// under. Stopping at the read is the difference between "try again" and "your +/// backup is now undecryptable". +pub fn ensure_readable() -> Result<(), AppCommandError> { + read(WEBDAV_PASSWORD)?; + read(SNAPSHOT_PASSPHRASE)?; + Ok(()) +} + +/// Stores, or removes the entry entirely when `value` is empty, so "cleared" +/// and "never set" stay the same state. +pub fn store(name: &str, value: &str) -> Result<(), AppCommandError> { + let result = if value.is_empty() { + store::delete(name) + } else { + store::set(name, value) + }; + result.map_err(|e| { + AppCommandError::io_error("Failed to store the config sync credential").with_detail(e) + }) +} + +#[cfg(not(test))] +mod store { + pub fn get(name: &str) -> Result, String> { + crate::keyring_store::get_secret(name) + } + pub fn set(name: &str, value: &str) -> Result<(), String> { + crate::keyring_store::set_secret(name, value) + } + pub fn delete(name: &str) -> Result<(), String> { + crate::keyring_store::delete_secret(name) + } +} + +/// Tests must never reach the developer's real login keychain (macOS would +/// prompt, CI has none), so the test build swaps in a process-local map. The +/// keyring itself is third-party and not what these tests are about; what they +/// exercise is that the settings path stores and reads secrets *somewhere other +/// than the settings row*. +#[cfg(test)] +mod store { + use std::collections::HashMap; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Mutex, OnceLock}; + + fn map() -> &'static Mutex> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(|| Mutex::new(HashMap::new())) + } + + /// Stands in for a denied keychain prompt or an unreadable `tokens.json`, + /// the one failure the in-process map cannot produce on its own. + static UNREADABLE: AtomicBool = AtomicBool::new(false); + + pub fn set_unreadable(unreadable: bool) { + UNREADABLE.store(unreadable, Ordering::SeqCst); + } + + pub fn get(name: &str) -> Result, String> { + if UNREADABLE.load(Ordering::SeqCst) { + return Err("simulated keyring read failure".to_string()); + } + Ok(map().lock().expect("credential map").get(name).cloned()) + } + pub fn set(name: &str, value: &str) -> Result<(), String> { + map() + .lock() + .expect("credential map") + .insert(name.to_string(), value.to_string()); + Ok(()) + } + pub fn delete(name: &str) -> Result<(), String> { + map().lock().expect("credential map").remove(name); + Ok(()) + } +} + +/// The secret store is process-global by nature, so two tests that both save +/// settings would overwrite each other's password. Any test that goes through +/// [`store`] must hold this for its duration. +#[cfg(test)] +pub fn test_guard() -> std::sync::MutexGuard<'static, ()> { + use std::sync::{Mutex, OnceLock}; + static GUARD: OnceLock> = OnceLock::new(); + // A poisoned guard means an unrelated test panicked while holding it; the + // secrets are re-seeded by every test that takes it, so recovering is + // correct and keeps one failure from cascading into the whole file. + GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Make every read fail for as long as the returned value is alive. RAII so a +/// failing assertion cannot leave the flag set and redden every later test. +#[cfg(test)] +pub fn unreadable_store() -> UnreadableStore { + store::set_unreadable(true); + UnreadableStore +} + +#[cfg(test)] +pub struct UnreadableStore; + +#[cfg(test)] +impl Drop for UnreadableStore { + fn drop(&mut self) { + store::set_unreadable(false); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The distinction the whole save path rests on: a store that will not open + /// must not look like a store with nothing in it, because "nothing in it" + /// travels back out as a deletion. + #[test] + fn an_unreadable_store_is_not_an_empty_one() { + let _guard = test_guard(); + store(WEBDAV_PASSWORD, "app-password").expect("store"); + assert_eq!(read(WEBDAV_PASSWORD).expect("readable"), Some("app-password".into())); + assert!(ensure_readable().is_ok()); + + { + let _unreadable = unreadable_store(); + assert!(read(WEBDAV_PASSWORD).is_err(), "a failed read must not read as absent"); + assert!(ensure_readable().is_err()); + // `load` still collapses the two on purpose: its callers are about + // to ask the user for the secret either way. + assert_eq!(load(WEBDAV_PASSWORD), ""); + } + + assert!(ensure_readable().is_ok(), "the guard must restore the store"); + store(WEBDAV_PASSWORD, "").expect("clean up"); + } + + #[test] + fn a_secret_round_trips_and_clearing_removes_it() { + let _guard = test_guard(); + store(WEBDAV_PASSWORD, "app-password").expect("store"); + assert_eq!(load(WEBDAV_PASSWORD), "app-password"); + + // Empty is "gone", not "stored as an empty string" — the difference the + // `hasPassword` flag in the settings view is computed from. + store(WEBDAV_PASSWORD, "").expect("clear"); + assert_eq!(load(WEBDAV_PASSWORD), ""); + } + + #[test] + fn the_two_secrets_do_not_share_an_entry() { + let _guard = test_guard(); + store(WEBDAV_PASSWORD, "password").expect("store"); + store(SNAPSHOT_PASSPHRASE, "passphrase").expect("store"); + assert_eq!(load(WEBDAV_PASSWORD), "password"); + assert_eq!(load(SNAPSHOT_PASSPHRASE), "passphrase"); + store(WEBDAV_PASSWORD, "").expect("clear"); + assert_eq!(load(SNAPSHOT_PASSPHRASE), "passphrase"); + store(SNAPSHOT_PASSPHRASE, "").expect("clear"); + } +} diff --git a/src-tauri/src/commands/config_sync/crypto.rs b/src-tauri/src/commands/config_sync/crypto.rs new file mode 100644 index 0000000000..bcd40a84a0 --- /dev/null +++ b/src-tauri/src/commands/config_sync/crypto.rs @@ -0,0 +1,398 @@ +//! Optional passphrase encryption for the snapshot payload. +//! +//! The feature's baseline security boundary is the user's own authenticated +//! WebDAV endpoint, and for a self-hosted share that is a real boundary. It is +//! a weaker one on a hosted drive, where the operator can read every file in +//! the account — and a config snapshot carries provider API keys. Turning +//! encryption on moves the trust boundary from "my storage provider" to "my +//! passphrase" without changing anything else about the protocol. +//! +//! ## Shape +//! +//! The envelope is JSON, not a binary header like +//! [`crate::commands::backup::crypto`]'s `.codegbak`. Three reasons: the remote +//! file keeps its `config.json` name and stays a JSON document a cloud drive's +//! web UI will preview; the import path can tell an encrypted payload from a +//! plaintext one by looking at the same parsed value it already reads the +//! export marker from; and the payload is tens of KB, so the streaming +//! construction that exists to keep gigabyte archives out of memory buys +//! nothing here. AES-256-GCM in one shot, Argon2id for the key. +//! +//! The header is cleartext because the salt and nonce must be readable before +//! the key can be derived. It needs no separate integrity check: every field in +//! it feeds either key derivation or the nonce, so tampering with any of them +//! produces a wrong key or a wrong nonce and the GCM tag fails — which is also +//! what tells a wrong passphrase from a right one. (`algo` and `kdf` are the +//! two that feed neither, and those are compared against fixed constants.) +//! +//! ## What the envelope does not bind +//! +//! No AAD, so the ciphertext is bound to nothing outside itself: not the remote +//! folder, not the profile, not the time it was written. A share operator can +//! therefore serve back an older copy of the same folder, or move the `work` +//! pair into `personal`, and it will decrypt and apply. That is deliberate, on +//! two grounds. It is not a regression — plaintext sync, the default, has +//! exactly the same exposure, because a snapshot has no notion of where it was +//! supposed to come from. And binding the profile would break the manual import +//! path, which decrypts a `config.json` the user copied off the share by hand +//! and cannot know which folder it came from. +//! +//! What encryption is claimed to buy is therefore narrow and worth stating +//! plainly: the operator cannot READ the snapshot, and cannot substitute one +//! they authored — only replay one of the user's own. Injecting attacker-chosen +//! provider endpoints and API keys is what it stops, and the other half of that +//! guarantee is in `open_after_download`, which refuses a manifest that claims +//! the payload is plaintext while the local switch says otherwise. + +use std::collections::BTreeMap; + +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::app_error::{ + AppCommandError, CONFIG_SYNC_I18N_KEY_BAD_PASSPHRASE, CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, +}; + +/// Marker + version of the encrypted envelope. Bump only for a change an older +/// binary cannot read. +pub const ENVELOPE_VERSION: u32 = 1; +/// The JSON key whose presence identifies an encrypted payload. +pub const ENVELOPE_MARKER: &str = "codegConfigEncryption"; + +const ALGO: &str = "AES-256-GCM"; +const KDF: &str = "Argon2id"; +const SALT_LEN: usize = 16; +/// GCM's standard nonce width. Random per encryption, never reused: every +/// upload re-encrypts from scratch rather than patching a stored ciphertext. +const NONCE_LEN: usize = 12; + +// Argon2id cost. Matches the backup envelope: 64 MiB / 3 passes / 1 lane is an +// interactive cost that still meaningfully slows brute force on a snapshot +// someone pulled off a cloud drive. +const DEFAULT_M_COST: u32 = 64 * 1024; +const DEFAULT_T_COST: u32 = 3; +const DEFAULT_P_COST: u32 = 1; + +// Bounds on the attacker-controlled header, so a hostile file cannot drive an +// unbounded Argon2 before the tag check gets a chance to fail. Same envelope +// the product ever emits, with headroom; widen only alongside a version bump. +const MAX_M_COST: u32 = 256 * 1024; // KiB +const MAX_T_COST: u32 = 10; +const MAX_P_COST: u32 = 4; +const MAX_SALT_LEN: usize = 64; +const MIN_SALT_LEN: usize = 8; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KdfParams { + pub m_cost: u32, + pub t_cost: u32, + pub p_cost: u32, + /// Argon2 version constant: `0x13` (19) for the modern V0x13. + pub version: u32, +} + +impl Default for KdfParams { + fn default() -> Self { + Self { + m_cost: DEFAULT_M_COST, + t_cost: DEFAULT_T_COST, + p_cost: DEFAULT_P_COST, + version: 0x13, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedPayload { + /// Named to match [`ENVELOPE_MARKER`] after `rename_all`. + pub codeg_config_encryption: u32, + pub algo: String, + pub kdf: String, + pub kdf_params: KdfParams, + pub salt_b64: String, + pub nonce_b64: String, + pub ciphertext_b64: String, +} + +/// Cheap enough to run on every parsed import: looks at one key of a value the +/// caller has already deserialized. +pub fn is_encrypted_value(value: &Value) -> bool { + value.get(ENVELOPE_MARKER).is_some() +} + +fn invalid(message: &str) -> AppCommandError { + AppCommandError::invalid_input(message) + .with_i18n(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, BTreeMap::new()) +} + +/// A wrong passphrase and a corrupted ciphertext are the same GCM tag failure, +/// and telling them apart is not possible by design. The message names the +/// likely cause without claiming to know. +pub fn bad_passphrase_error() -> AppCommandError { + AppCommandError::invalid_input("The snapshot could not be decrypted with the stored passphrase") + .with_i18n(CONFIG_SYNC_I18N_KEY_BAD_PASSPHRASE, BTreeMap::new()) +} + +fn derive_key(passphrase: &str, salt: &[u8], params: &KdfParams) -> Result<[u8; 32], AppCommandError> { + let p = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32)).map_err(|e| { + AppCommandError::task_execution_failed("Invalid KDF parameters").with_detail(e.to_string()) + })?; + let version = if params.version == 0x10 { + Version::V0x10 + } else { + Version::V0x13 + }; + let argon2 = Argon2::new(Algorithm::Argon2id, version, p); + let mut key = [0u8; 32]; + argon2 + .hash_password_into(passphrase.as_bytes(), salt, &mut key) + .map_err(|e| { + AppCommandError::task_execution_failed("Key derivation failed").with_detail(e.to_string()) + })?; + Ok(key) +} + +/// Wrap `plain` in an encrypted envelope. Synchronous and CPU-bound for the +/// duration of one Argon2 derivation (~100 ms); callers on an async path hop to +/// a blocking thread. +pub fn encrypt(plain: &[u8], passphrase: &str) -> Result, AppCommandError> { + if passphrase.is_empty() { + return Err(passphrase_required_error()); + } + let mut salt = [0u8; SALT_LEN]; + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut salt); + rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); + + let kdf_params = KdfParams::default(); + let key = derive_key(passphrase, &salt, &kdf_params)?; + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce_bytes), plain) + .map_err(|_| AppCommandError::task_execution_failed("Failed to encrypt the snapshot"))?; + + let payload = EncryptedPayload { + codeg_config_encryption: ENVELOPE_VERSION, + algo: ALGO.to_string(), + kdf: KDF.to_string(), + kdf_params, + salt_b64: B64.encode(salt), + nonce_b64: B64.encode(nonce_bytes), + ciphertext_b64: B64.encode(&ciphertext), + }; + serde_json::to_vec_pretty(&payload).map_err(|e| { + AppCommandError::task_execution_failed("Serialize encrypted snapshot") + .with_detail(e.to_string()) + }) +} + +pub fn passphrase_required_error() -> AppCommandError { + AppCommandError::invalid_input("This snapshot is encrypted and no passphrase is configured") + .with_i18n( + crate::app_error::CONFIG_SYNC_I18N_KEY_PASSPHRASE_REQUIRED, + BTreeMap::new(), + ) +} + +/// Unwrap an envelope produced by [`encrypt`]. +pub fn decrypt(payload: &EncryptedPayload, passphrase: &str) -> Result, AppCommandError> { + if payload.codeg_config_encryption > ENVELOPE_VERSION { + return Err(invalid("Encrypted snapshot uses a newer envelope format")); + } + if !payload.algo.eq_ignore_ascii_case(ALGO) || !payload.kdf.eq_ignore_ascii_case(KDF) { + return Err(invalid("Encrypted snapshot uses an unsupported algorithm")); + } + if passphrase.is_empty() { + return Err(passphrase_required_error()); + } + + let params = &payload.kdf_params; + if params.m_cost > MAX_M_COST || params.t_cost > MAX_T_COST || params.p_cost > MAX_P_COST { + return Err(invalid("Encrypted snapshot asks for an unsupported KDF cost")); + } + + let salt = B64 + .decode(payload.salt_b64.as_bytes()) + .map_err(|_| invalid("Encrypted snapshot has a malformed salt"))?; + if !(MIN_SALT_LEN..=MAX_SALT_LEN).contains(&salt.len()) { + return Err(invalid("Encrypted snapshot has a malformed salt")); + } + let nonce = B64 + .decode(payload.nonce_b64.as_bytes()) + .map_err(|_| invalid("Encrypted snapshot has a malformed nonce"))?; + if nonce.len() != NONCE_LEN { + return Err(invalid("Encrypted snapshot has a malformed nonce")); + } + let ciphertext = B64 + .decode(payload.ciphertext_b64.as_bytes()) + .map_err(|_| invalid("Encrypted snapshot has a malformed payload"))?; + + let key = derive_key(passphrase, &salt, params)?; + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + cipher + .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref()) + .map_err(|_| bad_passphrase_error()) +} + +/// Parse an envelope out of an already-deserialized value. +pub fn parse_envelope(value: Value) -> Result { + serde_json::from_value(value) + .map_err(|e| invalid("Malformed encrypted snapshot").with_detail(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Cheap Argon2 settings: these tests exercise the envelope, not the KDF, + /// and the default 64 MiB cost would dominate the suite's runtime. + fn cheap(payload: &mut EncryptedPayload) { + payload.kdf_params = KdfParams { + m_cost: 8, + t_cost: 1, + p_cost: 1, + version: 0x13, + }; + } + + /// Re-encrypt `plain` under the cheap parameters so the round-trip tests + /// do not each pay a 64 MiB derivation. + fn seal(plain: &[u8], passphrase: &str) -> EncryptedPayload { + let mut salt = [0u8; SALT_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut salt); + rand::rngs::OsRng.fill_bytes(&mut nonce); + let mut payload = EncryptedPayload { + codeg_config_encryption: ENVELOPE_VERSION, + algo: ALGO.to_string(), + kdf: KDF.to_string(), + kdf_params: KdfParams::default(), + salt_b64: B64.encode(salt), + nonce_b64: B64.encode(nonce), + ciphertext_b64: String::new(), + }; + cheap(&mut payload); + let key = derive_key(passphrase, &salt, &payload.kdf_params).expect("derive"); + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let ct = cipher + .encrypt(Nonce::from_slice(&nonce), plain) + .expect("encrypt"); + payload.ciphertext_b64 = B64.encode(&ct); + payload + } + + #[test] + fn a_snapshot_round_trips_through_the_envelope() { + let plain = br#"{"schemaVersion":1,"domains":{}}"#; + let payload = seal(plain, "correct horse"); + assert_eq!(decrypt(&payload, "correct horse").expect("decrypt"), plain); + } + + #[test] + fn the_envelope_never_contains_the_plaintext() { + let plain = b"sk-super-secret-api-key"; + let bytes = serde_json::to_vec(&seal(plain, "pw")).expect("bytes"); + let text = String::from_utf8(bytes).expect("utf8"); + assert!( + !text.contains("sk-super-secret-api-key"), + "plaintext leaked into the envelope: {text}" + ); + // And it is still JSON with the marker the import path branches on. + let value: Value = serde_json::from_str(&text).expect("json"); + assert!(is_encrypted_value(&value)); + assert!(!is_encrypted_value(&serde_json::json!({"schemaVersion": 1}))); + } + + #[test] + fn a_wrong_passphrase_is_refused_rather_than_misparsed() { + let payload = seal(b"payload", "right"); + let err = decrypt(&payload, "wrong").expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(CONFIG_SYNC_I18N_KEY_BAD_PASSPHRASE) + ); + } + + /// Every header field feeds the key or the nonce, so flipping one must fail + /// the tag — this is what stands in for a separate header MAC. + #[test] + fn tampering_with_the_cleartext_header_fails_the_tag() { + let original = seal(b"payload", "pw"); + + let mut swapped_salt = original.clone(); + swapped_salt.salt_b64 = B64.encode([7u8; SALT_LEN]); + assert!(decrypt(&swapped_salt, "pw").is_err()); + + let mut swapped_nonce = original.clone(); + swapped_nonce.nonce_b64 = B64.encode([7u8; NONCE_LEN]); + assert!(decrypt(&swapped_nonce, "pw").is_err()); + + let mut bumped_cost = original.clone(); + bumped_cost.kdf_params.t_cost += 1; + assert!(decrypt(&bumped_cost, "pw").is_err()); + + let mut flipped = original; + let mut ct = B64.decode(flipped.ciphertext_b64.as_bytes()).expect("decode"); + ct[0] ^= 0xff; + flipped.ciphertext_b64 = B64.encode(&ct); + assert!(decrypt(&flipped, "pw").is_err()); + } + + /// A hostile file must not be able to make us burn gigabytes of RAM on + /// Argon2 before the tag it cannot forge gets a chance to fail. + #[test] + fn an_absurd_kdf_cost_is_rejected_before_any_work_is_done() { + let mut payload = seal(b"payload", "pw"); + payload.kdf_params.m_cost = MAX_M_COST + 1; + assert!(decrypt(&payload, "pw").is_err()); + + let mut payload = seal(b"payload", "pw"); + payload.kdf_params.t_cost = MAX_T_COST + 1; + assert!(decrypt(&payload, "pw").is_err()); + } + + #[test] + fn a_newer_envelope_is_refused_rather_than_guessed_at() { + let mut payload = seal(b"payload", "pw"); + payload.codeg_config_encryption = ENVELOPE_VERSION + 1; + let err = decrypt(&payload, "pw").expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT) + ); + } + + #[test] + fn an_empty_passphrase_is_a_configuration_error_not_a_decrypt_failure() { + let payload = seal(b"payload", "pw"); + let err = decrypt(&payload, "").expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(crate::app_error::CONFIG_SYNC_I18N_KEY_PASSPHRASE_REQUIRED) + ); + assert!(encrypt(b"payload", "").is_err()); + } + + /// The expensive one, run once: the shipped defaults have to actually work + /// end to end, not just the cheap parameters the other tests use. + #[test] + fn the_shipped_parameters_round_trip() { + let bytes = encrypt(b"real defaults", "passphrase").expect("encrypt"); + let value: Value = serde_json::from_slice(&bytes).expect("json"); + assert!(is_encrypted_value(&value)); + let payload = parse_envelope(value).expect("parse"); + assert_eq!(payload.kdf_params.m_cost, DEFAULT_M_COST); + assert_eq!( + decrypt(&payload, "passphrase").expect("decrypt"), + b"real defaults" + ); + } +} diff --git a/src-tauri/src/commands/config_sync/domains.rs b/src-tauri/src/commands/config_sync/domains.rs index b31f3da55f..57fa86e68b 100644 --- a/src-tauri/src/commands/config_sync/domains.rs +++ b/src-tauri/src/commands/config_sync/domains.rs @@ -50,13 +50,24 @@ type ApplyFn = for<'a> fn( &'a DatabaseTransaction, &'a Value, ) -> BoxFuture<'a, Result>; +type ValidateFn = fn(&Value) -> Result<(), AppCommandError>; -/// One configuration domain: how it is read out of the local database and how -/// it is written back in. +/// One configuration domain: how it is read out of the local database, how it +/// is checked before anything is written, and how it is written back in. pub struct ConfigDomain { /// Stable snapshot key. Never rename: older snapshots are matched by it. pub id: &'static str, pub collect: CollectFn, + /// The decode half of [`Self::apply`], without the database. It exists so + /// a file can be REFUSED at preview time instead of failing halfway + /// through an apply: the envelope being well-formed JSON says nothing + /// about the domain payloads inside it, and a hand-edited export happily + /// previews "3 providers" and then aborts on the third. + /// + /// Each one runs the same `decode_rows::` call its applier opens with, + /// against the same DTO type; `validate_matches_apply` holds the two + /// together. + pub validate: ValidateFn, pub apply: ApplyFn, } @@ -68,35 +79,69 @@ pub const CONFIG_DOMAINS: &[ConfigDomain] = &[ ConfigDomain { id: DOMAIN_MODEL_PROVIDERS, collect: collect_model_providers, + validate: validate_model_providers, apply: apply_model_providers, }, ConfigDomain { id: DOMAIN_AGENT_SETTINGS, collect: collect_agent_settings, + validate: validate_agent_settings, apply: apply_agent_settings, }, ConfigDomain { id: DOMAIN_CUSTOM_AGENTS, collect: collect_custom_agents, + validate: validate_custom_agents, apply: apply_custom_agents, }, ConfigDomain { id: DOMAIN_QUICK_MESSAGES, collect: collect_quick_messages, + validate: validate_quick_messages, apply: apply_quick_messages, }, ConfigDomain { id: DOMAIN_TASK_TEMPLATES, collect: collect_task_templates, + validate: validate_task_templates, apply: apply_task_templates, }, ConfigDomain { id: DOMAIN_PREFERENCES, collect: collect_preferences, + validate: validate_preferences, apply: apply_preferences, }, ]; +fn validate_model_providers(value: &Value) -> Result<(), AppCommandError> { + decode_rows::(DOMAIN_MODEL_PROVIDERS, value).map(drop) +} + +fn validate_agent_settings(value: &Value) -> Result<(), AppCommandError> { + decode_rows::(DOMAIN_AGENT_SETTINGS, value).map(drop) +} + +fn validate_custom_agents(value: &Value) -> Result<(), AppCommandError> { + decode_rows::(DOMAIN_CUSTOM_AGENTS, value).map(drop) +} + +fn validate_quick_messages(value: &Value) -> Result<(), AppCommandError> { + decode_rows::(DOMAIN_QUICK_MESSAGES, value).map(drop) +} + +fn validate_task_templates(value: &Value) -> Result<(), AppCommandError> { + decode_rows::(DOMAIN_TASK_TEMPLATES, value).map(drop) +} + +/// `preferences` has no shape to decode: the applier walks whatever object it +/// is handed, skips non-portable keys and non-string values, and treats a +/// non-object as carrying nothing. Anything this accepts, the applier accepts +/// too — which is precisely the agreement the pair has to keep. +fn validate_preferences(_value: &Value) -> Result<(), AppCommandError> { + Ok(()) +} + /// How many entries a collected domain value holds — array length for row /// domains, key count for `preferences`. Used for the manifest's `counts` and /// for the import confirmation dialog. @@ -715,4 +760,63 @@ mod tests { assert_eq!(count_entries(&serde_json::json!({ "a": "b" })), 1); assert_eq!(count_entries(&Value::Null), 0); } + + /// The whole point of `validate` is that it answers the same question the + /// applier would, one step earlier. If the two ever disagree, the preview + /// is lying: either it waves through a payload that aborts the apply, or it + /// refuses a file that would have applied fine. + /// + /// Run against a real (in-memory) database so `apply` is the actual + /// applier, not a stand-in — the drift this guards against is exactly a + /// `validate` that stopped tracking its applier's DTO. + #[tokio::test] + async fn validate_matches_apply_on_every_domain() { + use sea_orm::TransactionTrait; + + // Shapes a hand-edited snapshot plausibly ends up with: the wrong + // container, the right container with the wrong element type, and a + // row missing a field the DTO requires. + let payloads = [ + serde_json::json!("not a list"), + serde_json::json!(42), + serde_json::json!({ "registryId": "acme" }), + serde_json::json!([1, 2, 3]), + serde_json::json!([{ "unexpected": true }]), + serde_json::json!([]), + Value::Null, + ]; + + let db = crate::db::test_helpers::fresh_in_memory_db().await; + for domain in CONFIG_DOMAINS { + for payload in &payloads { + let validated = (domain.validate)(payload).is_ok(); + // Each probe gets its own transaction, rolled back either way: + // a payload that applies must not leave rows behind for the + // next probe to trip over. + let tx = db.conn.begin().await.expect("begin"); + let applied = (domain.apply)(&tx, payload).await.is_ok(); + tx.rollback().await.expect("rollback"); + assert_eq!( + validated, applied, + "domain '{}' disagrees with itself on {payload}", + domain.id + ); + } + } + } + + /// And the pair is not vacuously in agreement: at least one of those + /// payloads must actually be refused, or a `validate` stubbed out to + /// `Ok(())` everywhere would pass the test above. + #[test] + fn a_malformed_row_domain_is_refused() { + for domain in CONFIG_DOMAINS { + if domain.id == DOMAIN_PREFERENCES { + continue; + } + let err = (domain.validate)(&serde_json::json!("not a list")) + .expect_err("a row domain must refuse a bare string"); + assert!(err.message.contains(domain.id), "{}", err.message); + } + } } diff --git a/src-tauri/src/commands/config_sync/local_io.rs b/src-tauri/src/commands/config_sync/local_io.rs index 7e31a25b0b..6fb525b5f0 100644 --- a/src-tauri/src/commands/config_sync/local_io.rs +++ b/src-tauri/src/commands/config_sync/local_io.rs @@ -9,13 +9,20 @@ //! //! Import also accepts a bare `config.json` — the exact file the sync writes //! to WebDAV — so a user who fetches one out of their cloud drive's web UI can -//! feed it straight back in. +//! feed it straight back in, encrypted or not. //! //! The checksum is NOT enforced on import. It exists to catch a truncated //! upload, which cannot happen to a local file the OS handed us whole; holding //! a hand-edited export to a byte-exact hash would only punish the user for -//! reformatting their own file. Schema version, structure, and the preference -//! allowlist are still enforced. +//! reformatting their own file. Schema version, structure, domain payloads, and +//! the preference allowlist are still enforced. +//! +//! Both halves come in a by-path and a by-content flavour. The desktop picks +//! paths with a native dialog and lets the backend do the I/O; a browser has no +//! path to hand over, so it posts the bytes instead. A config snapshot is tens +//! of KB, so the second flavour costs one copy in memory — which is why this +//! feature does not need the upload-staging machinery `backup` uses for +//! archives measured in gigabytes. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -23,15 +30,24 @@ use std::path::{Path, PathBuf}; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; +use super::credentials::{self, SNAPSHOT_PASSPHRASE}; +use super::crypto; use super::snapshot::{ - apply_snapshot_core, build_manifest, collect_snapshot_core, rollback_dir, serialize_snapshot, - write_rollback_snapshot, ApplyReport, ConfigManifest, ConfigSnapshot, + apply_snapshot_core, build_manifest, collect_snapshot_core, read_rollback, resolve_rollback, + rollback_dir, serialize_snapshot, write_rollback_snapshot, ApplyReport, ConfigManifest, + ConfigSnapshot, ENCRYPTION_NONE, }; use crate::app_error::{AppCommandError, CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT}; /// Marker + version of the single-file export envelope. pub const EXPORT_FORMAT_VERSION: u32 = 1; +/// Hard ceiling on a posted import, so a browser (or anything else speaking to +/// the HTTP API) cannot hand the parser an unbounded body. Two orders of +/// magnitude above any real snapshot, and the same bound the WebDAV client +/// applies to a download. +pub const MAX_IMPORT_BYTES: usize = 8 * 1024 * 1024; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConfigExportFile { @@ -73,7 +89,7 @@ pub async fn build_export_core( ) -> Result { let snapshot = collect_snapshot_core(conn).await?; let bytes = serialize_snapshot(&snapshot)?; - let manifest = build_manifest(&bytes, app_version, snapshot.counts()); + let manifest = build_manifest(&bytes, app_version, snapshot.counts(), ENCRYPTION_NONE); Ok(ConfigExportFile { codeg_config_export: EXPORT_FORMAT_VERSION, manifest, @@ -81,6 +97,31 @@ pub async fn build_export_core( }) } +/// The export as text, for a caller with somewhere other than a local path to +/// put it — a browser saves it with a `Blob` download. Byte-for-byte the same +/// document [`export_to_file_core`] writes, so the two runtimes produce +/// interchangeable files. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigExportContent { + pub content: String, + pub counts: BTreeMap, +} + +pub async fn export_content_core( + conn: &DatabaseConnection, + app_version: &str, +) -> Result { + let export = build_export_core(conn, app_version).await?; + let content = serde_json::to_string_pretty(&export).map_err(|e| { + AppCommandError::task_execution_failed("Serialize config export").with_detail(e.to_string()) + })?; + Ok(ConfigExportContent { + counts: export.config.counts(), + content, + }) +} + pub async fn export_to_file_core( conn: &DatabaseConnection, app_version: &str, @@ -90,6 +131,7 @@ pub async fn export_to_file_core( let bytes = serde_json::to_vec_pretty(&export).map_err(|e| { AppCommandError::task_execution_failed("Serialize config export").with_detail(e.to_string()) })?; + let counts = export.config.counts(); if let Some(parent) = dest.parent() { if !parent.as_os_str().is_empty() { @@ -100,13 +142,27 @@ pub async fn export_to_file_core( Ok(ConfigExportSummary { path: dest.to_string_lossy().to_string(), - counts: export.config.counts(), + counts, }) } -/// Accepts either envelope shape. A bare `config.json` gets a synthesized -/// manifest so the preview dialog has something to show. -pub fn parse_export_bytes(bytes: &[u8]) -> Result { +/// Accepts all three shapes a user can plausibly present: the export envelope, +/// a bare `config.json` lifted off the remote, and an encrypted one. The bare +/// forms get a synthesized manifest so the preview dialog has something to +/// show. +/// +/// `passphrase` is only consulted for the encrypted shape, and an empty one +/// there is reported as "configure a passphrase", not as "this file is junk" — +/// the file is fine, this machine just cannot read it yet. +pub fn parse_export_bytes( + bytes: &[u8], + passphrase: &str, +) -> Result { + if bytes.len() > MAX_IMPORT_BYTES { + return Err(AppCommandError::invalid_input("Config file is too large") + .with_i18n(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, BTreeMap::new())); + } + let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| { AppCommandError::invalid_input("Not a codeg config file") .with_detail(e.to_string()) @@ -119,16 +175,29 @@ pub fn parse_export_bytes(bytes: &[u8]) -> Result Result { + let snapshot = super::snapshot::parse_snapshot(snapshot_bytes)?; + let canonical = serialize_snapshot(&snapshot)?; + let manifest = build_manifest(&canonical, "unknown", snapshot.counts(), ENCRYPTION_NONE); Ok(ConfigExportFile { codeg_config_export: EXPORT_FORMAT_VERSION, manifest, @@ -136,15 +205,30 @@ pub fn parse_export_bytes(bytes: &[u8]) -> Result String { + credentials::load(SNAPSHOT_PASSPHRASE) +} + pub fn read_export_file(path: &Path) -> Result { let bytes = std::fs::read(path).map_err(AppCommandError::io)?; - parse_export_bytes(&bytes) + parse_export_bytes(&bytes, &stored_passphrase()) } /// Read and validate without touching the database — what the UI calls to /// populate "this file contains N providers, M agents…". pub fn peek_import_core(path: &Path) -> Result { - let export = read_export_file(path)?; + preview_of(read_export_file(path)?) +} + +/// Same, for a caller that already has the bytes (the web import posts them). +pub fn peek_import_bytes_core(bytes: &[u8]) -> Result { + preview_of(parse_export_bytes(bytes, &stored_passphrase())?) +} + +fn preview_of(export: ConfigExportFile) -> Result { Ok(ConfigImportPreview { counts: export.config.counts(), manifest: export.manifest, @@ -158,14 +242,59 @@ pub async fn import_from_file_core( // Parse before writing the rollback snapshot: a malformed file should cost // the user nothing at all. let export = read_export_file(path)?; + apply_import(conn, &export.config).await +} + +pub async fn import_bytes_core( + conn: &DatabaseConnection, + bytes: &[u8], +) -> Result { + let export = parse_export_bytes(bytes, &stored_passphrase())?; + apply_import(conn, &export.config).await +} + +async fn apply_import( + conn: &DatabaseConnection, + snapshot: &ConfigSnapshot, +) -> Result { + // Hold the uploader off for the duration: applying rewrites local + // configuration row by row, and a tick landing in the middle would push a + // half-merged state to the remote as if it were a state the user chose. + // The upload the import DOES deserve happens on the next tick, once the + // configuration is whole again. + let _suppression = super::auto_sync::suppress_auto_sync(); let rollback_path = save_rollback(conn).await; - let applied = apply_snapshot_core(conn, &export.config).await?; + let applied = apply_snapshot_core(conn, snapshot).await?; Ok(ConfigImportResult { applied, rollback_path, }) } +/// Undo an import or a restore by re-applying the snapshot taken just before +/// it. Writes its own rollback point first, so the undo is itself undoable — +/// a user who rolls back to the wrong one is not out of options. +/// +/// `dir` is a parameter rather than a call to [`rollback_dir`] for the same +/// reason [`write_rollback_snapshot`] takes one: the command layer supplies the +/// real directory, and a test supplies a temporary one instead of writing into +/// the developer's `~/.codeg`. +pub async fn apply_rollback_core( + conn: &DatabaseConnection, + dir: &Path, + id: &str, +) -> Result { + let path = resolve_rollback(dir, id)?; + let snapshot = read_rollback(&path)?; + apply_import(conn, &snapshot).await +} + +/// Newest first. Empty — never an error — when nothing has ever been imported: +/// the directory simply does not exist yet. +pub fn list_rollbacks_core(dir: &Path) -> Vec { + super::snapshot::list_rollback_infos(dir) +} + /// Capture "what this machine looked like before" so a surprising import is /// undoable. Best effort by design — see [`ConfigImportResult::rollback_path`]. pub async fn save_rollback(conn: &DatabaseConnection) -> Option { @@ -251,12 +380,96 @@ mod tests { let snapshot = collect_snapshot_core(&source.conn).await.expect("collect"); let bytes = serialize_snapshot(&snapshot).expect("bytes"); - let export = parse_export_bytes(&bytes).expect("parse bare"); + let export = parse_export_bytes(&bytes, "").expect("parse bare"); assert_eq!(export.config.schema_version, SCHEMA_VERSION); assert_eq!(export.manifest.app_version, "unknown"); assert_eq!(export.config.counts().get("quickMessages"), Some(&1)); } + /// The encrypted `config.json` the sync uploads has to come back in through + /// the same door: a user who downloads it from their cloud drive's web UI + /// should not be told their own file is not a codeg config. + #[tokio::test] + async fn an_encrypted_remote_config_json_is_accepted_with_the_stored_passphrase() { + let _guard = credentials::test_guard(); + let source = fresh_in_memory_db().await; + seed_message(&source.conn, "Sealed").await; + let snapshot = collect_snapshot_core(&source.conn).await.expect("collect"); + let sealed = crypto::encrypt(&serialize_snapshot(&snapshot).expect("bytes"), "hunter2") + .expect("encrypt"); + + // Nothing configured yet: the file is readable, this machine is not + // equipped to read it, and the message has to say which. + credentials::store(SNAPSHOT_PASSPHRASE, "").expect("clear"); + let err = parse_export_bytes(&sealed, &stored_passphrase()).expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(crate::app_error::CONFIG_SYNC_I18N_KEY_PASSPHRASE_REQUIRED) + ); + + credentials::store(SNAPSHOT_PASSPHRASE, "wrong").expect("store"); + let err = parse_export_bytes(&sealed, &stored_passphrase()).expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(crate::app_error::CONFIG_SYNC_I18N_KEY_BAD_PASSPHRASE) + ); + + credentials::store(SNAPSHOT_PASSPHRASE, "hunter2").expect("store"); + let preview = peek_import_bytes_core(&sealed).expect("peek"); + assert_eq!(preview.counts.get("quickMessages"), Some(&1)); + + let target = fresh_in_memory_db().await; + let result = import_bytes_core(&target.conn, &sealed).await.expect("import"); + assert!(result.applied.total >= 1); + credentials::store(SNAPSHOT_PASSPHRASE, "").expect("clear"); + } + + /// Regression: only the envelope was checked, so a file whose domain + /// payload was nonsense previewed cleanly ("1 quick message") and then + /// aborted the apply — after the rollback snapshot had been written and + /// with the user told an import was starting. + #[tokio::test] + async fn a_file_that_would_abort_mid_apply_never_reaches_the_preview() { + let bytes = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": SCHEMA_VERSION, + "domains": { + "quickMessages": [{ "title": "fine", "content": "body" }], + "customAgents": [{ "registryId": "acme" }] + } + })) + .expect("bytes"); + + let err = peek_import_bytes_core(&bytes).expect_err("must refuse at preview"); + assert_eq!( + err.i18n_key.as_deref(), + Some(crate::app_error::CONFIG_SYNC_I18N_KEY_BAD_DOMAIN) + ); + + // And nothing is written when the import is attempted anyway. + let target = fresh_in_memory_db().await; + import_bytes_core(&target.conn, &bytes) + .await + .expect_err("must refuse"); + assert_eq!( + quick_message::Entity::find() + .all(&target.conn) + .await + .expect("messages") + .len(), + 0 + ); + } + + #[tokio::test] + async fn an_oversized_payload_is_refused_before_it_is_parsed() { + let huge = vec![b'x'; MAX_IMPORT_BYTES + 1]; + let err = parse_export_bytes(&huge, "").expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT) + ); + } + /// A reformatted export (different indentation, reordered keys) must still /// import: the checksum guards transfers, not the user's text editor. #[tokio::test] @@ -284,6 +497,49 @@ mod tests { ); } + /// The safety net has to be reachable, not merely written: a snapshot taken + /// before an import must be listable by id and applicable by that id, or + /// the `rollbackPath` an import returns is a file the product cannot open. + #[tokio::test] + async fn a_rollback_snapshot_can_be_listed_and_applied_back() { + let dir = tempfile::tempdir().expect("tempdir"); + + let before = fresh_in_memory_db().await; + seed_message(&before.conn, "Original").await; + let snapshot = collect_snapshot_core(&before.conn).await.expect("collect"); + write_rollback_snapshot(dir.path(), &snapshot).expect("write rollback"); + + let listed = list_rollbacks_core(dir.path()); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].counts.get("quickMessages"), Some(&1)); + + // A machine that has since been overwritten with someone else's config. + let target = fresh_in_memory_db().await; + seed_message(&target.conn, "Imported").await; + + let result = apply_rollback_core(&target.conn, dir.path(), &listed[0].id) + .await + .expect("apply rollback"); + assert!(result.applied.total >= 1); + + let titles: Vec = quick_message::Entity::find() + .all(&target.conn) + .await + .expect("messages") + .into_iter() + .map(|m| m.title) + .collect(); + assert!(titles.contains(&"Original".to_string()), "{titles:?}"); + + // An id that is not on this machine is a plain "gone", not a panic and + // not a path. + assert!( + apply_rollback_core(&target.conn, dir.path(), "../../etc/passwd") + .await + .is_err() + ); + } + #[tokio::test] async fn junk_files_are_rejected_before_anything_is_touched() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/commands/config_sync/mod.rs b/src-tauri/src/commands/config_sync/mod.rs index a5157c1abd..b536b4ded5 100644 --- a/src-tauri/src/commands/config_sync/mod.rs +++ b/src-tauri/src/commands/config_sync/mod.rs @@ -29,22 +29,35 @@ //! machine can never overwrite another machine's credentials, which is what //! would turn two clients into a sync loop. //! +//! **Secrets stay out of the snapshot AND out of the settings row.** The +//! WebDAV password and the optional snapshot passphrase live in the OS keyring +//! (the `0600` token store on a server) — see [`credentials`]. A settings row +//! is plaintext in the SQLite file, and that file is inside every backup +//! archive; neither credential is portable, so neither belongs there. +//! //! Layering mirrors the backup engine: `*_core` functions take plain //! references (`&DatabaseConnection`, `&EventEmitter`) so desktop commands, -//! the (future) Axum handlers, and the background scheduler share one -//! implementation. +//! the Axum handlers in `web::handlers::config_sync`, and the background +//! scheduler share one implementation. pub mod auto_sync; +pub mod credentials; +pub mod crypto; pub mod domains; pub mod local_io; pub mod portable_keys; pub mod snapshot; pub mod webdav_sync; +/// Shared by the Tauri commands and the HTTP handlers, so "which version +/// wrote this snapshot" is the same answer in both runtimes. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + // ─── Desktop Tauri commands ────────────────────────────────────────────── // // Thin wrappers only. The frontend picks paths with the native file dialog -// and passes them in, exactly as the backup commands do. +// and passes them in, exactly as the backup commands do — except in a browser, +// which has no path to pass and posts the bytes instead (`*_content`). #[cfg(feature = "tauri-runtime")] mod tauri_commands { @@ -56,17 +69,18 @@ mod tauri_commands { use crate::db::AppDatabase; use super::local_io::{ - export_to_file_core, import_from_file_core, peek_import_core, ConfigExportSummary, - ConfigImportPreview, ConfigImportResult, + apply_rollback_core, export_content_core, export_to_file_core, import_bytes_core, + import_from_file_core, list_rollbacks_core, peek_import_bytes_core, peek_import_core, + ConfigExportContent, ConfigExportSummary, ConfigImportPreview, ConfigImportResult, }; - use super::snapshot::ConfigManifest; + use super::snapshot::{rollback_dir, ConfigManifest, RollbackSnapshotInfo}; use super::webdav_sync::{ download_and_apply_core, load_settings, load_state, merge_settings, peek_remote_core, save_settings_core, test_connection_core, upload_snapshot_core, ConfigSyncSettingsInput, ConfigSyncSettingsView, ConfigSyncState, DownloadOutcome, UploadOutcome, }; - const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + use super::APP_VERSION; #[tauri::command] pub async fn config_sync_export_file( @@ -153,6 +167,53 @@ mod tauri_commands { ) -> Result { download_and_apply_core(&db.conn).await } + + // ── By-content variants ── + // + // A Tauri window connected to a REMOTE codeg server routes these over + // HTTP, where there is no shared filesystem to name a path on. Registering + // them on the desktop too keeps one frontend code path for both. + + #[tauri::command] + pub async fn config_sync_export_content( + db: State<'_, AppDatabase>, + ) -> Result { + export_content_core(&db.conn, APP_VERSION).await + } + + #[tauri::command] + pub async fn config_sync_peek_content( + content: String, + ) -> Result { + peek_import_bytes_core(content.as_bytes()) + } + + #[tauri::command] + pub async fn config_sync_import_content( + content: String, + db: State<'_, AppDatabase>, + ) -> Result { + import_bytes_core(&db.conn, content.as_bytes()).await + } + + // ── Rollback snapshots ── + // + // Every import and every restore writes one first. Without these two they + // were a safety net that existed on disk and nowhere in the product. + + #[tauri::command] + pub async fn config_sync_list_rollbacks( + ) -> Result, AppCommandError> { + Ok(list_rollbacks_core(&rollback_dir())) + } + + #[tauri::command] + pub async fn config_sync_apply_rollback( + id: String, + db: State<'_, AppDatabase>, + ) -> Result { + apply_rollback_core(&db.conn, &rollback_dir(), &id).await + } } #[cfg(feature = "tauri-runtime")] diff --git a/src-tauri/src/commands/config_sync/snapshot.rs b/src-tauri/src/commands/config_sync/snapshot.rs index e906d39a49..b17b4f2e11 100644 --- a/src-tauri/src/commands/config_sync/snapshot.rs +++ b/src-tauri/src/commands/config_sync/snapshot.rs @@ -27,8 +27,9 @@ use sha2::{Digest, Sha256}; use super::domains::{count_entries, CONFIG_DOMAINS}; use crate::app_error::{ - AppCommandError, CONFIG_SYNC_I18N_KEY_CHECKSUM, CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, - CONFIG_SYNC_I18N_KEY_NEWER_SCHEMA, + AppCommandError, CONFIG_SYNC_I18N_KEY_BAD_DOMAIN, CONFIG_SYNC_I18N_KEY_CHECKSUM, + CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, CONFIG_SYNC_I18N_KEY_NEWER_SCHEMA, + CONFIG_SYNC_I18N_KEY_NO_ROLLBACK, }; /// Bump only for a change older binaries cannot read. Adding a domain does not @@ -36,11 +37,14 @@ use crate::app_error::{ /// empty, so both directions already degrade gracefully. pub const SCHEMA_VERSION: u32 = 1; -/// v1 uploads plaintext. The security boundary is the user's own -/// self-authenticated WebDAV endpoint; the field exists so a future encrypted -/// format is a value change rather than a format break, and so today's reader -/// refuses a file it cannot decrypt instead of misparsing it. +/// The default. The security boundary is then the user's own +/// self-authenticated WebDAV endpoint, which is a real one for a self-hosted +/// share and a weaker one on a hosted drive — hence the opt-in below. pub const ENCRYPTION_NONE: &str = "none"; +/// Opt-in passphrase encryption ([`super::crypto`]). The manifest stays +/// plaintext either way, so "whose copy is on the remote, from when" is +/// readable without the passphrase; only `config.json` is wrapped. +pub const ENCRYPTION_AES_GCM: &str = "aes-256-gcm"; pub const CONFIG_FILE_NAME: &str = "config.json"; pub const MANIFEST_FILE_NAME: &str = "manifest.json"; @@ -176,9 +180,36 @@ pub fn parse_snapshot(bytes: &[u8]) -> Result { .with_i18n(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, BTreeMap::new()) })?; reject_newer_schema(snapshot.schema_version)?; + validate_domains(&snapshot)?; Ok(snapshot) } +/// Dry-decode every domain the snapshot carries, without a database. +/// +/// Called from [`parse_snapshot`], which is the single door every snapshot +/// enters through — file import, WebDAV download, and rollback alike — so a +/// payload that would abort halfway through an apply is refused before the +/// preview is even drawn. Without it the envelope's JSON being well-formed was +/// the only thing checked, and a hand-edited file could confirm "3 providers, +/// 2 agents" and then fail on the agents with the providers already written. +/// +/// Domains this binary does not know are skipped, matching +/// [`apply_snapshot_core`]: a snapshot from a newer build stays partially +/// usable, and refusing it here would be stricter than the apply it guards. +pub fn validate_domains(snapshot: &ConfigSnapshot) -> Result<(), AppCommandError> { + for domain in CONFIG_DOMAINS { + let Some(value) = snapshot.domains.get(domain.id) else { + continue; + }; + (domain.validate)(value).map_err(|err| { + let mut params = BTreeMap::new(); + params.insert("domain".to_string(), domain.id.to_string()); + err.with_i18n(CONFIG_SYNC_I18N_KEY_BAD_DOMAIN, params) + })?; + } + Ok(()) +} + pub fn parse_manifest(bytes: &[u8]) -> Result { serde_json::from_slice(bytes).map_err(|e| { AppCommandError::invalid_input("Not a codeg config manifest") @@ -187,14 +218,18 @@ pub fn parse_manifest(bytes: &[u8]) -> Result { }) } +/// `config_bytes` must be the bytes that are actually written out — the +/// ciphertext when `encryption` is not [`ENCRYPTION_NONE`]. The checksum's job +/// is detecting a truncated transfer, so it has to cover what was transferred. pub fn build_manifest( config_bytes: &[u8], app_version: &str, counts: BTreeMap, + encryption: &str, ) -> ConfigManifest { ConfigManifest { schema_version: SCHEMA_VERSION, - encryption: ENCRYPTION_NONE.to_string(), + encryption: encryption.to_string(), created_at: Utc::now().to_rfc3339(), app_version: app_version.to_string(), source_device: source_device(), @@ -215,7 +250,10 @@ pub fn validate_manifest( ) -> Result<(), AppCommandError> { reject_newer_schema(manifest.schema_version)?; - if manifest.encryption != ENCRYPTION_NONE { + if !matches!( + manifest.encryption.as_str(), + ENCRYPTION_NONE | ENCRYPTION_AES_GCM + ) { return Err(AppCommandError::invalid_input(format!( "Unsupported snapshot encryption '{}'", manifest.encryption @@ -380,6 +418,98 @@ fn prune_rollback_snapshots(dir: &Path, keep: usize) { } } +/// One rollback snapshot, as the settings panel lists it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RollbackSnapshotInfo { + /// The file stem (`config-20260917T101530123`). Opaque to the frontend and + /// the only value it may hand back — [`resolve_rollback`] refuses anything + /// that is not exactly this shape, so the id can never become a path. + pub id: String, + /// RFC 3339, recovered from the id. `None` for a file whose name does not + /// carry a parseable stamp; the UI falls back to the id itself. + pub created_at: Option, + pub size: u64, + /// What applying it would write, recounted from the payload. + pub counts: BTreeMap, +} + +/// Newest first, skipping any file that no longer parses — a snapshot that +/// cannot be read cannot be applied either, and listing it would only offer the +/// user a button that fails. +pub fn list_rollback_infos(dir: &Path) -> Vec { + let mut infos = Vec::new(); + for path in list_rollback_snapshots(dir) { + let Some(id) = rollback_id(&path) else { + continue; + }; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(err) => { + tracing::warn!("[CONFIG-SYNC] rollback snapshot unreadable: {err}"); + continue; + } + }; + let Ok(snapshot) = parse_snapshot(&bytes) else { + tracing::warn!("[CONFIG-SYNC] rollback snapshot does not parse: {}", id); + continue; + }; + infos.push(RollbackSnapshotInfo { + created_at: created_at_from_id(&id), + size: bytes.len() as u64, + counts: snapshot.counts(), + id, + }); + } + infos +} + +fn rollback_id(path: &Path) -> Option { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| stem.to_string()) +} + +/// The names [`write_rollback_snapshot`] produces are `config-` plus a UTC +/// `%Y%m%dT%H%M%S%3f` stamp, so the timestamp is recoverable without trusting +/// the filesystem's mtime (which a copy or a restore would rewrite). +fn created_at_from_id(id: &str) -> Option { + let stamp = id.strip_prefix("config-")?; + chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%dT%H%M%S%3f") + .ok() + .map(|naive| naive.and_utc().to_rfc3339()) +} + +/// Map an id from [`list_rollback_infos`] back to its file. +/// +/// The id arrives from the frontend, so it is validated rather than trusted: +/// only the exact alphabet [`write_rollback_snapshot`] emits is accepted, which +/// leaves no way to express a separator, a parent link, or an extension and so +/// no way for the join below to leave `dir`. +pub fn resolve_rollback(dir: &Path, id: &str) -> Result { + let looks_like_ours = id + .strip_prefix("config-") + .is_some_and(|stamp| !stamp.is_empty() && stamp.bytes().all(|b| b.is_ascii_alphanumeric())); + if !looks_like_ours { + return Err(missing_rollback_error()); + } + let path = dir.join(format!("{id}.json")); + if !path.is_file() { + return Err(missing_rollback_error()); + } + Ok(path) +} + +fn missing_rollback_error() -> AppCommandError { + AppCommandError::not_found("That rollback snapshot is no longer on this machine") + .with_i18n(CONFIG_SYNC_I18N_KEY_NO_ROLLBACK, BTreeMap::new()) +} + +pub fn read_rollback(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(AppCommandError::io)?; + parse_snapshot(&bytes) +} + #[cfg(test)] mod tests { use super::*; @@ -670,7 +800,7 @@ mod tests { domains: BTreeMap::from([("preferences".to_string(), serde_json::json!({}))]), }; let bytes = serialize_snapshot(&snapshot).expect("serialize"); - let manifest = build_manifest(&bytes, "1.0.0", snapshot.counts()); + let manifest = build_manifest(&bytes, "1.0.0", snapshot.counts(), ENCRYPTION_NONE); validate_manifest(&manifest, &bytes).expect("matching bytes validate"); @@ -749,6 +879,125 @@ mod tests { ); } + /// Regression: the envelope parsing alone let a payload through that the + /// applier would abort on, so the confirmation dialog promised rows it + /// could not write. The refusal has to happen at parse time, which is the + /// step both the file import and the WebDAV download go through. + #[test] + fn a_domain_that_would_abort_the_apply_is_refused_at_parse_time() { + let bytes = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": SCHEMA_VERSION, + "domains": { + "quickMessages": [{ "title": "fine", "content": "body" }], + "modelProviders": "hand-edited into nonsense" + } + })) + .expect("bytes"); + + let err = parse_snapshot(&bytes).expect_err("must refuse"); + assert_eq!(err.i18n_key.as_deref(), Some(CONFIG_SYNC_I18N_KEY_BAD_DOMAIN)); + assert_eq!( + err.i18n_params + .as_ref() + .and_then(|params| params.get("domain")) + .map(String::as_str), + Some("modelProviders") + ); + } + + /// Forward compatibility is not sacrificed to the check above: a domain + /// this binary has never heard of is ignored by the applier, so refusing it + /// here would be stricter than the apply it guards. + #[test] + fn an_unknown_domain_is_not_what_the_dry_decode_is_for() { + let bytes = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": SCHEMA_VERSION, + "domains": { "somethingFromANewerBuild": "whatever shape it likes" } + })) + .expect("bytes"); + parse_snapshot(&bytes).expect("an unknown domain must not block the import"); + } + + #[test] + fn an_encrypted_manifest_is_a_known_format_now() { + let snapshot = ConfigSnapshot { + schema_version: SCHEMA_VERSION, + domains: BTreeMap::new(), + }; + let payload = b"ciphertext-stand-in"; + let manifest = build_manifest(payload, "1.0.0", snapshot.counts(), ENCRYPTION_AES_GCM); + // The checksum covers what is transferred, i.e. the ciphertext. + validate_manifest(&manifest, payload).expect("encrypted manifests validate"); + + let unknown = ConfigManifest { + encryption: "rot13".to_string(), + ..manifest + }; + let err = validate_manifest(&unknown, payload).expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT) + ); + } + + #[test] + fn a_rollback_id_can_never_become_a_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let snapshot = ConfigSnapshot { + schema_version: SCHEMA_VERSION, + domains: BTreeMap::new(), + }; + let written = write_rollback_snapshot(dir.path(), &snapshot).expect("write"); + let id = rollback_id(&written).expect("id"); + assert_eq!(resolve_rollback(dir.path(), &id).expect("resolve"), written); + + // Everything a traversal needs to express itself is outside the + // accepted alphabet. + for hostile in [ + "../../etc/passwd", + "config-../../etc/passwd", + "config-a/b", + "config-a.b", + "config-", + "passwd", + "", + ] { + let err = resolve_rollback(dir.path(), hostile).expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(CONFIG_SYNC_I18N_KEY_NO_ROLLBACK) + ); + } + + // A well-formed id for a file that was pruned is the same "gone". + assert!(resolve_rollback(dir.path(), "config-19700101T000000000").is_err()); + } + + #[tokio::test] + async fn the_rollback_list_describes_what_applying_one_would_write() { + let source = fresh_in_memory_db().await; + seed_source(&source.conn).await; + let snapshot = collect_snapshot_core(&source.conn).await.expect("collect"); + + let dir = tempfile::tempdir().expect("tempdir"); + write_rollback_snapshot(dir.path(), &snapshot).expect("write"); + // Unreadable junk alongside it must not take the list down with it. + std::fs::write(dir.path().join("config-20240101T000000000.json"), b"{oops") + .expect("write junk"); + + let infos = list_rollback_infos(dir.path()); + assert_eq!(infos.len(), 1, "the unparseable file must be skipped"); + assert_eq!(infos[0].counts.get("quickMessages"), Some(&1)); + assert!(infos[0].size > 0); + assert!(infos[0].created_at.is_some(), "the id carries its stamp"); + assert_eq!( + read_rollback(&resolve_rollback(dir.path(), &infos[0].id).expect("resolve")) + .expect("read") + .counts(), + snapshot.counts() + ); + } + #[test] fn rollback_snapshots_are_pruned_newest_first() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/commands/config_sync/webdav_sync.rs b/src-tauri/src/commands/config_sync/webdav_sync.rs index 5fe1b8b168..b4cc3b4a2b 100644 --- a/src-tauri/src/commands/config_sync/webdav_sync.rs +++ b/src-tauri/src/commands/config_sync/webdav_sync.rs @@ -23,18 +23,23 @@ use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; +use super::credentials::{self, SNAPSHOT_PASSPHRASE, WEBDAV_PASSWORD}; +use super::crypto; use super::snapshot::{ apply_snapshot_core, build_manifest, collect_snapshot_core, parse_manifest, parse_snapshot, serialize_snapshot, sha256_hex, validate_manifest, ApplyReport, ConfigManifest, - CONFIG_FILE_NAME, MANIFEST_FILE_NAME, + CONFIG_FILE_NAME, ENCRYPTION_AES_GCM, ENCRYPTION_NONE, MANIFEST_FILE_NAME, }; use crate::app_error::{AppCommandError, CONFIG_SYNC_I18N_KEY_NO_REMOTE}; use crate::db::service::app_metadata_service; use crate::network::webdav::{sanitize_path_segment, WebdavClient}; -/// Credentials of this feature. NOT in `portable_keys`: if it travelled, one +/// Settings of this feature. NOT in `portable_keys`: if they travelled, one /// machine's credentials would overwrite the other's and the two would sync /// into each other in a loop. +/// +/// The two secrets live in the keyring ([`super::credentials`]), not in this +/// row; what is left here is addresses and switches. pub const CONFIG_SYNC_SETTINGS_KEY: &str = "config_sync_settings"; /// Last-uploaded hash and last result. Device-local by nature. pub const CONFIG_SYNC_STATE_KEY: &str = "config_sync_state"; @@ -55,9 +60,29 @@ pub struct ConfigSyncSettings { pub enabled: bool, pub server_url: String, pub username: String, - /// Stored as given. See the module docs of `mod.rs` for why the snapshot - /// itself stays unencrypted; this value never leaves the local database. + /// Never serialized into the settings row — it lives in the keyring, and + /// [`load_settings`] puts it here. `default` is what lets an OLD row, which + /// does still carry the password inline, deserialize so the value can be + /// migrated out of it. + #[serde(default, skip_serializing)] pub password: String, + /// Same treatment, for the optional snapshot passphrase. There is no legacy + /// form of this one: it never had a plaintext home. + #[serde(default, skip_serializing)] + pub passphrase: String, + /// Wrap `config.json` in [`super::crypto`]'s envelope before uploading. + /// Off by default — the plaintext boundary is the user's own authenticated + /// endpoint, which is a real boundary for a self-hosted share. + #[serde(default)] + pub encrypt: bool, + /// Opaque, non-secret, regenerated whenever the passphrase changes. It is + /// part of [`ConfigSyncSettings::remote_target`] so that re-keying (or + /// switching encryption on) makes the upload baseline stop matching and the + /// next tick re-uploads. A hash of the passphrase would do the same job and + /// would also park an offline-crackable digest of a user-chosen secret in + /// the database; a random id leaks nothing. + #[serde(default)] + pub passphrase_id: String, pub remote_dir: String, pub profile: String, pub auto_sync: bool, @@ -75,9 +100,25 @@ impl ConfigSyncSettings { !self.server_url.trim().is_empty() } + /// How the payload is protected, as a value the upload baseline can be + /// stamped with. Turning encryption on, or re-keying it, leaves the + /// PLAINTEXT snapshot byte-identical — so without this the hash would still + /// match, the tick would skip, and the remote would keep the copy written + /// under the old protection. For a re-key that is not merely stale: the + /// remote would be unreadable with the passphrase this machine now holds. + fn protection(&self) -> &str { + if self.encrypt { + // Empty only in the transient state "encryption on, passphrase not + // yet set", which `merge_settings` refuses to persist. + &self.passphrase_id + } else { + "plain" + } + } + /// The remote location this configuration points at, as a value that can /// be stored alongside the upload hash. Two settings that agree here write - /// the same two files. + /// the same two files, readable the same way. /// /// A NUL separator rather than a slash: every part is user-typed, and a /// `/` would let `{dir: "a/b", profile: "c"}` and `{dir: "a", profile: @@ -85,8 +126,11 @@ impl ConfigSyncSettings { /// today; the separator is what keeps that from becoming load-bearing.) fn remote_target(&self) -> String { format!( - "{}\u{0}{}\u{0}{}", - self.server_url, self.remote_dir, self.profile + "{}\u{0}{}\u{0}{}\u{0}{}", + self.server_url, + self.remote_dir, + self.profile, + self.protection() ) } } @@ -98,6 +142,9 @@ impl Default for ConfigSyncSettings { server_url: String::new(), username: String::new(), password: String::new(), + passphrase: String::new(), + encrypt: false, + passphrase_id: String::new(), remote_dir: DEFAULT_REMOTE_DIR.to_string(), profile: DEFAULT_PROFILE.to_string(), auto_sync: true, @@ -116,6 +163,10 @@ pub struct ConfigSyncSettingsView { pub server_url: String, pub username: String, pub has_password: bool, + pub encrypt: bool, + /// Same contract as `has_password`: the passphrase itself never crosses the + /// bridge, only whether one is on file. + pub has_passphrase: bool, pub remote_dir: String, pub profile: String, pub auto_sync: bool, @@ -129,6 +180,8 @@ impl From<&ConfigSyncSettings> for ConfigSyncSettingsView { server_url: settings.server_url.clone(), username: settings.username.clone(), has_password: !settings.password.is_empty(), + encrypt: settings.encrypt, + has_passphrase: !settings.passphrase.is_empty(), remote_dir: settings.remote_dir.clone(), profile: settings.profile.clone(), auto_sync: settings.auto_sync, @@ -153,6 +206,14 @@ pub struct ConfigSyncSettingsInput { pub username: String, #[serde(default)] pub password: Option, + /// Same "empty means keep what is stored" rule as the password. Unlike the + /// password it is NOT scoped to the account: it protects the snapshot, not + /// the connection, so moving the same configuration to a different host + /// does not orphan it. + #[serde(default)] + pub passphrase: Option, + #[serde(default)] + pub encrypt: bool, #[serde(default)] pub remote_dir: Option, #[serde(default)] @@ -209,7 +270,8 @@ fn remote_lock() -> &'static Mutex<()> { // ─── settings persistence ───────────────────────────────────────────── /// Never fails: a row this build cannot parse degrades to defaults (sync off) -/// rather than breaking the settings page. +/// rather than breaking the settings page. The secrets are read from the +/// keyring and grafted on; the row itself holds none. pub async fn load_settings(conn: &DatabaseConnection) -> ConfigSyncSettings { let raw = match app_metadata_service::get_value(conn, CONFIG_SYNC_SETTINGS_KEY).await { Ok(Some(raw)) => raw, @@ -219,12 +281,102 @@ pub async fn load_settings(conn: &DatabaseConnection) -> ConfigSyncSettings { return ConfigSyncSettings::default(); } }; - match serde_json::from_str::(&raw) { + let mut settings = match serde_json::from_str::(&raw) { Ok(settings) => settings, Err(err) => { tracing::warn!("[CONFIG-SYNC] failed to parse sync settings: {err}"); ConfigSyncSettings::default() } + }; + + // Whatever the row still carries is a pre-keyring leftover. + let legacy_password = std::mem::take(&mut settings.password); + settings.password = credentials::load(WEBDAV_PASSWORD); + settings.passphrase = credentials::load(SNAPSHOT_PASSPHRASE); + + // The trigger is "the ROW still holds a password", not "the keyring is + // empty". Those look equivalent and are not: if a previous migration + // stored the secret and then failed to rewrite the row, the keyring is + // populated while the plaintext is still sitting in `app_metadata` — and a + // keyring-empty test would skip the retry forever, leaving that copy in + // the database (and in every backup archive) for good. + if !legacy_password.is_empty() { + // The keyring copy wins where both exist; it is the one every later + // save writes to. The row copy is only a fallback for the very first + // migration, before anything has been stored. + if settings.password.is_empty() { + settings.password = legacy_password; + } + migrate_legacy_password(conn, &settings.password).await; + } + settings +} + +/// Move a password written by a build that kept it in the settings row into the +/// keyring, then strip the `password` key out of the row. +/// +/// Best effort in both directions: a keyring that cannot be written leaves the +/// row as it was and sync keeps working from it, because refusing to load +/// settings would break the feature outright over a storage upgrade. Either +/// half failing is retried on the next load — see the caller. +async fn migrate_legacy_password(conn: &DatabaseConnection, password: &str) { + if let Err(err) = credentials::store(WEBDAV_PASSWORD, password) { + tracing::warn!( + "[CONFIG-SYNC] keeping the password in the settings row: {}", + err.message + ); + return; + } + + // Re-read and remove one key, rather than re-serializing the struct this + // load parsed. Two reasons, and both are silent corruption otherwise: + // + // - A concurrent save may have landed in between. Writing the parsed + // struct back would revert it — putting the OLD server URL beside the + // NEW password the keyring just took, which is exactly the pairing + // `merge_settings` refuses to create on purpose. + // - A row written by a NEWER build carries fields this one does not know. + // Serializing our struct over it drops them for good. + // + // Removing a single key from whatever the row holds *now* can only ever + // take the plaintext out. + let raw = match app_metadata_service::get_value(conn, CONFIG_SYNC_SETTINGS_KEY).await { + Ok(Some(raw)) => raw, + Ok(None) => return, + Err(err) => { + tracing::warn!("[CONFIG-SYNC] failed to re-read sync settings: {err}"); + return; + } + }; + let mut value = match serde_json::from_str::(&raw) { + Ok(value) => value, + Err(err) => { + tracing::warn!("[CONFIG-SYNC] failed to parse sync settings: {err}"); + return; + } + }; + let Some(object) = value.as_object_mut() else { + return; + }; + if object.remove("password").is_none() { + // Someone else already erased it; nothing to write. + return; + } + + match serde_json::to_string(&value) { + Ok(serialized) => { + if let Err(err) = + app_metadata_service::upsert_value(conn, CONFIG_SYNC_SETTINGS_KEY, &serialized).await + { + // The keyring copy is authoritative from here on, so the stale + // plaintext is redundant rather than load-bearing — but it is + // still plaintext, so say so. + tracing::warn!("[CONFIG-SYNC] failed to erase the stored password: {err}"); + } else { + tracing::info!("[CONFIG-SYNC] moved the WebDAV password to the keyring"); + } + } + Err(err) => tracing::warn!("[CONFIG-SYNC] failed to rewrite sync settings: {err}"), } } @@ -232,13 +384,29 @@ pub async fn save_settings_core( conn: &DatabaseConnection, input: ConfigSyncSettingsInput, ) -> Result { + // Before anything else, because this save is a read-modify-write of the + // secret store and an unreadable store reads back as empty — which travels + // out as a deletion. Nudging the sync interval must not be able to destroy + // the passphrase the snapshot already on the remote is encrypted under. + credentials::ensure_readable()?; + let existing = load_settings(conn).await; let merged = merge_settings(&existing, input)?; + // Secrets first. If the keyring refuses them the save fails outright rather + // than persisting a configuration whose credentials went nowhere — that + // would surface minutes later as "the server rejected your password". + credentials::store(WEBDAV_PASSWORD, &merged.password)?; + credentials::store(SNAPSHOT_PASSPHRASE, &merged.passphrase)?; + let serialized = serde_json::to_string(&merged).map_err(|e| { AppCommandError::invalid_input("Failed to serialize config sync settings") .with_detail(e.to_string()) })?; + debug_assert!( + !serialized.contains(&merged.password) || merged.password.is_empty(), + "the settings row must never carry the password" + ); app_metadata_service::upsert_value(conn, CONFIG_SYNC_SETTINGS_KEY, &serialized) .await .map_err(AppCommandError::db)?; @@ -277,6 +445,25 @@ pub fn merge_settings( _ => String::new(), }; + // The passphrase is deliberately NOT scoped the way the password is: it + // protects the snapshot, not the connection, so repointing at another host + // must not orphan it. It also survives `encrypt` being switched off, so a + // user who turns encryption off can still pull down the encrypted copy + // that is already on the remote. + let passphrase = match input.passphrase { + Some(value) if !value.is_empty() => value, + _ => existing.passphrase.clone(), + }; + if input.encrypt && passphrase.is_empty() { + return Err(crypto::passphrase_required_error()); + } + // Re-keying has to invalidate the upload baseline; see `protection`. + let passphrase_id = if passphrase == existing.passphrase && !existing.passphrase_id.is_empty() { + existing.passphrase_id.clone() + } else { + uuid::Uuid::new_v4().simple().to_string() + }; + let remote_dir = normalize_segment(input.remote_dir, &existing.remote_dir, DEFAULT_REMOTE_DIR)?; let profile = normalize_segment(input.profile, &existing.profile, DEFAULT_PROFILE)?; @@ -285,6 +472,9 @@ pub fn merge_settings( server_url, username, password, + passphrase, + encrypt: input.encrypt, + passphrase_id, remote_dir, profile, auto_sync: input.auto_sync, @@ -392,7 +582,10 @@ pub async fn upload_snapshot_core( let client = client_for(&settings)?; let dir = remote_dir_path(&settings)?; - let manifest = build_manifest(&bytes, app_version, counts.clone()); + // What goes on the wire, which is also what the manifest's checksum has to + // cover — its job is catching a truncated transfer. + let (payload, encryption) = seal_for_upload(&settings, bytes).await?; + let manifest = build_manifest(&payload, app_version, counts.clone(), encryption); let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| { AppCommandError::task_execution_failed("Serialize manifest").with_detail(e.to_string()) })?; @@ -400,7 +593,9 @@ pub async fn upload_snapshot_core( let result = async { let _guard = remote_lock().lock().await; client.ensure_dir(&dir).await?; - client.put(&format!("{dir}/{CONFIG_FILE_NAME}"), bytes).await?; + client + .put(&format!("{dir}/{CONFIG_FILE_NAME}"), payload) + .await?; client .put(&format!("{dir}/{MANIFEST_FILE_NAME}"), manifest_bytes) .await?; @@ -432,6 +627,70 @@ pub async fn upload_snapshot_core( } } +/// Wrap the snapshot when encryption is on. Argon2 is deliberately expensive, +/// so the derivation runs on a blocking thread rather than parking the runtime +/// for ~100 ms on every upload. +async fn seal_for_upload( + settings: &ConfigSyncSettings, + plain: Vec, +) -> Result<(Vec, &'static str), AppCommandError> { + if !settings.encrypt { + return Ok((plain, ENCRYPTION_NONE)); + } + let passphrase = settings.passphrase.clone(); + let sealed = tokio::task::spawn_blocking(move || crypto::encrypt(&plain, &passphrase)) + .await + .map_err(|e| { + AppCommandError::task_execution_failed("Encrypt snapshot").with_detail(e.to_string()) + })??; + Ok((sealed, ENCRYPTION_AES_GCM)) +} + +/// The inverse. `manifest` says whether the bytes are wrapped — but it is only +/// a second file on the same share, so whoever can replace the payload can +/// replace the manifest too, checksum and all. It is therefore trusted to say +/// "encrypted" and NOT trusted to say "plaintext": the local switch is the +/// authority for the downgrade direction. +async fn open_after_download( + manifest: &ConfigManifest, + settings: &ConfigSyncSettings, + payload: Vec, +) -> Result, AppCommandError> { + if manifest.encryption != ENCRYPTION_AES_GCM { + // Without this, encryption protects nothing against the adversary it + // was added for. The share operator swaps in a plaintext snapshot of + // their choosing plus a manifest reading `encryption: "none"` with a + // matching SHA-256; every check above passes, and provider endpoints + // and API keys of their choosing land in the local database while the + // user's switch says "encrypted". + if settings.encrypt { + return Err(AppCommandError::invalid_input( + "The remote snapshot is not encrypted, but encryption is on for this machine", + ) + .with_i18n( + crate::app_error::CONFIG_SYNC_I18N_KEY_NOT_ENCRYPTED, + BTreeMap::new(), + )); + } + return Ok(payload); + } + let value: serde_json::Value = serde_json::from_slice(&payload).map_err(|e| { + AppCommandError::invalid_input("Encrypted snapshot is not readable") + .with_detail(e.to_string()) + .with_i18n( + crate::app_error::CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, + BTreeMap::new(), + ) + })?; + let envelope = crypto::parse_envelope(value)?; + let passphrase = settings.passphrase.clone(); + tokio::task::spawn_blocking(move || crypto::decrypt(&envelope, &passphrase)) + .await + .map_err(|e| { + AppCommandError::task_execution_failed("Decrypt snapshot").with_detail(e.to_string()) + })? +} + /// Fetch the remote pair, verify it, and apply it locally. /// /// Always explicit: nothing here runs on a timer. Automatic download would @@ -465,8 +724,11 @@ pub async fn download_and_apply_core( }; let manifest = parse_manifest(&manifest_bytes)?; - // Checksum first: an interrupted upload must never reach the database. + // Checksum first: an interrupted upload must never reach the database — and + // it must not be handed to the decrypter either, where a truncated payload + // would come back as "wrong passphrase". validate_manifest(&manifest, &config_bytes)?; + let config_bytes = open_after_download(&manifest, &settings, config_bytes).await?; let snapshot = parse_snapshot(&config_bytes)?; // Hold the suppression guard across the apply. Applying rewrites local @@ -516,6 +778,8 @@ mod tests { server_url: " https://dav.example.com/dav ".to_string(), username: " alice ".to_string(), password: Some("app-password".to_string()), + passphrase: None, + encrypt: false, remote_dir: Some("codeg".to_string()), profile: Some("work".to_string()), auto_sync: true, @@ -678,6 +942,7 @@ mod tests { #[tokio::test] async fn the_view_never_carries_the_password() { + let _guard = credentials::test_guard(); let db = fresh_in_memory_db().await; let view = save_settings_core(&db.conn, input()).await.expect("save"); assert!(view.has_password); @@ -688,12 +953,394 @@ mod tests { "password leaked to the frontend: {serialized}" ); - // And it round-trips through the database untouched. + // And it round-trips, untouched, from wherever it was put. let stored = load_settings(&db.conn).await; assert_eq!(stored.password, "app-password"); assert_eq!(stored.profile, "work"); } + /// The settings row is plaintext in the SQLite file, and the SQLite file is + /// inside every backup archive. The credential has no business being in + /// either. + #[tokio::test] + async fn the_settings_row_holds_no_secret() { + let _guard = credentials::test_guard(); + let db = fresh_in_memory_db().await; + save_settings_core( + &db.conn, + ConfigSyncSettingsInput { + passphrase: Some("snapshot-passphrase".to_string()), + encrypt: true, + ..input() + }, + ) + .await + .expect("save"); + + let row = app_metadata_service::get_value(&db.conn, CONFIG_SYNC_SETTINGS_KEY) + .await + .expect("read row") + .expect("row exists"); + assert!(!row.contains("app-password"), "{row}"); + assert!(!row.contains("snapshot-passphrase"), "{row}"); + // The non-secret half is still there, or the settings page would come + // back blank. + assert!(row.contains("dav.example.com"), "{row}"); + + let loaded = load_settings(&db.conn).await; + assert_eq!(loaded.password, "app-password"); + assert_eq!(loaded.passphrase, "snapshot-passphrase"); + } + + /// Upgrading must not log the user out of their own WebDAV share: a row + /// written by the build that stored the password inline is read once, moved + /// into the keyring, and erased. + #[tokio::test] + async fn a_password_written_by_an_older_build_is_migrated_out_of_the_row() { + let _guard = credentials::test_guard(); + credentials::store(WEBDAV_PASSWORD, "").expect("start clean"); + let db = fresh_in_memory_db().await; + app_metadata_service::upsert_value( + &db.conn, + CONFIG_SYNC_SETTINGS_KEY, + r#"{"enabled":true,"serverUrl":"https://dav.example.com/dav","username":"alice","password":"legacy-secret","remoteDir":"codeg","profile":"work","autoSync":true,"intervalMinutes":5}"#, + ) + .await + .expect("seed legacy row"); + + let loaded = load_settings(&db.conn).await; + assert_eq!(loaded.password, "legacy-secret"); + assert_eq!(credentials::load(WEBDAV_PASSWORD), "legacy-secret"); + + let row = app_metadata_service::get_value(&db.conn, CONFIG_SYNC_SETTINGS_KEY) + .await + .expect("read row") + .expect("row exists"); + assert!(!row.contains("legacy-secret"), "still in the row: {row}"); + + // Idempotent: a second load reads the keyring copy and changes nothing. + assert_eq!(load_settings(&db.conn).await.password, "legacy-secret"); + credentials::store(WEBDAV_PASSWORD, "").expect("clean up"); + } + + /// The migration is two writes, and the second one can fail: the keyring + /// takes the secret, then the row rewrite loses to a busy database. That + /// leaves the state this test seeds — keyring populated, plaintext STILL in + /// the row — and the next load has to finish the job. + /// + /// Keying the retry off "the keyring is empty" would skip it forever here, + /// and the plaintext would stay in `app_metadata` (and in every backup + /// archive taken from it) for the life of the install. + #[tokio::test] + async fn an_interrupted_migration_is_finished_by_the_next_load() { + let _guard = credentials::test_guard(); + credentials::store(WEBDAV_PASSWORD, "legacy-secret").expect("keyring half succeeded"); + let db = fresh_in_memory_db().await; + app_metadata_service::upsert_value( + &db.conn, + CONFIG_SYNC_SETTINGS_KEY, + r#"{"enabled":true,"serverUrl":"https://dav.example.com/dav","username":"alice","password":"legacy-secret","remoteDir":"codeg","profile":"work","autoSync":true,"intervalMinutes":5}"#, + ) + .await + .expect("seed half-migrated row"); + + assert_eq!(load_settings(&db.conn).await.password, "legacy-secret"); + + let row = app_metadata_service::get_value(&db.conn, CONFIG_SYNC_SETTINGS_KEY) + .await + .expect("read row") + .expect("row exists"); + assert!( + !row.contains("legacy-secret"), + "a half-finished migration was never retried: {row}" + ); + credentials::store(WEBDAV_PASSWORD, "").expect("clean up"); + } + + /// The migration rewrites the row, and the row is shared: a save that lands + /// between this load's read and its write must survive, and so must fields + /// a NEWER build wrote that this one cannot parse. Both are the same + /// property — the migration removes one key rather than serializing its own + /// idea of the settings over the top — and the unknown field is the half + /// that can be pinned down without racing anything. + /// + /// Serializing the parsed struct back would drop `futureField` here, and in + /// the racing case would pair the OLD server URL with the NEW password the + /// keyring just took: the exact combination `merge_settings` refuses to + /// create, arrived at behind its back. + #[tokio::test] + async fn the_migration_removes_the_password_and_nothing_else() { + let _guard = credentials::test_guard(); + credentials::store(WEBDAV_PASSWORD, "").expect("start clean"); + let db = fresh_in_memory_db().await; + app_metadata_service::upsert_value( + &db.conn, + CONFIG_SYNC_SETTINGS_KEY, + r#"{"enabled":true,"serverUrl":"https://dav.example.com/dav","username":"alice","password":"legacy-secret","remoteDir":"codeg","profile":"work","autoSync":true,"intervalMinutes":5,"futureField":"written by a newer build"}"#, + ) + .await + .expect("seed a row this build does not fully understand"); + + assert_eq!(load_settings(&db.conn).await.password, "legacy-secret"); + + let row = app_metadata_service::get_value(&db.conn, CONFIG_SYNC_SETTINGS_KEY) + .await + .expect("read row") + .expect("row exists"); + assert!(!row.contains("legacy-secret"), "the password must be gone: {row}"); + assert!( + row.contains("written by a newer build"), + "the migration overwrote a field it does not own: {row}" + ); + credentials::store(WEBDAV_PASSWORD, "").expect("clean up"); + } + + /// A keyring that will not open reads back as "no secret", and a save is a + /// read-modify-write: without a guard, changing the sync interval would + /// hand `""` to the credential store, which means DELETE. The passphrase + /// protecting the copy already on the remote would go with it, and no + /// retry brings it back. + #[tokio::test] + async fn an_unreadable_credential_store_refuses_the_save_instead_of_erasing_it() { + let _guard = credentials::test_guard(); + let db = fresh_in_memory_db().await; + credentials::store(WEBDAV_PASSWORD, "app-password").expect("seed"); + credentials::store(SNAPSHOT_PASSPHRASE, "hunter2").expect("seed"); + + let err = { + let _unreadable = credentials::unreadable_store(); + save_settings_core(&db.conn, input()) + .await + .expect_err("a save that cannot read the store must not write to it") + }; + assert_eq!( + err.i18n_key.as_deref(), + Some(crate::app_error::CONFIG_SYNC_I18N_KEY_CREDENTIALS_UNREADABLE) + ); + + // Both secrets are still there once the store opens again. + assert_eq!(credentials::load(WEBDAV_PASSWORD), "app-password"); + assert_eq!(credentials::load(SNAPSHOT_PASSPHRASE), "hunter2"); + + credentials::store(WEBDAV_PASSWORD, "").expect("clean up"); + credentials::store(SNAPSHOT_PASSPHRASE, "").expect("clean up"); + } + + /// And when the two copies disagree — the user re-saved a new password + /// after a partial migration — the keyring is the one every save writes to, + /// so it wins and the stale row copy is erased rather than resurrected. + #[tokio::test] + async fn the_keyring_copy_wins_over_a_stale_row_copy() { + let _guard = credentials::test_guard(); + credentials::store(WEBDAV_PASSWORD, "current").expect("store"); + let db = fresh_in_memory_db().await; + app_metadata_service::upsert_value( + &db.conn, + CONFIG_SYNC_SETTINGS_KEY, + r#"{"enabled":true,"serverUrl":"https://dav.example.com/dav","username":"alice","password":"outdated","remoteDir":"codeg","profile":"work","autoSync":true,"intervalMinutes":5}"#, + ) + .await + .expect("seed row"); + + assert_eq!(load_settings(&db.conn).await.password, "current"); + let row = app_metadata_service::get_value(&db.conn, CONFIG_SYNC_SETTINGS_KEY) + .await + .expect("read row") + .expect("row exists"); + assert!(!row.contains("outdated"), "{row}"); + credentials::store(WEBDAV_PASSWORD, "").expect("clean up"); + } + + /// Switching encryption on leaves the PLAINTEXT snapshot byte-identical, so + /// the hash alone would say "already uploaded" and the remote would keep + /// its unencrypted copy — the user would have turned on a protection that + /// never reached the server. Same shape as the retarget bug, same fix: the + /// baseline records what it was uploaded under. + #[test] + fn turning_encryption_on_or_rekeying_invalidates_the_upload_baseline() { + let plain = ConfigSyncSettings { + server_url: "https://dav.example.com/dav".to_string(), + ..Default::default() + }; + let encrypted = merge_settings( + &plain, + ConfigSyncSettingsInput { + encrypt: true, + passphrase: Some("first".to_string()), + ..input() + }, + ) + .expect("merge"); + assert_ne!(plain.remote_target(), encrypted.remote_target()); + + let rekeyed = merge_settings( + &encrypted, + ConfigSyncSettingsInput { + encrypt: true, + passphrase: Some("second".to_string()), + ..input() + }, + ) + .expect("merge"); + assert_ne!( + encrypted.remote_target(), + rekeyed.remote_target(), + "a re-key leaves the remote unreadable; it must force a re-upload" + ); + + // Saving again without retyping the passphrase is not a re-key, and + // must NOT cost an upload every time the settings page is saved. + let resaved = merge_settings( + &rekeyed, + ConfigSyncSettingsInput { + encrypt: true, + passphrase: None, + ..input() + }, + ) + .expect("merge"); + assert_eq!(rekeyed.remote_target(), resaved.remote_target()); + } + + #[test] + fn encryption_without_a_passphrase_is_refused_instead_of_failing_every_tick() { + let err = merge_settings( + &ConfigSyncSettings::default(), + ConfigSyncSettingsInput { + encrypt: true, + passphrase: None, + ..input() + }, + ) + .expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(crate::app_error::CONFIG_SYNC_I18N_KEY_PASSPHRASE_REQUIRED) + ); + } + + /// The passphrase is not scoped to the account the way the password is, and + /// it outlives the switch: an encrypted snapshot already on the remote has + /// to stay readable after a user turns encryption off. + #[test] + fn the_passphrase_survives_a_host_change_and_the_switch_going_off() { + let stored_with_passphrase = ConfigSyncSettings { + passphrase: "kept".to_string(), + encrypt: true, + passphrase_id: "id".to_string(), + ..stored() + }; + + let moved = merge_settings( + &stored_with_passphrase, + ConfigSyncSettingsInput { + password: Some("new".to_string()), + server_url: "https://dav.other.example/dav".to_string(), + encrypt: true, + passphrase: None, + ..input() + }, + ) + .expect("merge"); + assert_eq!(moved.passphrase, "kept"); + + let switched_off = merge_settings( + &stored_with_passphrase, + ConfigSyncSettingsInput { + encrypt: false, + passphrase: None, + ..input() + }, + ) + .expect("merge"); + assert_eq!(switched_off.passphrase, "kept"); + assert!(!switched_off.encrypt); + } + + /// The manifest is the authority on whether the payload is wrapped, and it + /// stays plaintext so "whose copy, from when" is readable without the + /// passphrase. The payload itself must not be. + #[tokio::test] + async fn an_encrypted_upload_is_unreadable_but_its_manifest_is_not() { + let db = fresh_in_memory_db().await; + let snapshot = collect_snapshot_core(&db.conn).await.expect("collect"); + let plain = serialize_snapshot(&snapshot).expect("bytes"); + + let settings = ConfigSyncSettings { + encrypt: true, + passphrase: "hunter2".to_string(), + ..Default::default() + }; + let (payload, encryption) = seal_for_upload(&settings, plain.clone()) + .await + .expect("seal"); + assert_eq!(encryption, ENCRYPTION_AES_GCM); + assert_ne!(payload, plain); + + let manifest = build_manifest(&payload, "1.0.0", snapshot.counts(), encryption); + // The checksum has to cover the transferred bytes, or a truncated + // ciphertext would read as a wrong passphrase. + validate_manifest(&manifest, &payload).expect("manifest matches the ciphertext"); + let manifest_json = serde_json::to_string(&manifest).expect("json"); + assert!(manifest_json.contains("aes-256-gcm"), "{manifest_json}"); + + let opened = open_after_download(&manifest, &settings, payload.clone()) + .await + .expect("open"); + assert_eq!(opened, plain); + + // A machine without the passphrase gets told so, rather than being + // handed nonsense to parse. + let bare = ConfigSyncSettings::default(); + let err = open_after_download(&manifest, &bare, payload) + .await + .expect_err("must refuse"); + assert_eq!( + err.i18n_key.as_deref(), + Some(crate::app_error::CONFIG_SYNC_I18N_KEY_PASSPHRASE_REQUIRED) + ); + } + + /// The manifest is a second file on the same share, not a signature: the + /// party this feature encrypts AGAINST can rewrite it. So it may be + /// believed when it says "encrypted" and not when it says "plaintext" — + /// otherwise a two-file swap (attacker's snapshot + `encryption: "none"` + + /// the matching checksum) walks straight past every check and writes + /// provider endpoints and API keys into the local database. + #[tokio::test] + async fn a_manifest_cannot_switch_encryption_off() { + let db = fresh_in_memory_db().await; + let snapshot = collect_snapshot_core(&db.conn).await.expect("collect"); + let forged = serialize_snapshot(&snapshot).expect("bytes"); + // Forged end to end: the checksum is over the attacker's own bytes, so + // `validate_manifest` has nothing to object to. + let manifest = build_manifest(&forged, "1.0.0", snapshot.counts(), ENCRYPTION_NONE); + validate_manifest(&manifest, &forged).expect("a forgery is self-consistent"); + + let protected = ConfigSyncSettings { + encrypt: true, + passphrase: "hunter2".to_string(), + ..Default::default() + }; + let err = open_after_download(&manifest, &protected, forged.clone()) + .await + .expect_err("a downgrade must not be applied"); + assert_eq!( + err.i18n_key.as_deref(), + Some(crate::app_error::CONFIG_SYNC_I18N_KEY_NOT_ENCRYPTED) + ); + + // And the same pair is still accepted by a machine that never asked for + // encryption — the refusal is the user's switch, not a format rule. + let plain = ConfigSyncSettings::default(); + assert_eq!( + open_after_download(&manifest, &plain, forged.clone()) + .await + .expect("plaintext sync still works"), + forged + ); + } + /// Seed "this exact configuration is already on the currently configured /// remote", which is the state the timer spends most of its life in. async fn seed_uploaded_baseline(db: &crate::db::AppDatabase) -> String { @@ -780,6 +1427,7 @@ mod tests { /// baseline has no such window. #[tokio::test] async fn a_baseline_does_not_carry_over_to_a_new_remote() { + let _guard = credentials::test_guard(); let db = fresh_in_memory_db().await; save_settings_core(&db.conn, input()).await.expect("save"); let hash = seed_uploaded_baseline(&db).await; diff --git a/src-tauri/src/keyring_store.rs b/src-tauri/src/keyring_store.rs index d3e9041b95..8b16ff7eb4 100644 --- a/src-tauri/src/keyring_store.rs +++ b/src-tauri/src/keyring_store.rs @@ -9,6 +9,13 @@ fn channel_token_key(channel_id: i32) -> String { format!("chat-channel:{}", channel_id) } +/// Namespace for secrets that are not tokens of an account or a channel. The +/// prefix keeps them from ever colliding with a `github-token:` whose id +/// happens to look like a secret name. +fn secret_key(name: &str) -> String { + format!("secret:{}", name) +} + // ── Tauri mode: OS keyring ── #[cfg(feature = "tauri-runtime")] @@ -75,6 +82,18 @@ fn read_tokens() -> std::collections::HashMap { /// read-only mount is not made worse by proceeding. #[cfg(not(feature = "tauri-runtime"))] fn read_tokens_at(path: &std::path::Path) -> std::collections::HashMap { + read_tokens_at_checked(path).unwrap_or_default() +} + +/// The same read, but an unreadable or corrupt store is an error rather than an +/// empty map. Callers that WRITE secrets back need the distinction: "there is no +/// entry" and "the file would not open" lead to opposite decisions on the way +/// out — write nothing, versus refuse to write at all — and collapsing them +/// turns a transient read failure into a permanent deletion. +#[cfg(not(feature = "tauri-runtime"))] +fn read_tokens_at_checked( + path: &std::path::Path, +) -> Result, String> { #[cfg(unix)] if path.exists() { use std::os::unix::fs::PermissionsExt; @@ -89,10 +108,15 @@ fn read_tokens_at(path: &std::path::Path) -> std::collections::HashMap serde_json::from_str(&raw) + .map_err(|e| format!("token store is not readable JSON: {e}")), + // A store that was never written is legitimately empty. + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok(std::collections::HashMap::new()) + } + Err(err) => Err(format!("token store read error: {err}")), + } } #[cfg(not(feature = "tauri-runtime"))] @@ -227,6 +251,68 @@ pub fn delete_channel_token(channel_id: i32) -> Result<(), String> { write_tokens(&tokens) } +// ── Named secrets ── +// Same storage as the tokens above (OS keyring on desktop, the 0600 +// `tokens.json` on a server), for credentials that belong to a feature rather +// than to an account: the config-sync WebDAV password and snapshot passphrase. + +#[cfg(feature = "tauri-runtime")] +pub fn set_secret(name: &str, value: &str) -> Result<(), String> { + let entry = keyring::Entry::new(SERVICE_NAME, &secret_key(name)) + .map_err(|e| format!("keyring init error: {e}"))?; + entry + .set_password(value) + .map_err(|e| format!("keyring set error: {e}")) +} + +/// `Ok(None)` is "nothing stored under that name"; `Err` is "the store would not +/// open". Unlike [`get_token`], which collapses both into `None`, a secret's +/// reader has to keep them apart: an empty value means "delete this entry" when +/// it travels back through [`set_secret`]/[`delete_secret`], so a denied +/// keychain prompt read as "absent" would erase the secret at the next save. +#[cfg(feature = "tauri-runtime")] +pub fn get_secret(name: &str) -> Result, String> { + let entry = keyring::Entry::new(SERVICE_NAME, &secret_key(name)) + .map_err(|e| format!("keyring init error: {e}"))?; + match entry.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("keyring get error: {e}")), + } +} + +#[cfg(feature = "tauri-runtime")] +pub fn delete_secret(name: &str) -> Result<(), String> { + let entry = keyring::Entry::new(SERVICE_NAME, &secret_key(name)) + .map_err(|e| format!("keyring init error: {e}"))?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("keyring delete error: {e}")), + } +} + +#[cfg(not(feature = "tauri-runtime"))] +pub fn set_secret(name: &str, value: &str) -> Result<(), String> { + let mut tokens = read_tokens(); + tokens.insert(secret_key(name), value.to_string()); + write_tokens(&tokens) +} + +#[cfg(not(feature = "tauri-runtime"))] +pub fn get_secret(name: &str) -> Result, String> { + Ok(read_tokens_at_checked(&tokens_file_path())? + .get(&secret_key(name)) + .cloned()) +} + +#[cfg(not(feature = "tauri-runtime"))] +pub fn delete_secret(name: &str) -> Result<(), String> { + let mut tokens = read_tokens(); + tokens.remove(&secret_key(name)); + write_tokens(&tokens) +} + #[cfg(all(test, not(feature = "tauri-runtime")))] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 47956232e7..dd37c3b498 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1873,6 +1873,11 @@ mod tauri_app { config_sync::config_sync_upload_now, config_sync::config_sync_peek_remote, config_sync::config_sync_download_apply, + config_sync::config_sync_export_content, + config_sync::config_sync_peek_content, + config_sync::config_sync_import_content, + config_sync::config_sync_list_rollbacks, + config_sync::config_sync_apply_rollback, backup::backup_create, backup::backup_prepare_source, backup::backup_release_source, diff --git a/src-tauri/src/web/handlers/config_sync.rs b/src-tauri/src/web/handlers/config_sync.rs new file mode 100644 index 0000000000..dc3e833ca1 --- /dev/null +++ b/src-tauri/src/web/handlers/config_sync.rs @@ -0,0 +1,147 @@ +//! Configuration sync HTTP endpoints (server / web mode). +//! +//! One handler per Tauri command in `commands::config_sync`, over the same +//! `*_core` functions — the split exists precisely so neither runtime gets a +//! second implementation to keep in step. +//! +//! The only shape that differs is local file transfer. A browser cannot hand +//! the backend a path, so export answers with the document's text (the client +//! saves it with a `Blob`) and import posts the text back. That is the whole +//! mechanism: a config snapshot is tens of KB, so it needs none of the +//! upload-staging, download-ticket, and temp-file reaping machinery +//! `handlers::backup` carries for archives measured in gigabytes. + +use std::sync::Arc; + +use axum::extract::Extension; +use axum::Json; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::config_sync::local_io::{ + apply_rollback_core, export_content_core, import_bytes_core, list_rollbacks_core, + peek_import_bytes_core, ConfigExportContent, ConfigImportPreview, ConfigImportResult, +}; +use crate::commands::config_sync::snapshot::{ + rollback_dir, ConfigManifest, RollbackSnapshotInfo, +}; +use crate::commands::config_sync::webdav_sync::{ + download_and_apply_core, load_settings, load_state, merge_settings, peek_remote_core, + save_settings_core, test_connection_core, upload_snapshot_core, ConfigSyncSettingsInput, + ConfigSyncSettingsView, ConfigSyncState, DownloadOutcome, UploadOutcome, +}; +use crate::commands::config_sync::APP_VERSION; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsParams { + pub settings: ConfigSyncSettingsInput, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentParams { + pub content: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RollbackParams { + pub id: String, +} + +pub async fn config_sync_get_settings( + Extension(state): Extension>, +) -> Result, AppCommandError> { + Ok(Json(ConfigSyncSettingsView::from( + &load_settings(&state.db.conn).await, + ))) +} + +pub async fn config_sync_update_settings( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + save_settings_core(&state.db.conn, params.settings) + .await + .map(Json) +} + +pub async fn config_sync_get_state( + Extension(state): Extension>, +) -> Result, AppCommandError> { + Ok(Json(load_state(&state.db.conn).await)) +} + +pub async fn config_sync_test_connection( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let existing = load_settings(&state.db.conn).await; + let candidate = merge_settings(&existing, params.settings)?; + test_connection_core(&candidate).await.map(Json) +} + +pub async fn config_sync_upload_now( + Extension(state): Extension>, +) -> Result, AppCommandError> { + upload_snapshot_core(&state.db.conn, APP_VERSION, true) + .await + .map(Json) +} + +pub async fn config_sync_peek_remote( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + peek_remote_core(&state.db.conn).await.map(Json) +} + +pub async fn config_sync_download_apply( + Extension(state): Extension>, +) -> Result, AppCommandError> { + download_and_apply_core(&state.db.conn).await.map(Json) +} + +pub async fn config_sync_export_content( + Extension(state): Extension>, +) -> Result, AppCommandError> { + export_content_core(&state.db.conn, APP_VERSION) + .await + .map(Json) +} + +pub async fn config_sync_peek_content( + Json(params): Json, +) -> Result, AppCommandError> { + peek_import_bytes_core(params.content.as_bytes()).map(Json) +} + +pub async fn config_sync_import_content( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + import_bytes_core(&state.db.conn, params.content.as_bytes()) + .await + .map(Json) +} + +pub async fn config_sync_list_rollbacks( +) -> Result>, AppCommandError> { + let infos = tokio::task::spawn_blocking(|| list_rollbacks_core(&rollback_dir())) + .await + .map_err(|e| { + AppCommandError::task_execution_failed("List rollback snapshots") + .with_detail(e.to_string()) + })?; + Ok(Json(infos)) +} + +pub async fn config_sync_apply_rollback( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + apply_rollback_core(&state.db.conn, &rollback_dir(), ¶ms.id) + .await + .map(Json) +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 6a38e353f5..3c96367a10 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod background; pub mod backup; pub mod chat_authoring; pub mod chat_channel; +pub mod config_sync; pub mod conversations; pub mod custom_skills; pub mod delegation; diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 664342481c..4875810a42 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -589,6 +589,60 @@ pub fn build_router( "/backup_discard_pending", post(handlers::backup::backup_discard_pending), ) + // ─── Configuration sync ─── + // + // The WebDAV half is runtime-agnostic. Local file transfer is the + // by-content pair: a browser has no path to name, and the payload is + // tens of KB, so it travels in the JSON body rather than through the + // upload-staging machinery above. + .route( + "/config_sync_get_settings", + post(handlers::config_sync::config_sync_get_settings), + ) + .route( + "/config_sync_update_settings", + post(handlers::config_sync::config_sync_update_settings), + ) + .route( + "/config_sync_get_state", + post(handlers::config_sync::config_sync_get_state), + ) + .route( + "/config_sync_test_connection", + post(handlers::config_sync::config_sync_test_connection), + ) + .route( + "/config_sync_upload_now", + post(handlers::config_sync::config_sync_upload_now), + ) + .route( + "/config_sync_peek_remote", + post(handlers::config_sync::config_sync_peek_remote), + ) + .route( + "/config_sync_download_apply", + post(handlers::config_sync::config_sync_download_apply), + ) + .route( + "/config_sync_export_content", + post(handlers::config_sync::config_sync_export_content), + ) + .route( + "/config_sync_peek_content", + post(handlers::config_sync::config_sync_peek_content), + ) + .route( + "/config_sync_import_content", + post(handlers::config_sync::config_sync_import_content), + ) + .route( + "/config_sync_list_rollbacks", + post(handlers::config_sync::config_sync_list_rollbacks), + ) + .route( + "/config_sync_apply_rollback", + post(handlers::config_sync::config_sync_apply_rollback), + ) .route( "/download_workspace_file", post(handlers::workspace_files::download_workspace_file), diff --git a/src/components/settings/config-sync-settings.test.tsx b/src/components/settings/config-sync-settings.test.tsx index 65d8958073..fc4b91c055 100644 --- a/src/components/settings/config-sync-settings.test.tsx +++ b/src/components/settings/config-sync-settings.test.tsx @@ -2,8 +2,10 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { NextIntlClientProvider } from "next-intl" import { beforeEach, describe, expect, it, vi } from "vitest" -// Flipped per-test: the panel is desktop-only and must disappear entirely on -// web / remote-desktop rather than render disabled controls. +// Flipped per-test. The panel itself is runtime-agnostic now — only the file +// picker underneath it differs — so these decide which branch of +// `@/lib/config-sync` the (unmocked) helpers would take, not whether the +// section renders at all. const env = vi.hoisted(() => ({ desktop: true, remoteId: null as string | null, @@ -41,7 +43,9 @@ vi.mock("@/lib/config-sync", async () => { downloadAndApplyConfig: vi.fn(), exportConfigToFile: vi.fn(), pickConfigFileToImport: vi.fn(), - importConfigFromFile: vi.fn(), + importPickedConfig: vi.fn(), + listConfigRollbacks: vi.fn(), + applyConfigRollback: vi.fn(), listenConfigSyncStatus: vi.fn(async (handler: (e: unknown) => void) => { statusHandler = handler return () => {} @@ -62,10 +66,12 @@ vi.mock("sonner", () => ({ import { ConfigSyncSettings } from "./config-sync-settings" import enMessages from "@/i18n/messages/en.json" import { + applyConfigRollback, downloadAndApplyConfig, getConfigSyncSettings, getConfigSyncState, - importConfigFromFile, + importPickedConfig, + listConfigRollbacks, peekRemoteConfig, pickConfigFileToImport, updateConfigSyncSettings, @@ -78,12 +84,26 @@ const SAVED = { serverUrl: "https://dav.example.com/dav/", username: "alice", hasPassword: true, + encrypt: false, + hasPassphrase: false, remoteDir: "codeg", profile: "default", autoSync: true, intervalMinutes: 5, } +/** The shape `pickConfigFileToImport` returns on a local desktop. */ +function pickedPath(counts: Record = { modelProviders: 3 }) { + return { + source: { + kind: "path" as const, + path: "/tmp/config.json", + label: "/tmp/config.json", + }, + preview: { manifest: manifest(), counts }, + } +} + function manifest(overrides: Record = {}) { return { schemaVersion: 1, @@ -125,20 +145,25 @@ beforeEach(() => { lastError: null, }) vi.mocked(updateConfigSyncSettings).mockResolvedValue({ ...SAVED }) + vi.mocked(listConfigRollbacks).mockResolvedValue([]) }) describe("ConfigSyncSettings — availability", () => { - it("renders nothing on web", () => { + /// Regression: the panel used to `return null` for anything but a local + /// desktop window, because the commands were registered on the Tauri + /// runtime only. They exist on the HTTP API now, and a browser pointed at a + /// codeg-server has exactly the same configuration worth syncing. + it("renders in a browser, where the commands now exist too", async () => { env.desktop = false - const { container } = renderPanel() - expect(container).toBeEmptyDOMElement() - expect(getConfigSyncSettings).not.toHaveBeenCalled() + renderPanel() + await screen.findByRole("button", { name: t.saveButton }) + expect(getConfigSyncSettings).toHaveBeenCalled() }) - it("renders nothing for a remote-desktop window", () => { + it("renders for a remote-desktop window", async () => { env.remoteId = "remote-1" - const { container } = renderPanel() - expect(container).toBeEmptyDOMElement() + renderPanel() + await screen.findByRole("button", { name: t.saveButton }) }) it("shows the file actions even before WebDAV is set up", async () => { @@ -190,8 +215,7 @@ describe("ConfigSyncSettings — credentials", () => { enabled: false, }) await renderLoaded() - // The master switch is the first one; the second is "upload automatically". - fireEvent.click(screen.getAllByRole("switch")[0]) + fireEvent.click(screen.getByRole("switch", { name: t.webdavTitle })) await waitFor(() => expect(updateConfigSyncSettings).toHaveBeenCalled()) expect(vi.mocked(updateConfigSyncSettings).mock.calls[0][0]).toMatchObject({ enabled: false, @@ -268,18 +292,16 @@ describe("ConfigSyncSettings — restore from remote", () => { describe("ConfigSyncSettings — file import", () => { it("previews the file and applies it only after confirmation", async () => { - vi.mocked(pickConfigFileToImport).mockResolvedValue({ - path: "/tmp/config.json", - preview: { manifest: manifest(), counts: { modelProviders: 3 } }, - }) - vi.mocked(importConfigFromFile).mockResolvedValue({ + const picked = pickedPath() + vi.mocked(pickConfigFileToImport).mockResolvedValue(picked) + vi.mocked(importPickedConfig).mockResolvedValue({ applied: { domains: { modelProviders: 3 }, total: 3 }, rollbackPath: null, }) await renderLoaded() fireEvent.click(screen.getByRole("button", { name: t.importButton })) await screen.findByText(t.importConfirmTitle) - expect(importConfigFromFile).not.toHaveBeenCalled() + expect(importPickedConfig).not.toHaveBeenCalled() // Regression: the confirm button used to be gated on a `preview.importable` // field the backend never sends, so it was `undefined` on every real file @@ -290,14 +312,40 @@ describe("ConfigSyncSettings — file import", () => { expect(confirm).toBeEnabled() fireEvent.click(confirm) await waitFor(() => - expect(importConfigFromFile).toHaveBeenCalledWith("/tmp/config.json") + expect(importPickedConfig).toHaveBeenCalledWith(picked.source) + ) + }) + + /// The browser has no path to hand over, so the picker returns the bytes. + /// The panel must pass whichever it was given straight back, untouched. + it("imports a browser-picked file by content, not by path", async () => { + env.desktop = false + const picked = { + source: { + kind: "content" as const, + content: '{"schemaVersion":1,"domains":{}}', + label: "codeg-config.json", + }, + preview: { manifest: manifest(), counts: { quickMessages: 1 } }, + } + vi.mocked(pickConfigFileToImport).mockResolvedValue(picked) + vi.mocked(importPickedConfig).mockResolvedValue({ + applied: { domains: { quickMessages: 1 }, total: 1 }, + rollbackPath: null, + }) + await renderLoaded() + fireEvent.click(screen.getByRole("button", { name: t.importButton })) + await screen.findByText(t.importConfirmTitle) + fireEvent.click(screen.getByRole("button", { name: t.importConfirmAction })) + await waitFor(() => + expect(importPickedConfig).toHaveBeenCalledWith(picked.source) ) }) /// `peek` recounts the payload; the file's own manifest is just a claim. it("counts what will be applied, not what the file says about itself", async () => { vi.mocked(pickConfigFileToImport).mockResolvedValue({ - path: "/tmp/config.json", + ...pickedPath({ modelProviders: 2 }), preview: { manifest: manifest({ counts: { modelProviders: 99 } }), counts: { modelProviders: 2 }, @@ -330,6 +378,135 @@ describe("ConfigSyncSettings — file import", () => { }) }) +describe("ConfigSyncSettings — encryption", () => { + it("asks for a passphrase only once encryption is switched on", async () => { + const { container } = await renderLoaded() + expect( + container.querySelector("#config-sync-passphrase") + ).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole("switch", { name: t.encryptLabel })) + await waitFor(() => + expect( + container.querySelector("#config-sync-passphrase") + ).toBeInTheDocument() + ) + // And the warning stops claiming the upload is plaintext. + expect(screen.queryByText(t.plaintextWarning)).not.toBeInTheDocument() + expect(screen.getByText(t.encryptedNotice)).toBeInTheDocument() + }) + + it("sends the typed passphrase and then clears the field", async () => { + vi.mocked(getConfigSyncSettings).mockResolvedValue({ + ...SAVED, + encrypt: true, + hasPassphrase: false, + }) + vi.mocked(updateConfigSyncSettings).mockResolvedValue({ + ...SAVED, + encrypt: true, + hasPassphrase: true, + }) + const { container } = await renderLoaded() + const passphrase = container.querySelector( + "#config-sync-passphrase" + ) as HTMLInputElement + expect(passphrase.placeholder).toBe(t.passphrasePlaceholder) + + fireEvent.change(passphrase, { target: { value: "correct horse" } }) + fireEvent.click(screen.getByRole("button", { name: t.saveButton })) + await waitFor(() => expect(updateConfigSyncSettings).toHaveBeenCalled()) + expect(vi.mocked(updateConfigSyncSettings).mock.calls[0][0]).toMatchObject({ + encrypt: true, + passphrase: "correct horse", + }) + await waitFor(() => expect(passphrase.value).toBe("")) + // Stored now, so an untouched field means "keep it". + await waitFor(() => expect(passphrase.placeholder).toBe(t.passphraseKeep)) + }) +}) + +describe("ConfigSyncSettings — rollback snapshots", () => { + const SNAPSHOT = { + id: "config-20260606T120000000", + createdAt: "2026-06-06T12:00:00Z", + size: 4096, + counts: { modelProviders: 2 }, + } + + it("hides the section when there is nothing to undo", async () => { + await renderLoaded() + expect(screen.queryByText(t.rollbackTitle)).not.toBeInTheDocument() + }) + + /// Regression: every import and restore wrote a pre-apply snapshot and + /// returned its path, but nothing listed or applied one — the safety net + /// existed on disk and was unreachable from the product. + it("lists a saved configuration and applies it after confirmation", async () => { + vi.mocked(listConfigRollbacks).mockResolvedValue([SNAPSHOT]) + vi.mocked(applyConfigRollback).mockResolvedValue({ + applied: { domains: { modelProviders: 2 }, total: 2 }, + rollbackPath: null, + }) + await renderLoaded() + await screen.findByText(t.rollbackTitle) + expect(screen.getByText("2 model providers")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: t.rollbackAction })) + await screen.findByText(t.rollbackConfirmTitle) + expect(applyConfigRollback).not.toHaveBeenCalled() + + fireEvent.click( + screen.getByRole("button", { name: t.rollbackConfirmAction }) + ) + await waitFor(() => + expect(applyConfigRollback).toHaveBeenCalledWith(SNAPSHOT.id) + ) + }) + + /// The undo writes its own rollback point, so the list has to be re-read + /// rather than left showing the state from before the click. + it("re-reads the list after an undo", async () => { + vi.mocked(listConfigRollbacks).mockResolvedValue([SNAPSHOT]) + vi.mocked(applyConfigRollback).mockResolvedValue({ + applied: { domains: {}, total: 0 }, + rollbackPath: null, + }) + await renderLoaded() + await screen.findByText(t.rollbackTitle) + expect(listConfigRollbacks).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole("button", { name: t.rollbackAction })) + await screen.findByText(t.rollbackConfirmTitle) + fireEvent.click( + screen.getByRole("button", { name: t.rollbackConfirmAction }) + ) + await waitFor(() => expect(listConfigRollbacks).toHaveBeenCalledTimes(2)) + }) + + /// A snapshot pruned since the list was drawn must leave the list rather + /// than sit there offering a button that fails every time. + it("drops a snapshot the backend can no longer find", async () => { + vi.mocked(listConfigRollbacks).mockResolvedValueOnce([SNAPSHOT]) + vi.mocked(applyConfigRollback).mockRejectedValue( + new Error("That rollback snapshot is no longer on this machine") + ) + vi.mocked(listConfigRollbacks).mockResolvedValue([]) + await renderLoaded() + await screen.findByText(t.rollbackTitle) + + fireEvent.click(screen.getByRole("button", { name: t.rollbackAction })) + await screen.findByText(t.rollbackConfirmTitle) + fireEvent.click( + screen.getByRole("button", { name: t.rollbackConfirmAction }) + ) + await waitFor(() => expect(toastError).toHaveBeenCalled()) + await waitFor(() => + expect(screen.queryByText(t.rollbackTitle)).not.toBeInTheDocument() + ) + }) +}) + describe("ConfigSyncSettings — background status", () => { it("reflects an upload performed by the periodic loop", async () => { await renderLoaded() diff --git a/src/components/settings/config-sync-settings.tsx b/src/components/settings/config-sync-settings.tsx index eabe69efc6..4baceec1f9 100644 --- a/src/components/settings/config-sync-settings.tsx +++ b/src/components/settings/config-sync-settings.tsx @@ -9,6 +9,8 @@ import { Loader2, RefreshCw, ShieldAlert, + ShieldCheck, + Undo2, X, } from "lucide-react" import { useTranslations } from "next-intl" @@ -39,14 +41,14 @@ import { toLocalizedErrorMessage, type AppErrorTranslator, } from "@/lib/app-error" -import { isDesktop } from "@/lib/platform" -import { getActiveRemoteConnectionId } from "@/lib/transport" import { + applyConfigRollback, downloadAndApplyConfig, exportConfigToFile, getConfigSyncSettings, getConfigSyncState, - importConfigFromFile, + importPickedConfig, + listConfigRollbacks, listenConfigSyncStatus, peekRemoteConfig, pickConfigFileToImport, @@ -54,10 +56,11 @@ import { testConfigSyncConnection, updateConfigSyncSettings, uploadConfigNow, - type ConfigImportPreview, type ConfigManifest, type ConfigSyncSettingsInput, type DomainCounts, + type PickedConfigImport, + type RollbackSnapshot, } from "@/lib/config-sync" /** Fixed choices instead of a free number field: the interval only has to be @@ -104,8 +107,6 @@ function presetFromUrl(url: string): PresetId { return "custom" } -type PendingImport = { path: string; preview: ConfigImportPreview } - function formatTimestamp(value: string | null): string | null { if (!value) return null const parsed = new Date(value) @@ -123,10 +124,6 @@ export function ConfigSyncSettings() { [tRoot] ) - // Native dialogs plus Tauri-only commands: a remote-desktop window points at - // another machine's server, where neither applies. - const desktop = isDesktop() && getActiveRemoteConnectionId() === null - const [loaded, setLoaded] = useState(false) const [enabled, setEnabled] = useState(false) const [serverUrl, setServerUrl] = useState("") @@ -144,6 +141,11 @@ export function ConfigSyncSettings() { serverUrl: "", username: "", }) + // Encryption is opt-in: the default boundary is the user's own authenticated + // endpoint, which is a real one for a share they host themselves. + const [encrypt, setEncrypt] = useState(false) + const [passphrase, setPassphrase] = useState("") + const [hasPassphrase, setHasPassphrase] = useState(false) const [remoteDir, setRemoteDir] = useState("codeg") const [profile, setProfile] = useState("default") const [autoSync, setAutoSync] = useState(true) @@ -153,13 +155,25 @@ export function ConfigSyncSettings() { const [lastError, setLastError] = useState(null) const [busy, setBusy] = useState< - null | "save" | "test" | "upload" | "download" | "export" | "import" + | null + | "save" + | "test" + | "upload" + | "download" + | "export" + | "import" + | "rollback" >(null) - const [pendingImport, setPendingImport] = useState(null) + const [pendingImport, setPendingImport] = useState( + null + ) const [remoteManifest, setRemoteManifest] = useState( null ) const [restoreOpen, setRestoreOpen] = useState(false) + const [rollbacks, setRollbacks] = useState([]) + const [pendingRollback, setPendingRollback] = + useState(null) // Guards against a late `setState` when the settings page unmounts during a // slow WebDAV round-trip. @@ -171,14 +185,27 @@ export function ConfigSyncSettings() { } }, []) + /** Refreshed after anything that writes one, so the undo list is never a + * snapshot of the panel's first paint. */ + const refreshRollbacks = useCallback(async () => { + try { + const listed = await listConfigRollbacks() + if (mounted.current) setRollbacks(listed) + } catch (err) { + // A missing or unreadable snapshot directory is not worth a toast: the + // section simply does not appear. + console.error("[config-sync] failed to list rollback snapshots", err) + } + }, []) + useEffect(() => { - if (!desktop) return let cancelled = false void (async () => { try { const [settings, state] = await Promise.all([ getConfigSyncSettings(), getConfigSyncState(), + refreshRollbacks(), ]) if (cancelled) return setEnabled(settings.enabled) @@ -190,6 +217,8 @@ export function ConfigSyncSettings() { serverUrl: settings.serverUrl, username: settings.username, }) + setEncrypt(settings.encrypt) + setHasPassphrase(settings.hasPassphrase) setRemoteDir(settings.remoteDir) setProfile(settings.profile) setAutoSync(settings.autoSync) @@ -205,12 +234,11 @@ export function ConfigSyncSettings() { return () => { cancelled = true } - }, [desktop]) + }, [refreshRollbacks]) // The background uploader reports here; without this the panel would show a // stale "last synced" until the page is reopened. useEffect(() => { - if (!desktop) return let unlisten: (() => void) | null = null let disposed = false void listenConfigSyncStatus((event) => { @@ -224,7 +252,7 @@ export function ConfigSyncSettings() { disposed = true unlisten?.() } - }, [desktop]) + }, []) const currentInput = useCallback( ( @@ -234,6 +262,8 @@ export function ConfigSyncSettings() { serverUrl: serverUrl.trim(), username: username.trim(), password: password.length > 0 ? password : null, + passphrase: passphrase.length > 0 ? passphrase : null, + encrypt, remoteDir: remoteDir.trim(), profile: profile.trim(), autoSync, @@ -245,6 +275,8 @@ export function ConfigSyncSettings() { serverUrl, username, password, + passphrase, + encrypt, remoteDir, profile, autoSync, @@ -258,6 +290,7 @@ export function ConfigSyncSettings() { const saved = await updateConfigSyncSettings(currentInput()) if (!mounted.current) return setHasPassword(saved.hasPassword) + setHasPassphrase(saved.hasPassphrase) setSavedAccount({ serverUrl: saved.serverUrl, username: saved.username, @@ -265,9 +298,10 @@ export function ConfigSyncSettings() { setRemoteDir(saved.remoteDir) setProfile(saved.profile) setIntervalMinutes(saved.intervalMinutes) - // Clear the field once it is stored, so a second save does not re-send - // a value the user cannot see. + // Clear the fields once they are stored, so a second save does not + // re-send values the user cannot see. setPassword("") + setPassphrase("") toast.success(t("saved")) } catch (err) { toast.error(localize(err)) @@ -294,11 +328,13 @@ export function ConfigSyncSettings() { ) if (!mounted.current) return setHasPassword(saved.hasPassword) + setHasPassphrase(saved.hasPassphrase) setSavedAccount({ serverUrl: saved.serverUrl, username: saved.username, }) setPassword("") + setPassphrase("") } catch (err) { if (mounted.current) setEnabled(previous) toast.error(localize(err)) @@ -370,12 +406,14 @@ export function ConfigSyncSettings() { toast.success(t("restored", { count: outcome.applied.total }), { description: t("restartHint"), }) + // A restore just wrote a rollback point; the undo list has to show it. + await refreshRollbacks() } catch (err) { toast.error(localize(err)) } finally { if (mounted.current) setBusy(null) } - }, [localize, t]) + }, [localize, refreshRollbacks, t]) const handleExport = useCallback(async () => { setBusy("export") @@ -404,24 +442,52 @@ export function ConfigSyncSettings() { const handleConfirmImport = useCallback(async () => { if (!pendingImport) return - const path = pendingImport.path + const source = pendingImport.source setPendingImport(null) setBusy("import") try { - const result = await importConfigFromFile(path) + const result = await importPickedConfig(source) if (mounted.current) { toast.success(t("imported", { count: result.applied.total }), { description: t("restartHint"), }) } + await refreshRollbacks() } catch (err) { toast.error(localize(err)) } finally { if (mounted.current) setBusy(null) } - }, [pendingImport, localize, t]) + }, [pendingImport, localize, refreshRollbacks, t]) - if (!desktop) return null + /** + * Undo an import or a restore. The backend captures the pre-apply state + * every time either one runs; without this the snapshots were written, + * pruned, and never reachable from the product. + */ + const handleConfirmRollback = useCallback(async () => { + if (!pendingRollback) return + const id = pendingRollback.id + setPendingRollback(null) + setBusy("rollback") + try { + const result = await applyConfigRollback(id) + if (mounted.current) { + toast.success(t("rolledBack", { count: result.applied.total }), { + description: t("restartHint"), + }) + } + // The undo wrote its own rollback point, so it is itself undoable. + await refreshRollbacks() + } catch (err) { + toast.error(localize(err)) + // A snapshot that has since been pruned must disappear from the list + // rather than stay there offering a button that fails. + await refreshRollbacks() + } finally { + if (mounted.current) setBusy(null) + } + }, [pendingRollback, localize, refreshRollbacks, t]) const syncedLabel = formatTimestamp(lastSyncAt) // Only the hosted services need a setup note; "custom" has nothing to say. @@ -517,13 +583,59 @@ export function ConfigSyncSettings() {

{t("fileHint")}

+ {/* Only once there is something to undo — an empty list is noise. */} + {rollbacks.length > 0 ? ( +
+ +

{t("rollbackHint")}

+
    + {rollbacks.map((snapshot) => ( +
  • +
    +

    + {formatTimestamp(snapshot.createdAt) ?? + t("rollbackUnknownTime")} +

    + +
    + +
  • + ))} +
+
+ ) : null} +
- +

{t("webdavHint")}

void handleToggleEnabled(next)} disabled={!loaded || busy !== null} @@ -651,7 +763,56 @@ export function ConfigSyncSettings() {
-
+ +
+ + {encrypt ? ( +
+ + setPassphrase(e.target.value)} + placeholder={ + hasPassphrase + ? t("passphraseKeep") + : t("passphrasePlaceholder") + } + autoComplete="new-password" + /> +

+ {t("passphraseHint")} +

+
+ ) : null} + +
+
+

@@ -659,6 +820,7 @@ export function ConfigSyncSettings() {

{t("working")}

: null}
-
- -

- {t("plaintextWarning")} -

-
+ {/* The warning has to stop saying "plaintext" the moment it stops + being true, or it trains the user to ignore it. */} + {encrypt ? ( +
+ +

+ {t("encryptedNotice")} +

+
+ ) : ( +
+ +

+ {t("plaintextWarning")} +

+
+ )}
) : null}
@@ -797,6 +970,45 @@ export function ConfigSyncSettings() { + { + if (!open) setPendingRollback(null) + }} + > + + + {t("rollbackConfirmTitle")} + +
+

+ {t("rollbackConfirmBody", { + time: + formatTimestamp(pendingRollback?.createdAt ?? null) ?? + t("rollbackUnknownTime"), + })} +

+ {pendingRollback ? ( + + ) : null} +

{t("restartHint")}

+
+
+
+ + {t("cancel")} + { + e.preventDefault() + void handleConfirmRollback() + }} + > + {t("rollbackConfirmAction")} + + +
+
+ diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 859d446933..c7ef36316f 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4777,7 +4777,22 @@ "quickMessages": "{count, plural, one {رسالة سريعة واحدة} other {# رسالة سريعة}}", "taskTemplates": "{count, plural, one {قالب مهمة واحد} other {# قالب مهمة}}", "preferences": "{count, plural, one {تفضيل واحد} other {# تفضيل}}" - } + }, + "rollbackTitle": "التراجع عن استيراد", + "rollbackHint": "يحفظ كل استيراد وكل استعادة حالة هذا الجهاز قبل التطبيق، ويُحتفظ بآخر عشر نسخ.", + "rollbackAction": "تراجع", + "rollbackUnknownTime": "الوقت غير معروف", + "rollbackConfirmTitle": "العودة إلى هذا الإعداد؟", + "rollbackConfirmBody": "سيحل محل الإعداد الحالي النسخةُ المحفوظة في {time}. يُحفظ الإعداد الحالي أولًا، لذا يمكن التراجع عن هذه الخطوة أيضًا.", + "rollbackConfirmAction": "تراجع", + "rolledBack": "{count, plural, one {تمت استعادة عنصر واحد} other {تمت استعادة # عنصرًا}}", + "encryptLabel": "تشفير اللقطة", + "encryptHint": "احمِ الملف المرفوع بعبارة مرور. تحتاج كل الأجهزة المتزامنة إلى العبارة نفسها.", + "encryptedNotice": "تُشفَّر اللقطة بعبارة المرور قبل رفعها. إذا فقدت العبارة تعذّرت استعادة النسخة الموجودة على الخادم.", + "passphrase": "عبارة المرور", + "passphraseKeep": "اتركه فارغًا للإبقاء على عبارة المرور المحفوظة", + "passphrasePlaceholder": "عبارة مرور التشفير", + "passphraseHint": "تُحفظ في سلسلة مفاتيح هذا الجهاز ولا تُرفع أبدًا. أدخل العبارة نفسها على أجهزتك الأخرى." }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "المجلد البعيد أو اسم الملف التعريفي غير صالح.", "quota": "لم تعد هناك مساحة على خادم WebDAV.", "network": "تعذر الوصول إلى خادم WebDAV.", - "server": "أعاد خادم WebDAV خطأ ({status})." + "server": "أعاد خادم WebDAV خطأ ({status}).", + "passphraseRequired": "هذه اللقطة مشفَّرة. اضبط عبارة مرور التشفير قبل المزامنة.", + "badPassphrase": "تعذّر فك تشفير اللقطة. تحقق من عبارة المرور، وقد يكون الملف تالفًا أيضًا.", + "badDomain": "تعذّرت قراءة قسم {domain} من هذه اللقطة.", + "noRollback": "لم يعد ذلك الإعداد المحفوظ موجودًا على هذا الجهاز.", + "credentialsUnreadable": "تعذّرت قراءة بيانات الاعتماد المحفوظة. افتح قفل سلسلة مفاتيح النظام ثم أعد المحاولة.", + "notEncrypted": "اللقطة الموجودة على الخادم غير مشفّرة، لكن التشفير مُفعّل على هذا الجهاز. ارفع نسخة من هذا الجهاز لاستبدالها، أو أوقف التشفير لاستخدامها." } }, "WebConnection": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 5b49e8e7ca..b481071dba 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4777,7 +4777,22 @@ "quickMessages": "{count, plural, one {# Schnellnachricht} other {# Schnellnachrichten}}", "taskTemplates": "{count, plural, one {# Aufgabenvorlage} other {# Aufgabenvorlagen}}", "preferences": "{count, plural, one {# Einstellung} other {# Einstellungen}}" - } + }, + "rollbackTitle": "Import rückgängig machen", + "rollbackHint": "Jeder Import und jede Wiederherstellung sichern vorher den Stand dieses Geräts. Die letzten zehn bleiben erhalten.", + "rollbackAction": "Rückgängig", + "rollbackUnknownTime": "Zeit unbekannt", + "rollbackConfirmTitle": "Zu dieser Konfiguration zurückkehren?", + "rollbackConfirmBody": "Die aktuelle Konfiguration wird durch die Kopie vom {time} ersetzt. Die aktuelle wird vorher gesichert, dieser Schritt lässt sich also ebenfalls rückgängig machen.", + "rollbackConfirmAction": "Rückgängig", + "rolledBack": "{count, plural, one {# Eintrag wiederhergestellt} other {# Einträge wiederhergestellt}}", + "encryptLabel": "Snapshot verschlüsseln", + "encryptHint": "Schützt die hochgeladene Datei mit einer Passphrase. Jedes synchronisierte Gerät braucht dieselbe.", + "encryptedNotice": "Der Snapshot wird vor dem Hochladen mit deiner Passphrase verschlüsselt. Geht sie verloren, lässt sich die Kopie auf dem Server nicht wiederherstellen.", + "passphrase": "Passphrase", + "passphraseKeep": "Leer lassen, um die gespeicherte Passphrase zu behalten", + "passphrasePlaceholder": "Passphrase zur Verschlüsselung", + "passphraseHint": "Bleibt im Schlüsselbund dieses Geräts und wird nie hochgeladen. Gib auf deinen anderen Geräten dieselbe Passphrase ein." }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "Der entfernte Ordner oder der Profilname ist ungültig.", "quota": "Auf dem WebDAV-Server ist kein Speicherplatz mehr frei.", "network": "Der WebDAV-Server war nicht erreichbar.", - "server": "Der WebDAV-Server hat einen Fehler zurückgegeben ({status})." + "server": "Der WebDAV-Server hat einen Fehler zurückgegeben ({status}).", + "passphraseRequired": "Dieser Snapshot ist verschlüsselt. Lege vor dem Synchronisieren die Passphrase fest.", + "badPassphrase": "Der Snapshot konnte nicht entschlüsselt werden. Prüfe die Passphrase – die Datei kann auch beschädigt sein.", + "badDomain": "Der Abschnitt {domain} dieses Snapshots konnte nicht gelesen werden.", + "noRollback": "Diese gespeicherte Konfiguration ist auf diesem Gerät nicht mehr vorhanden.", + "credentialsUnreadable": "Die gespeicherten Zugangsdaten konnten nicht gelesen werden. Entsperre den Systemschlüsselbund und versuche es erneut.", + "notEncrypted": "Der Snapshot auf dem Server ist nicht verschlüsselt, auf diesem Gerät ist die Verschlüsselung aber aktiviert. Lade von diesem Gerät hoch, um ihn zu ersetzen, oder deaktiviere die Verschlüsselung, um ihn zu verwenden." } }, "WebConnection": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index a8f10c5cf7..01df1f8b00 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4777,7 +4777,22 @@ "quickMessages": "{count, plural, one {# quick message} other {# quick messages}}", "taskTemplates": "{count, plural, one {# task template} other {# task templates}}", "preferences": "{count, plural, one {# preference} other {# preferences}}" - } + }, + "rollbackTitle": "Undo an import", + "rollbackHint": "Every import and every restore saves what this machine looked like beforehand. The last ten are kept.", + "rollbackAction": "Undo", + "rollbackUnknownTime": "Unknown time", + "rollbackConfirmTitle": "Go back to this configuration?", + "rollbackConfirmBody": "This replaces the current configuration with the copy saved on {time}. The current one is saved first, so this is itself undoable.", + "rollbackConfirmAction": "Undo", + "rolledBack": "{count, plural, one {# entry restored} other {# entries restored}}", + "encryptLabel": "Encrypt the snapshot", + "encryptHint": "Protect the uploaded file with a passphrase. Every machine you sync needs the same one.", + "encryptedNotice": "The snapshot is encrypted with your passphrase before it is uploaded. If you lose the passphrase, the copy on the server cannot be recovered.", + "passphrase": "Passphrase", + "passphraseKeep": "Leave empty to keep the saved passphrase", + "passphrasePlaceholder": "Encryption passphrase", + "passphraseHint": "Kept in this machine's keychain and never uploaded. Enter the same passphrase on your other machines." }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "The remote folder or profile name is not valid.", "quota": "The WebDAV server has no space left.", "network": "Could not reach the WebDAV server.", - "server": "The WebDAV server returned an error ({status})." + "server": "The WebDAV server returned an error ({status}).", + "passphraseRequired": "This snapshot is encrypted. Set the encryption passphrase before syncing.", + "badPassphrase": "The snapshot could not be decrypted. Check the passphrase — or the file may be damaged.", + "badDomain": "The {domain} section of this snapshot could not be read.", + "noRollback": "That saved configuration is no longer on this machine.", + "credentialsUnreadable": "Could not read the saved credentials. Unlock the system keychain, then try again.", + "notEncrypted": "The snapshot on the server is not encrypted, but encryption is turned on here. Upload from this device to replace it, or turn encryption off to use it." } }, "WebConnection": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 3f15e7f834..ad1437d91b 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4777,7 +4777,22 @@ "quickMessages": "{count, plural, one {# mensaje rápido} other {# mensajes rápidos}}", "taskTemplates": "{count, plural, one {# plantilla de tarea} other {# plantillas de tareas}}", "preferences": "{count, plural, one {# preferencia} other {# preferencias}}" - } + }, + "rollbackTitle": "Deshacer una importación", + "rollbackHint": "Cada importación y cada restauración guardan antes el estado de este equipo. Se conservan las diez últimas.", + "rollbackAction": "Deshacer", + "rollbackUnknownTime": "Hora desconocida", + "rollbackConfirmTitle": "¿Volver a esta configuración?", + "rollbackConfirmBody": "Se reemplazará la configuración actual por la copia guardada el {time}. La actual se guarda primero, así que esto también se puede deshacer.", + "rollbackConfirmAction": "Deshacer", + "rolledBack": "{count, plural, one {# entrada restaurada} other {# entradas restauradas}}", + "encryptLabel": "Cifrar la instantánea", + "encryptHint": "Protege el archivo subido con una frase de contraseña. Todos los equipos que sincronices necesitan la misma.", + "encryptedNotice": "La instantánea se cifra con tu frase de contraseña antes de subirse. Si la pierdes, la copia del servidor no se podrá recuperar.", + "passphrase": "Frase de contraseña", + "passphraseKeep": "Déjalo vacío para conservar la frase guardada", + "passphrasePlaceholder": "Frase de contraseña de cifrado", + "passphraseHint": "Se guarda en el llavero de este equipo y nunca se sube. Introduce la misma frase en tus otros equipos." }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "La carpeta remota o el nombre del perfil no son válidos.", "quota": "El servidor WebDAV no tiene espacio disponible.", "network": "No se pudo contactar con el servidor WebDAV.", - "server": "El servidor WebDAV devolvió un error ({status})." + "server": "El servidor WebDAV devolvió un error ({status}).", + "passphraseRequired": "Esta instantánea está cifrada. Configura la frase de contraseña antes de sincronizar.", + "badPassphrase": "No se pudo descifrar la instantánea. Comprueba la frase de contraseña; el archivo también podría estar dañado.", + "badDomain": "No se pudo leer la sección {domain} de esta instantánea.", + "noRollback": "Esa configuración guardada ya no está en este equipo.", + "credentialsUnreadable": "No se pudieron leer las credenciales guardadas. Desbloquea el llavero del sistema e inténtalo de nuevo.", + "notEncrypted": "La instantánea del servidor no está cifrada, pero el cifrado está activado en este dispositivo. Súbela desde este dispositivo para reemplazarla o desactiva el cifrado para usarla." } }, "WebConnection": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 12f21a2a4c..5389056593 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4777,7 +4777,22 @@ "quickMessages": "{count, plural, one {# message rapide} other {# messages rapides}}", "taskTemplates": "{count, plural, one {# modèle de tâche} other {# modèles de tâches}}", "preferences": "{count, plural, one {# préférence} other {# préférences}}" - } + }, + "rollbackTitle": "Annuler un import", + "rollbackHint": "Chaque import et chaque restauration enregistrent d'abord l'état de cette machine. Les dix derniers sont conservés.", + "rollbackAction": "Annuler", + "rollbackUnknownTime": "Heure inconnue", + "rollbackConfirmTitle": "Revenir à cette configuration ?", + "rollbackConfirmBody": "La configuration actuelle sera remplacée par la copie enregistrée le {time}. L'actuelle est sauvegardée au préalable, cette étape est donc elle aussi réversible.", + "rollbackConfirmAction": "Annuler", + "rolledBack": "{count, plural, one {# entrée restaurée} other {# entrées restaurées}}", + "encryptLabel": "Chiffrer l'instantané", + "encryptHint": "Protège le fichier envoyé par une phrase secrète. Toutes les machines synchronisées doivent utiliser la même.", + "encryptedNotice": "L'instantané est chiffré avec votre phrase secrète avant l'envoi. Si vous la perdez, la copie sur le serveur sera irrécupérable.", + "passphrase": "Phrase secrète", + "passphraseKeep": "Laissez vide pour conserver la phrase enregistrée", + "passphrasePlaceholder": "Phrase secrète de chiffrement", + "passphraseHint": "Conservée dans le trousseau de cette machine et jamais envoyée. Saisissez la même phrase sur vos autres machines." }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "Le dossier distant ou le nom de profil n’est pas valide.", "quota": "Le serveur WebDAV n’a plus d’espace disponible.", "network": "Impossible de joindre le serveur WebDAV.", - "server": "Le serveur WebDAV a renvoyé une erreur ({status})." + "server": "Le serveur WebDAV a renvoyé une erreur ({status}).", + "passphraseRequired": "Cet instantané est chiffré. Définissez la phrase secrète avant de synchroniser.", + "badPassphrase": "Impossible de déchiffrer l'instantané. Vérifiez la phrase secrète — le fichier peut aussi être endommagé.", + "badDomain": "La section {domain} de cet instantané n'a pas pu être lue.", + "noRollback": "Cette configuration enregistrée n'est plus sur cette machine.", + "credentialsUnreadable": "Impossible de lire les identifiants enregistrés. Déverrouillez le trousseau du système, puis réessayez.", + "notEncrypted": "L'instantané présent sur le serveur n'est pas chiffré, alors que le chiffrement est activé sur cet appareil. Envoyez-le depuis cet appareil pour le remplacer, ou désactivez le chiffrement pour l'utiliser." } }, "WebConnection": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 74f93a2d4c..2bda5657a2 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4777,7 +4777,22 @@ "quickMessages": "クイックメッセージ {count} 件", "taskTemplates": "タスクテンプレート {count} 件", "preferences": "アプリ設定 {count} 件" - } + }, + "rollbackTitle": "インポートを元に戻す", + "rollbackHint": "インポートと復元のたびに、直前のこのマシンの設定を保存します。最新の 10 件が残ります。", + "rollbackAction": "元に戻す", + "rollbackUnknownTime": "時刻不明", + "rollbackConfirmTitle": "この設定に戻しますか?", + "rollbackConfirmBody": "現在の設定を {time} に保存されたコピーで置き換えます。現在の設定は先に保存されるので、この操作自体も元に戻せます。", + "rollbackConfirmAction": "元に戻す", + "rolledBack": "{count} 件の設定を復元しました", + "encryptLabel": "スナップショットを暗号化", + "encryptHint": "アップロードするファイルをパスフレーズで保護します。同期するすべてのマシンで同じものが必要です。", + "encryptedNotice": "スナップショットはアップロード前にパスフレーズで暗号化されます。パスフレーズを失うと、サーバー上のコピーは復元できません。", + "passphrase": "パスフレーズ", + "passphraseKeep": "空のままにすると保存済みのパスフレーズを使います", + "passphrasePlaceholder": "暗号化パスフレーズ", + "passphraseHint": "このマシンのキーチェーンにのみ保存され、アップロードされません。他のマシンでも同じパスフレーズを入力してください。" }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "リモートフォルダーまたはプロファイル名が無効です。", "quota": "WebDAV サーバーの空き容量がありません。", "network": "WebDAV サーバーに接続できませんでした。", - "server": "WebDAV サーバーがエラーを返しました({status})。" + "server": "WebDAV サーバーがエラーを返しました({status})。", + "passphraseRequired": "このスナップショットは暗号化されています。同期する前に暗号化パスフレーズを設定してください。", + "badPassphrase": "スナップショットを復号できませんでした。パスフレーズを確認してください。ファイルが壊れている可能性もあります。", + "badDomain": "このスナップショットの {domain} の部分を読み取れませんでした。", + "noRollback": "その保存済みの設定はこのマシンにもう残っていません。", + "credentialsUnreadable": "保存された認証情報を読み取れませんでした。システムキーチェーンのロックを解除してから再試行してください。", + "notEncrypted": "サーバー上のスナップショットは暗号化されていませんが、このデバイスでは暗号化が有効です。このデバイスからアップロードして置き換えるか、暗号化をオフにして使用してください。" } }, "WebConnection": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 967e6b5608..31b412ca83 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4777,7 +4777,22 @@ "quickMessages": "빠른 메시지 {count}개", "taskTemplates": "작업 템플릿 {count}개", "preferences": "앱 환경설정 {count}개" - } + }, + "rollbackTitle": "가져오기 되돌리기", + "rollbackHint": "가져오기와 복원을 할 때마다 직전의 이 기기 설정을 저장합니다. 최근 10개가 남습니다.", + "rollbackAction": "되돌리기", + "rollbackUnknownTime": "시간 불명", + "rollbackConfirmTitle": "이 설정으로 되돌릴까요?", + "rollbackConfirmBody": "현재 설정을 {time}에 저장된 사본으로 바꿉니다. 현재 설정을 먼저 저장하므로 이 작업도 되돌릴 수 있습니다.", + "rollbackConfirmAction": "되돌리기", + "rolledBack": "{count}개 항목을 복원했습니다", + "encryptLabel": "스냅샷 암호화", + "encryptHint": "업로드하는 파일을 암호구로 보호합니다. 동기화하는 모든 기기에 같은 암호구가 필요합니다.", + "encryptedNotice": "스냅샷은 업로드 전에 암호구로 암호화됩니다. 암호구를 잃어버리면 서버의 사본은 복구할 수 없습니다.", + "passphrase": "암호구", + "passphraseKeep": "비워 두면 저장된 암호구를 사용합니다", + "passphrasePlaceholder": "암호화 암호구", + "passphraseHint": "이 기기의 키체인에만 저장되며 업로드되지 않습니다. 다른 기기에도 같은 암호구를 입력하세요." }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "원격 폴더 또는 프로필 이름이 올바르지 않습니다.", "quota": "WebDAV 서버에 남은 공간이 없습니다.", "network": "WebDAV 서버에 연결할 수 없습니다.", - "server": "WebDAV 서버가 오류를 반환했습니다({status})." + "server": "WebDAV 서버가 오류를 반환했습니다({status}).", + "passphraseRequired": "이 스냅샷은 암호화되어 있습니다. 동기화하기 전에 암호화 암호구를 설정하세요.", + "badPassphrase": "스냅샷을 복호화하지 못했습니다. 암호구를 확인하세요. 파일이 손상되었을 수도 있습니다.", + "badDomain": "이 스냅샷의 {domain} 부분을 읽지 못했습니다.", + "noRollback": "저장된 그 설정은 이 기기에 더 이상 없습니다.", + "credentialsUnreadable": "저장된 자격 증명을 읽지 못했습니다. 시스템 키체인을 잠금 해제한 후 다시 시도하세요.", + "notEncrypted": "서버의 스냅샷은 암호화되어 있지 않지만 이 기기에서는 암호화가 켜져 있습니다. 이 기기에서 업로드해 교체하거나 암호화를 끄고 사용하세요." } }, "WebConnection": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index d6377ea2aa..d6f35ec46f 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4777,7 +4777,22 @@ "quickMessages": "{count, plural, one {# mensagem rápida} other {# mensagens rápidas}}", "taskTemplates": "{count, plural, one {# modelo de tarefa} other {# modelos de tarefa}}", "preferences": "{count, plural, one {# preferência} other {# preferências}}" - } + }, + "rollbackTitle": "Desfazer uma importação", + "rollbackHint": "Cada importação e cada restauração guardam antes o estado desta máquina. Os dez últimos são mantidos.", + "rollbackAction": "Desfazer", + "rollbackUnknownTime": "Hora desconhecida", + "rollbackConfirmTitle": "Voltar a esta configuração?", + "rollbackConfirmBody": "A configuração atual será substituída pela cópia guardada em {time}. A atual é guardada primeiro, por isso este passo também pode ser desfeito.", + "rollbackConfirmAction": "Desfazer", + "rolledBack": "{count, plural, one {# entrada restaurada} other {# entradas restauradas}}", + "encryptLabel": "Cifrar o snapshot", + "encryptHint": "Protege o ficheiro enviado com uma frase-passe. Todas as máquinas sincronizadas precisam da mesma.", + "encryptedNotice": "O snapshot é cifrado com a sua frase-passe antes do envio. Se a perder, a cópia no servidor não poderá ser recuperada.", + "passphrase": "Frase-passe", + "passphraseKeep": "Deixe vazio para manter a frase-passe guardada", + "passphrasePlaceholder": "Frase-passe de cifra", + "passphraseHint": "Fica no porta-chaves desta máquina e nunca é enviada. Introduza a mesma frase-passe nas suas outras máquinas." }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "A pasta remota ou o nome do perfil não é válido.", "quota": "O servidor WebDAV não tem mais espaço.", "network": "Não foi possível alcançar o servidor WebDAV.", - "server": "O servidor WebDAV retornou um erro ({status})." + "server": "O servidor WebDAV retornou um erro ({status}).", + "passphraseRequired": "Este snapshot está cifrado. Defina a frase-passe antes de sincronizar.", + "badPassphrase": "Não foi possível decifrar o snapshot. Verifique a frase-passe — o ficheiro também pode estar danificado.", + "badDomain": "Não foi possível ler a secção {domain} deste snapshot.", + "noRollback": "Essa configuração guardada já não está nesta máquina.", + "credentialsUnreadable": "Não foi possível ler as credenciais salvas. Desbloqueie o chaveiro do sistema e tente novamente.", + "notEncrypted": "O instantâneo no servidor não está criptografado, mas a criptografia está ativada neste dispositivo. Envie a partir deste dispositivo para substituí-lo ou desative a criptografia para usá-lo." } }, "WebConnection": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index d4fa9b86d7..197bacdff3 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4777,7 +4777,22 @@ "quickMessages": "{count} 条快捷消息", "taskTemplates": "{count} 个任务模板", "preferences": "{count} 项应用偏好" - } + }, + "rollbackTitle": "撤销导入", + "rollbackHint": "每次导入和每次还原前,都会先保存这台设备当时的配置,最近十份会保留下来。", + "rollbackAction": "撤销", + "rollbackUnknownTime": "时间未知", + "rollbackConfirmTitle": "回到这份配置?", + "rollbackConfirmBody": "这会用 {time} 保存的那份副本替换当前配置。当前配置会先被保存下来,所以这一步同样可以撤销。", + "rollbackConfirmAction": "撤销", + "rolledBack": "已恢复 {count} 条配置", + "encryptLabel": "加密快照", + "encryptHint": "用口令保护上传的文件。参与同步的每台设备都要使用同一个口令。", + "encryptedNotice": "快照会先用你的口令加密再上传。口令一旦丢失,服务器上的那份副本将无法恢复。", + "passphrase": "加密口令", + "passphraseKeep": "留空则沿用已保存的口令", + "passphrasePlaceholder": "加密口令", + "passphraseHint": "只保存在这台设备的钥匙串里,不会上传。请在你的其他设备上输入同一个口令。" }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "远程目录或配置集名称无效。", "quota": "WebDAV 服务器已无可用空间。", "network": "无法连接到 WebDAV 服务器。", - "server": "WebDAV 服务器返回错误({status})。" + "server": "WebDAV 服务器返回错误({status})。", + "passphraseRequired": "该快照已加密,请先设置加密口令再同步。", + "badPassphrase": "无法解密该快照。请检查口令,也可能是文件已损坏。", + "badDomain": "无法读取该快照中的 {domain} 部分。", + "noRollback": "这台设备上已经没有那份保存的配置了。", + "credentialsUnreadable": "无法读取已保存的凭据。请解锁系统钥匙串后重试。", + "notEncrypted": "服务器上的快照未加密,但本机已开启加密。请从本机上传以替换它,或关闭加密后再使用。" } }, "WebConnection": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index de33aef081..cefc12eda4 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4777,7 +4777,22 @@ "quickMessages": "{count} 則快捷訊息", "taskTemplates": "{count} 個任務範本", "preferences": "{count} 項應用偏好" - } + }, + "rollbackTitle": "復原匯入", + "rollbackHint": "每次匯入與每次還原前,都會先保存這台裝置當時的設定,最近十份會保留下來。", + "rollbackAction": "復原", + "rollbackUnknownTime": "時間不明", + "rollbackConfirmTitle": "回到這份設定?", + "rollbackConfirmBody": "這會用 {time} 保存的那份副本取代目前的設定。目前的設定會先被保存,所以這一步同樣可以復原。", + "rollbackConfirmAction": "復原", + "rolledBack": "已還原 {count} 項設定", + "encryptLabel": "加密快照", + "encryptHint": "用通行密語保護上傳的檔案。參與同步的每台裝置都要使用同一組。", + "encryptedNotice": "快照會先用你的通行密語加密再上傳。一旦遺失,伺服器上的副本將無法復原。", + "passphrase": "通行密語", + "passphraseKeep": "留空則沿用已保存的通行密語", + "passphrasePlaceholder": "加密通行密語", + "passphraseHint": "只保存在這台裝置的鑰匙圈中,不會上傳。請在你的其他裝置上輸入同一組。" }, "backup": { "restore": { @@ -4806,7 +4821,13 @@ "remotePath": "遠端目錄或設定集名稱無效。", "quota": "WebDAV 伺服器已無可用空間。", "network": "無法連線到 WebDAV 伺服器。", - "server": "WebDAV 伺服器回傳錯誤({status})。" + "server": "WebDAV 伺服器回傳錯誤({status})。", + "passphraseRequired": "該快照已加密,請先設定加密通行密語再同步。", + "badPassphrase": "無法解密該快照。請檢查通行密語,也可能是檔案已損毀。", + "badDomain": "無法讀取該快照中的 {domain} 部分。", + "noRollback": "這台裝置上已經沒有那份保存的設定了。", + "credentialsUnreadable": "無法讀取已儲存的憑證。請解鎖系統鑰匙圈後重試。", + "notEncrypted": "伺服器上的快照未加密,但本機已開啟加密。請從本機上傳以取代它,或關閉加密後再使用。" } }, "WebConnection": { diff --git a/src/lib/config-sync.ts b/src/lib/config-sync.ts index 68cb87d631..392ae7affb 100644 --- a/src/lib/config-sync.ts +++ b/src/lib/config-sync.ts @@ -9,11 +9,15 @@ * Kept out of `api.ts` on purpose — that module is already thousands of lines * and this feature has its own vocabulary. * - * Desktop-only in this version: file paths come from native dialogs and the - * commands are registered on the Tauri runtime, so the settings UI gates on - * `isDesktop()` rather than degrading here. + * Works in both runtimes. Everything WebDAV is runtime-agnostic; the only + * split is local file transfer, where a local desktop window uses native save + * and open dialogs and everything else (a browser, or a desktop window pointed + * at a remote server) moves the document's text through the same command. A + * config snapshot is tens of KB, so that costs one string — which is why this + * feature needs none of the upload-staging machinery `backup` uses. */ +import { isLocalDesktop } from "./platform" import { getTransport } from "./transport" /** Stable domain ids, matching `domains::CONFIG_DOMAINS` in Rust. */ @@ -86,8 +90,13 @@ export interface ConfigSyncSettingsView { enabled: boolean serverUrl: string username: string - /** The password itself never crosses the bridge. */ + /** The password itself never crosses the bridge — it lives in the OS + * keyring, and this says only whether one is on file. */ hasPassword: boolean + /** Wrap the uploaded snapshot in a passphrase-derived AES-256-GCM envelope. + * The manifest stays plaintext either way. */ + encrypt: boolean + hasPassphrase: boolean remoteDir: string profile: string autoSync: boolean @@ -101,12 +110,27 @@ export interface ConfigSyncSettingsInput { /** `null` or `""` keeps the stored password. Never send a placeholder — * the backend would store the placeholder verbatim. */ password: string | null + /** Same rule. Unlike the password it is not tied to the account, so editing + * the server URL does not orphan it. */ + passphrase: string | null + encrypt: boolean remoteDir: string profile: string autoSync: boolean intervalMinutes: number } +/** One pre-apply snapshot on this machine, as the settings panel lists it. */ +export interface RollbackSnapshot { + /** Opaque; the only value that may be sent back. */ + id: string + /** `null` when the file name carries no parseable stamp. */ + createdAt: string | null + size: number + /** What applying it would write, recounted from the payload. */ + counts: DomainCounts +} + export interface ConfigSyncState { lastUploadedSha256: string | null /** Which remote the hash above went to. The pair is what suppresses a @@ -150,11 +174,41 @@ export function defaultExportFileName(now: Date = new Date()): string { return `codeg-config-${stamp}.${CONFIG_EXPORT_EXTENSION}` } +/** Where an import's bytes are coming from. A local desktop window names a + * path the backend reads itself; everything else carries the text. */ +export type ConfigImportSource = + | { kind: "path"; path: string; label: string } + | { kind: "content"; content: string; label: string } + +export interface PickedConfigImport { + source: ConfigImportSource + preview: ConfigImportPreview +} + +/** The export as text, for the runtimes that save it client-side. */ +interface ConfigExportContent { + content: string + counts: DomainCounts +} + /** `null` when the user dismissed the save dialog. */ export async function exportConfigToFile(): Promise { + const fileName = defaultExportFileName() + + if (!isLocalDesktop()) { + const built = await getTransport().call( + "config_sync_export_content", + {} + ) + downloadTextFile(fileName, built.content) + // The browser owns the destination from here, so the "path" is the name + // it was offered under — the summary is only ever shown as a toast. + return { path: fileName, counts: built.counts } + } + const { save } = await import("@tauri-apps/plugin-dialog") const destPath = await save({ - defaultPath: defaultExportFileName(), + defaultPath: fileName, filters: [{ name: "Codeg config", extensions: ["json"] }], }) if (!destPath) return null @@ -165,10 +219,18 @@ export async function exportConfigToFile(): Promise /** Opens a file picker and inspects the choice WITHOUT applying it. `null` * when the dialog was dismissed. */ -export async function pickConfigFileToImport(): Promise<{ - path: string - preview: ConfigImportPreview -} | null> { +export async function pickConfigFileToImport(): Promise { + if (!isLocalDesktop()) { + const file = await pickLocalFile() + if (!file) return null + const content = await file.text() + const preview = await getTransport().call( + "config_sync_peek_content", + { content } + ) + return { source: { kind: "content", content, label: file.name }, preview } + } + const { open } = await import("@tauri-apps/plugin-dialog") const picked = await open({ multiple: false, @@ -181,14 +243,77 @@ export async function pickConfigFileToImport(): Promise<{ "config_sync_peek_file", { srcPath } ) - return { path: srcPath, preview } + return { source: { kind: "path", path: srcPath, label: srcPath }, preview } } -export async function importConfigFromFile( - srcPath: string +export async function importPickedConfig( + source: ConfigImportSource ): Promise { + if (source.kind === "content") { + return getTransport().call( + "config_sync_import_content", + { content: source.content } + ) + } return getTransport().call("config_sync_import_file", { - srcPath, + srcPath: source.path, + }) +} + +/** Newest first. Empty when nothing has ever been imported or restored. */ +export async function listConfigRollbacks(): Promise { + return getTransport().call( + "config_sync_list_rollbacks", + {} + ) +} + +/** Re-apply the configuration captured just before an import or a restore. + * Writes its own rollback point first, so the undo is itself undoable. */ +export async function applyConfigRollback( + id: string +): Promise { + return getTransport().call("config_sync_apply_rollback", { + id, + }) +} + +function downloadTextFile(fileName: string, content: string): void { + const url = URL.createObjectURL( + new Blob([content], { type: "application/json" }) + ) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = fileName + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + // Revoking synchronously can cancel the download in some browsers; one turn + // of the event loop is enough for the click to have been taken. + setTimeout(() => URL.revokeObjectURL(url), 0) +} + +/** `null` when the picker was dismissed. There is no cancel event for a file + * input in every browser we target, so a dismissed dialog simply never + * resolves to a file — the `cancel` event covers the modern ones and the + * promise is abandoned otherwise, which is the same outcome the user sees. */ +function pickLocalFile(): Promise { + return new Promise((resolve) => { + const input = document.createElement("input") + input.type = "file" + input.accept = ".json,application/json" + input.style.display = "none" + input.addEventListener("change", () => { + const file = input.files?.[0] ?? null + input.remove() + resolve(file) + }) + input.addEventListener("cancel", () => { + input.remove() + resolve(null) + }) + document.body.appendChild(input) + input.click() }) } From 1c3889120fd64bf34d564e0465347b157adbe9f8 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 14:55:00 +0800 Subject: [PATCH 5/9] fix(config-sync): six defects found reviewing the follow-up work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A forged manifest could switch encryption off.** `open_after_download` treated the manifest as the authority on whether the payload was wrapped, and the manifest is just a second file on the same share — so the operator this feature encrypts *against* could replace both halves with a plaintext snapshot of their choosing and a matching checksum. Every check passed and attacker-chosen provider endpoints and API keys landed in the local database while the switch read "encrypted". The manifest is now believed when it says "encrypted" and not when it says "plaintext"; the local switch decides that direction. **An unreadable keyring erased the secrets.** `credentials::load` mapped a failed read to `""`, and `""` means DELETE on the way back out, so a denied keychain prompt plus any unrelated save — nudging the interval — permanently destroyed the passphrase the remote copy is encrypted under. Reads now distinguish absent from unreadable, and a save that cannot read the store refuses instead of rewriting it. **The legacy-password migration clobbered the row.** It serialized this build's own struct over whatever was there, so a save landing in the window lost its edits — pairing the OLD server URL with the NEW password the keyring had just taken, the exact combination `merge_settings` refuses to create — and a row written by a newer build lost the fields this one cannot parse. It now removes a single key from the row as it stands. **The rollback list offered ids the resolver refused.** The lister took any file stem; the resolver holds ids to an alphabet. One copy through a file manager (`config-….json` → `config-… (1).json`) produced a Restore button that answered "no longer on this machine" about a file sitting right there. Both now share one definition. **An import wrote its rollback point to the wrong directory.** `save_rollback` called `rollback_dir()` while its callers took a directory parameter, so an undo driven against one directory wrote and pruned another — and the test suite was evicting snapshots from the developer's real `~/.codeg`. **The preview over-promised.** `count_entries` counted every key of the `preferences` object, but the applier writes only allowlisted keys holding strings, so the dialog said "3 preferences" and the import wrote 1. Counting is now a function on the domain table beside `apply`, held to it by a test, the same shape `validate` already uses. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/commands/config_sync/domains.rs | 118 ++++++++++++++++-- .../src/commands/config_sync/local_io.rs | 83 +++++++++--- src-tauri/src/commands/config_sync/mod.rs | 4 +- .../src/commands/config_sync/snapshot.rs | 35 +++++- .../src/commands/config_sync/webdav_sync.rs | 3 +- src-tauri/src/web/handlers/config_sync.rs | 2 +- 6 files changed, 213 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/commands/config_sync/domains.rs b/src-tauri/src/commands/config_sync/domains.rs index 57fa86e68b..e122a9746d 100644 --- a/src-tauri/src/commands/config_sync/domains.rs +++ b/src-tauri/src/commands/config_sync/domains.rs @@ -51,6 +51,7 @@ type ApplyFn = for<'a> fn( &'a Value, ) -> BoxFuture<'a, Result>; type ValidateFn = fn(&Value) -> Result<(), AppCommandError>; +type CountFn = fn(&Value) -> usize; /// One configuration domain: how it is read out of the local database, how it /// is checked before anything is written, and how it is written back in. @@ -68,6 +69,12 @@ pub struct ConfigDomain { /// against the same DTO type; `validate_matches_apply` holds the two /// together. pub validate: ValidateFn, + /// How many entries [`Self::apply`] would write. Beside the applier rather + /// than derived from the JSON container, because the two are not the same + /// number: `preferences` is an object whose non-portable and non-string + /// members are skipped on the way in, so counting its keys promises the + /// confirmation dialog rows that the import will not write. + pub count: CountFn, pub apply: ApplyFn, } @@ -80,36 +87,42 @@ pub const CONFIG_DOMAINS: &[ConfigDomain] = &[ id: DOMAIN_MODEL_PROVIDERS, collect: collect_model_providers, validate: validate_model_providers, + count: count_rows, apply: apply_model_providers, }, ConfigDomain { id: DOMAIN_AGENT_SETTINGS, collect: collect_agent_settings, validate: validate_agent_settings, + count: count_rows, apply: apply_agent_settings, }, ConfigDomain { id: DOMAIN_CUSTOM_AGENTS, collect: collect_custom_agents, validate: validate_custom_agents, + count: count_rows, apply: apply_custom_agents, }, ConfigDomain { id: DOMAIN_QUICK_MESSAGES, collect: collect_quick_messages, validate: validate_quick_messages, + count: count_rows, apply: apply_quick_messages, }, ConfigDomain { id: DOMAIN_TASK_TEMPLATES, collect: collect_task_templates, validate: validate_task_templates, + count: count_rows, apply: apply_task_templates, }, ConfigDomain { id: DOMAIN_PREFERENCES, collect: collect_preferences, validate: validate_preferences, + count: count_portable_preferences, apply: apply_preferences, }, ]; @@ -142,17 +155,40 @@ fn validate_preferences(_value: &Value) -> Result<(), AppCommandError> { Ok(()) } -/// How many entries a collected domain value holds — array length for row -/// domains, key count for `preferences`. Used for the manifest's `counts` and -/// for the import confirmation dialog. -pub fn count_entries(value: &Value) -> usize { +/// Row domains apply every element they decode, so the list length is the +/// answer. +fn count_rows(value: &Value) -> usize { match value { Value::Array(items) => items.len(), - Value::Object(map) => map.len(), _ => 0, } } +/// `preferences` does not. Its applier skips keys outside the portable +/// allowlist and values that are not strings, so the key count overstates what +/// an import writes — `{"appearanceMode":"dark","githubAccounts":"…"}` previews +/// as two and applies one. `count_matches_apply_on_every_domain` keeps this +/// filter and the applier's from drifting apart. +fn count_portable_preferences(value: &Value) -> usize { + match value { + Value::Object(map) => map + .iter() + .filter(|(key, entry)| is_portable_key(key) && entry.is_string()) + .count(), + _ => 0, + } +} + +/// How many entries applying this domain would write. Domains this build does +/// not know are not applied at all, so they count for nothing rather than for +/// however many elements they happen to contain. +pub fn count_entries(id: &str, value: &Value) -> usize { + CONFIG_DOMAINS + .iter() + .find(|domain| domain.id == id) + .map_or(0, |domain| (domain.count)(value)) +} + fn db_err(err: sea_orm::DbErr) -> AppCommandError { AppCommandError::db(crate::db::error::DbError::from(err)) } @@ -756,9 +792,27 @@ mod tests { #[test] fn count_entries_handles_arrays_objects_and_junk() { - assert_eq!(count_entries(&serde_json::json!([1, 2, 3])), 3); - assert_eq!(count_entries(&serde_json::json!({ "a": "b" })), 1); - assert_eq!(count_entries(&Value::Null), 0); + let rows = DOMAIN_QUICK_MESSAGES; + assert_eq!(count_entries(rows, &serde_json::json!([1, 2, 3])), 3); + assert_eq!(count_entries(rows, &Value::Null), 0); + // A row domain handed an object is junk, not one entry. + assert_eq!(count_entries(rows, &serde_json::json!({ "a": "b" })), 0); + + // Preferences count only what an import would write. + assert_eq!( + count_entries( + DOMAIN_PREFERENCES, + &serde_json::json!({ "appearance_mode": "dark", "github_accounts": "leaked" }) + ), + 1 + ); + + // A domain from a newer build is not applied, so it counts for nothing + // rather than advertising rows that will be skipped. + assert_eq!( + count_entries("somethingNewer", &serde_json::json!([1, 2, 3])), + 0 + ); } /// The whole point of `validate` is that it answers the same question the @@ -805,6 +859,54 @@ mod tests { } } + /// The other half of the same agreement. The confirmation dialog and the + /// manifest both quote `count`, and the user reads that as "this is what + /// will be written" — so whenever an apply succeeds, the number it reports + /// has to be the number that was promised. + /// + /// `preferences` is the one that can drift, because its applier filters and + /// its container does not: counting keys would promise the two entries + /// below that an import silently drops. + #[tokio::test] + async fn count_matches_apply_on_every_domain() { + use sea_orm::TransactionTrait; + + let portable = *PORTABLE_PREFERENCE_KEYS + .first() + .expect("at least one portable preference key"); + let mut mixed = Map::new(); + mixed.insert(portable.to_string(), Value::from("kept")); + // Not on the allowlist: refused on the way in, so it must not be + // counted on the way out. + mixed.insert("definitely_not_portable".to_string(), Value::from("dropped")); + // On the allowlist but not a string — preferences are stored as text, + // and the applier skips anything else rather than stringifying it. + mixed.insert("appearance_zoom_level".to_string(), Value::from(7)); + + let payloads = [ + serde_json::json!([]), + Value::Null, + serde_json::json!({}), + Value::Object(mixed), + ]; + + let db = crate::db::test_helpers::fresh_in_memory_db().await; + for domain in CONFIG_DOMAINS { + for payload in &payloads { + let promised = (domain.count)(payload); + let tx = db.conn.begin().await.expect("begin"); + let applied = (domain.apply)(&tx, payload).await; + tx.rollback().await.expect("rollback"); + let Ok(written) = applied else { continue }; + assert_eq!( + promised, written, + "domain '{}' promised {promised} and wrote {written} for {payload}", + domain.id + ); + } + } + } + /// And the pair is not vacuously in agreement: at least one of those /// payloads must actually be refused, or a `validate` stubbed out to /// `Ok(())` everywhere would pass the test above. diff --git a/src-tauri/src/commands/config_sync/local_io.rs b/src-tauri/src/commands/config_sync/local_io.rs index 6fb525b5f0..ffc17c2b05 100644 --- a/src-tauri/src/commands/config_sync/local_io.rs +++ b/src-tauri/src/commands/config_sync/local_io.rs @@ -25,7 +25,7 @@ //! archives measured in gigabytes. use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; @@ -34,8 +34,8 @@ use super::credentials::{self, SNAPSHOT_PASSPHRASE}; use super::crypto; use super::snapshot::{ apply_snapshot_core, build_manifest, collect_snapshot_core, read_rollback, resolve_rollback, - rollback_dir, serialize_snapshot, write_rollback_snapshot, ApplyReport, ConfigManifest, - ConfigSnapshot, ENCRYPTION_NONE, + serialize_snapshot, write_rollback_snapshot, ApplyReport, ConfigManifest, ConfigSnapshot, + ENCRYPTION_NONE, }; use crate::app_error::{AppCommandError, CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT}; @@ -238,24 +238,27 @@ fn preview_of(export: ConfigExportFile) -> Result Result { // Parse before writing the rollback snapshot: a malformed file should cost // the user nothing at all. let export = read_export_file(path)?; - apply_import(conn, &export.config).await + apply_import(conn, &export.config, rollbacks).await } pub async fn import_bytes_core( conn: &DatabaseConnection, bytes: &[u8], + rollbacks: &Path, ) -> Result { let export = parse_export_bytes(bytes, &stored_passphrase())?; - apply_import(conn, &export.config).await + apply_import(conn, &export.config, rollbacks).await } async fn apply_import( conn: &DatabaseConnection, snapshot: &ConfigSnapshot, + rollbacks: &Path, ) -> Result { // Hold the uploader off for the duration: applying rewrites local // configuration row by row, and a tick landing in the middle would push a @@ -263,7 +266,7 @@ async fn apply_import( // The upload the import DOES deserve happens on the next tick, once the // configuration is whole again. let _suppression = super::auto_sync::suppress_auto_sync(); - let rollback_path = save_rollback(conn).await; + let rollback_path = save_rollback(conn, rollbacks).await; let applied = apply_snapshot_core(conn, snapshot).await?; Ok(ConfigImportResult { applied, @@ -286,7 +289,7 @@ pub async fn apply_rollback_core( ) -> Result { let path = resolve_rollback(dir, id)?; let snapshot = read_rollback(&path)?; - apply_import(conn, &snapshot).await + apply_import(conn, &snapshot, dir).await } /// Newest first. Empty — never an error — when nothing has ever been imported: @@ -297,7 +300,13 @@ pub fn list_rollbacks_core(dir: &Path) -> Vec Option { +/// +/// `dir` is a parameter for the same reason the read side takes one. Calling +/// [`rollback_dir`] in here instead would mean an import driven against a +/// temporary directory still WRITES to — and prunes — the real one, so the two +/// halves of "undo" would disagree about where the snapshots are, and a test +/// would evict the developer's own. +pub async fn save_rollback(conn: &DatabaseConnection, dir: &Path) -> Option { let snapshot = match collect_snapshot_core(conn).await { Ok(snapshot) => snapshot, Err(err) => { @@ -305,8 +314,7 @@ pub async fn save_rollback(conn: &DatabaseConnection) -> Option { return None; } }; - let dir: PathBuf = rollback_dir(); - match write_rollback_snapshot(&dir, &snapshot) { + match write_rollback_snapshot(dir, &snapshot) { Ok(path) => Some(path.to_string_lossy().to_string()), Err(err) => { tracing::warn!("[CONFIG-SYNC] rollback snapshot not written: {err}"); @@ -356,7 +364,8 @@ mod tests { assert_eq!(preview.counts.get("quickMessages"), Some(&1)); let target = fresh_in_memory_db().await; - let result = import_from_file_core(&target.conn, &dest) + let rollbacks = tempfile::tempdir().expect("tempdir"); + let result = import_from_file_core(&target.conn, &dest, rollbacks.path()) .await .expect("import"); assert!(result.applied.total >= 1); @@ -419,7 +428,10 @@ mod tests { assert_eq!(preview.counts.get("quickMessages"), Some(&1)); let target = fresh_in_memory_db().await; - let result = import_bytes_core(&target.conn, &sealed).await.expect("import"); + let rollbacks = tempfile::tempdir().expect("tempdir"); + let result = import_bytes_core(&target.conn, &sealed, rollbacks.path()) + .await + .expect("import"); assert!(result.applied.total >= 1); credentials::store(SNAPSHOT_PASSPHRASE, "").expect("clear"); } @@ -447,7 +459,8 @@ mod tests { // And nothing is written when the import is attempted anyway. let target = fresh_in_memory_db().await; - import_bytes_core(&target.conn, &bytes) + let rollbacks = tempfile::tempdir().expect("tempdir"); + import_bytes_core(&target.conn, &bytes, rollbacks.path()) .await .expect_err("must refuse"); assert_eq!( @@ -484,7 +497,7 @@ mod tests { std::fs::write(&path, compact).expect("write"); let target = fresh_in_memory_db().await; - import_from_file_core(&target.conn, &path) + import_from_file_core(&target.conn, &path, dir.path()) .await .expect("import compact"); assert_eq!( @@ -538,6 +551,46 @@ mod tests { .await .is_err() ); + + // And the undo left its own undo point in the SAME directory it reads + // from — see the next test for why that is not automatic. + let after = list_rollbacks_core(dir.path()); + assert_eq!(after.len(), 2, "the undo must itself be undoable"); + } + + /// The write half and the read half of "undo" have to agree on where the + /// snapshots live. They did not: the reader took a directory and the writer + /// called [`rollback_dir`] regardless, so an import driven against a + /// temporary directory still wrote into — and PRUNED — the real + /// `~/.codeg/config-snapshots`. The button showed nothing while the test + /// suite quietly evicted the developer's own eleventh-newest snapshot. + #[tokio::test] + async fn an_import_saves_its_rollback_point_where_the_list_will_look() { + let rollbacks = tempfile::tempdir().expect("tempdir"); + let source = fresh_in_memory_db().await; + seed_message(&source.conn, "Incoming").await; + let export = build_export_core(&source.conn, "9.9.9").await.expect("export"); + let bytes = serde_json::to_vec(&export).expect("bytes"); + + let target = fresh_in_memory_db().await; + seed_message(&target.conn, "Local").await; + assert!(list_rollbacks_core(rollbacks.path()).is_empty()); + + let result = import_bytes_core(&target.conn, &bytes, rollbacks.path()) + .await + .expect("import"); + + let listed = list_rollbacks_core(rollbacks.path()); + assert_eq!(listed.len(), 1, "the rollback point went somewhere else"); + assert!( + result + .rollback_path + .as_deref() + .is_some_and(|path| path.starts_with(&*rollbacks.path().to_string_lossy())), + "reported {:?}, expected it under {}", + result.rollback_path, + rollbacks.path().display() + ); } #[tokio::test] @@ -547,7 +600,7 @@ mod tests { std::fs::write(&path, b"just some notes").expect("write"); let target = fresh_in_memory_db().await; - let err = import_from_file_core(&target.conn, &path) + let err = import_from_file_core(&target.conn, &path, dir.path()) .await .expect_err("must reject"); assert_eq!( diff --git a/src-tauri/src/commands/config_sync/mod.rs b/src-tauri/src/commands/config_sync/mod.rs index b536b4ded5..14dedd3a38 100644 --- a/src-tauri/src/commands/config_sync/mod.rs +++ b/src-tauri/src/commands/config_sync/mod.rs @@ -106,7 +106,7 @@ mod tauri_commands { src_path: String, db: State<'_, AppDatabase>, ) -> Result { - import_from_file_core(&db.conn, Path::new(&src_path)).await + import_from_file_core(&db.conn, Path::new(&src_path), &rollback_dir()).await } #[tauri::command] @@ -193,7 +193,7 @@ mod tauri_commands { content: String, db: State<'_, AppDatabase>, ) -> Result { - import_bytes_core(&db.conn, content.as_bytes()).await + import_bytes_core(&db.conn, content.as_bytes(), &rollback_dir()).await } // ── Rollback snapshots ── diff --git a/src-tauri/src/commands/config_sync/snapshot.rs b/src-tauri/src/commands/config_sync/snapshot.rs index b17b4f2e11..ad32ed177a 100644 --- a/src-tauri/src/commands/config_sync/snapshot.rs +++ b/src-tauri/src/commands/config_sync/snapshot.rs @@ -68,7 +68,7 @@ impl ConfigSnapshot { pub fn counts(&self) -> BTreeMap { self.domains .iter() - .map(|(id, value)| (id.clone(), count_entries(value))) + .map(|(id, value)| (id.clone(), count_entries(id, value))) .collect() } } @@ -464,9 +464,20 @@ pub fn list_rollback_infos(dir: &Path) -> Vec { infos } +/// The one definition of what an id looks like, shared by the lister and the +/// resolver. Splitting it in two is how a list ends up offering a Restore +/// button that the resolver then refuses: a file-manager copy that renames +/// collisions produces `config-20260917T101530123 (1).json`, whose stem parses +/// fine but is not an id this code will ever join to a path. +fn is_rollback_id(id: &str) -> bool { + id.strip_prefix("config-") + .is_some_and(|stamp| !stamp.is_empty() && stamp.bytes().all(|b| b.is_ascii_alphanumeric())) +} + fn rollback_id(path: &Path) -> Option { path.file_stem() .and_then(|stem| stem.to_str()) + .filter(|stem| is_rollback_id(stem)) .map(|stem| stem.to_string()) } @@ -487,10 +498,7 @@ fn created_at_from_id(id: &str) -> Option { /// leaves no way to express a separator, a parent link, or an extension and so /// no way for the join below to leave `dir`. pub fn resolve_rollback(dir: &Path, id: &str) -> Result { - let looks_like_ours = id - .strip_prefix("config-") - .is_some_and(|stamp| !stamp.is_empty() && stamp.bytes().all(|b| b.is_ascii_alphanumeric())); - if !looks_like_ours { + if !is_rollback_id(id) { return Err(missing_rollback_error()); } let path = dir.join(format!("{id}.json")); @@ -985,6 +993,16 @@ mod tests { std::fs::write(dir.path().join("config-20240101T000000000.json"), b"{oops") .expect("write junk"); + // A file manager renaming a collision on copy. The stem parses, so the + // lister used to publish it — and then Restore answered "no longer on + // this machine" about a file sitting right there, because the resolver + // holds ids to an alphabet with no room for a space or a bracket. + std::fs::copy( + dir.path().join(format!("{}.json", list_rollback_infos(dir.path())[0].id)), + dir.path().join("config-20240101T000000001 (1).json"), + ) + .expect("copy"); + let infos = list_rollback_infos(dir.path()); assert_eq!(infos.len(), 1, "the unparseable file must be skipped"); assert_eq!(infos[0].counts.get("quickMessages"), Some(&1)); @@ -996,6 +1014,13 @@ mod tests { .counts(), snapshot.counts() ); + + // The invariant behind both of those: the list is exactly the set of + // ids the resolver will accept, so no row in it can fail on click. + for info in &infos { + resolve_rollback(dir.path(), &info.id) + .unwrap_or_else(|_| panic!("listed id '{}' does not resolve", info.id)); + } } #[test] diff --git a/src-tauri/src/commands/config_sync/webdav_sync.rs b/src-tauri/src/commands/config_sync/webdav_sync.rs index b4cc3b4a2b..9fbc9aa6a1 100644 --- a/src-tauri/src/commands/config_sync/webdav_sync.rs +++ b/src-tauri/src/commands/config_sync/webdav_sync.rs @@ -735,7 +735,8 @@ pub async fn download_and_apply_core( // configuration, and an auto-sync tick landing mid-apply would push a // half-merged state straight back to the remote. let _suppression = super::auto_sync::suppress_auto_sync(); - let rollback_path = super::local_io::save_rollback(conn).await; + let rollback_path = + super::local_io::save_rollback(conn, &super::snapshot::rollback_dir()).await; let applied = apply_snapshot_core(conn, &snapshot).await?; Ok(DownloadOutcome { diff --git a/src-tauri/src/web/handlers/config_sync.rs b/src-tauri/src/web/handlers/config_sync.rs index dc3e833ca1..d6da73a148 100644 --- a/src-tauri/src/web/handlers/config_sync.rs +++ b/src-tauri/src/web/handlers/config_sync.rs @@ -121,7 +121,7 @@ pub async fn config_sync_import_content( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - import_bytes_core(&state.db.conn, params.content.as_bytes()) + import_bytes_core(&state.db.conn, params.content.as_bytes(), &rollback_dir()) .await .map(Json) } From ecdcfee07d8928b0b99f83acf3d435bca6523e2e Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 15:13:07 +0800 Subject: [PATCH 6/9] fix(config-sync): three more from the panel review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A withdrawn passphrase was stored anyway.** Turning the encryption switch back off hid the field without clearing its state, so the next Save — about anything at all — submitted the passphrase the user had just decided against, and `hasPassphrase` then claimed a protection they never confirmed. The switch clears what was typed; the one already on file survives, because it is what still decrypts the copy on the remote. **The master switch behaved like a Save button.** It persists on click by design (every other control lives inside the block it hides), but it was persisting the whole live form — so typing a password and then turning WebDAV off instead of saving wrote that password to the keyring, cleared the field, and said nothing. It now sends `null` for both secrets and leaves the fields alone. **A dismissed file picker could deadlock the panel.** `pickLocalFile` resolved on `change` and on `cancel` and otherwise never settled, while the caller holds `busy` for the duration — so on any engine that does not dispatch `cancel` (WebKitGTK, which a Linux desktop build runs) dismissing the import dialog left every button in the section disabled until the page was remounted. Regaining window focus with no file attached now ends it too. Also: the runtime split is tested where it lives. The panel's own tests mock `@/lib/config-sync` wholesale, so their `desktop = false` could not constrain which branch a real browser takes — it only guards against a re-introduced `return null`. `src/lib/config-sync.test.ts` now pins the branch itself: which command each runtime calls, that a browser never reaches a `*_file` command, and that a dismissed picker settles. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/config-sync-settings.test.tsx | 74 ++++++++- .../settings/config-sync-settings.tsx | 26 +++- src/lib/config-sync.test.ts | 144 +++++++++++++++++- src/lib/config-sync.ts | 43 ++++-- 4 files changed, 263 insertions(+), 24 deletions(-) diff --git a/src/components/settings/config-sync-settings.test.tsx b/src/components/settings/config-sync-settings.test.tsx index fc4b91c055..eda2e193bc 100644 --- a/src/components/settings/config-sync-settings.test.tsx +++ b/src/components/settings/config-sync-settings.test.tsx @@ -2,10 +2,11 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { NextIntlClientProvider } from "next-intl" import { beforeEach, describe, expect, it, vi } from "vitest" -// Flipped per-test. The panel itself is runtime-agnostic now — only the file -// picker underneath it differs — so these decide which branch of -// `@/lib/config-sync` the (unmocked) helpers would take, not whether the -// section renders at all. +// Flipped per-test. The panel is runtime-agnostic now, so the only thing +// `desktop: false` can still catch here is a re-introduced `return null` gate — +// which is exactly what the availability tests below are for. The runtime +// branch itself lives in `@/lib/config-sync` (mocked in this file) and is +// pinned by `src/lib/config-sync.test.ts`. const env = vi.hoisted(() => ({ desktop: true, remoteId: null as string | null, @@ -316,10 +317,11 @@ describe("ConfigSyncSettings — file import", () => { ) }) - /// The browser has no path to hand over, so the picker returns the bytes. - /// The panel must pass whichever it was given straight back, untouched. - it("imports a browser-picked file by content, not by path", async () => { - env.desktop = false + /// The panel is deliberately blind to which runtime produced the pick: it + /// hands the `source` back exactly as given. Which source a real browser + /// produces is decided inside `@/lib/config-sync` — mocked here — and is + /// pinned by `config-sync.test.ts` instead, where the branch actually lives. + it("passes a by-content source straight back, untouched", async () => { const picked = { source: { kind: "content" as const, @@ -424,6 +426,62 @@ describe("ConfigSyncSettings — encryption", () => { // Stored now, so an untouched field means "keep it". await waitFor(() => expect(passphrase.placeholder).toBe(t.passphraseKeep)) }) + + /// A passphrase typed and then withdrawn — the switch goes back off before + /// Save — must not be stored. The field is hidden at that point, so state + /// left behind there would ride along on a later Save about something else + /// entirely, and `hasPassphrase` would then claim a protection the user + /// never confirmed. + it("drops a typed passphrase when encryption is switched back off", async () => { + const { container } = await renderLoaded() + fireEvent.click(screen.getByRole("switch", { name: t.encryptLabel })) + const passphrase = (await waitFor(() => { + const field = container.querySelector("#config-sync-passphrase") + expect(field).toBeInTheDocument() + return field + })) as HTMLInputElement + fireEvent.change(passphrase, { target: { value: "withdrawn" } }) + + fireEvent.click(screen.getByRole("switch", { name: t.encryptLabel })) + await waitFor(() => + expect( + container.querySelector("#config-sync-passphrase") + ).not.toBeInTheDocument() + ) + + fireEvent.click(screen.getByRole("button", { name: t.saveButton })) + await waitFor(() => expect(updateConfigSyncSettings).toHaveBeenCalled()) + expect(vi.mocked(updateConfigSyncSettings).mock.calls[0][0]).toMatchObject({ + encrypt: false, + // `null` is "unchanged", which leaves whatever is already stored alone — + // that copy is what still decrypts the snapshot on the remote. + passphrase: null, + }) + }) +}) + +describe("ConfigSyncSettings — the master switch is not a Save button", () => { + /// It has to write something (see the handler's comment), but a credential + /// typed into a field and never submitted is not part of that something. + /// Otherwise a user who types a password, thinks better of it and turns sync + /// OFF has just stored the password instead of discarding it. + it("does not submit a typed password or passphrase", async () => { + const { container } = await renderLoaded() + const password = container.querySelector( + "#config-sync-password" + ) as HTMLInputElement + fireEvent.change(password, { target: { value: "half-typed" } }) + + fireEvent.click(screen.getByRole("switch", { name: t.webdavTitle })) + await waitFor(() => expect(updateConfigSyncSettings).toHaveBeenCalled()) + const sent = vi.mocked(updateConfigSyncSettings).mock.calls[0][0] + expect(sent).toMatchObject({ enabled: false, password: null }) + expect(JSON.stringify(sent)).not.toContain("half-typed") + + // And the text survives, so the explicit Save the user may still want is + // one click away rather than retyped. + await waitFor(() => expect(password.value).toBe("half-typed")) + }) }) describe("ConfigSyncSettings — rollback snapshots", () => { diff --git a/src/components/settings/config-sync-settings.tsx b/src/components/settings/config-sync-settings.tsx index 4baceec1f9..f15bd3ebae 100644 --- a/src/components/settings/config-sync-settings.tsx +++ b/src/components/settings/config-sync-settings.tsx @@ -310,6 +310,20 @@ export function ConfigSyncSettings() { } }, [currentInput, localize, t]) + /** + * Turning encryption off drops anything typed into the passphrase field but + * NOT the one already on file. The field is only visible while the switch is + * on, so a passphrase left in state there would be submitted by a later Save + * that the user believes has nothing to do with it — storing a secret they + * withdrew, and making `hasPassphrase` claim protection they never confirmed. + * The stored passphrase survives on purpose: it is what still decrypts the + * copy already sitting on the remote. + */ + const handleToggleEncrypt = useCallback((next: boolean) => { + setEncrypt(next) + if (!next) setPassphrase("") + }, []) + /** * The master switch saves on click, like the proxy and launch-at-login * switches in the sections above. It has to: every other control — Save @@ -323,8 +337,14 @@ export function ConfigSyncSettings() { setEnabled(next) setBusy("save") try { + // Secrets are explicitly withheld: `null` means "unchanged". Anything + // typed into the password or passphrase field has not been submitted + // yet, and flipping a switch is not a submission — a user who types a + // password, thinks better of it and turns sync OFF instead must not + // find that password stored. The fields keep their text, so an + // explicit Save is still one click away. const saved = await updateConfigSyncSettings( - currentInput({ enabled: next }) + currentInput({ enabled: next, password: null, passphrase: null }) ) if (!mounted.current) return setHasPassword(saved.hasPassword) @@ -333,8 +353,6 @@ export function ConfigSyncSettings() { serverUrl: saved.serverUrl, username: saved.username, }) - setPassword("") - setPassphrase("") } catch (err) { if (mounted.current) setEnabled(previous) toast.error(localize(err)) @@ -776,7 +794,7 @@ export function ConfigSyncSettings() { diff --git a/src/lib/config-sync.test.ts b/src/lib/config-sync.test.ts index d4b20de902..f9a1865063 100644 --- a/src/lib/config-sync.test.ts +++ b/src/lib/config-sync.test.ts @@ -1,11 +1,40 @@ -import { describe, expect, it } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { CONFIG_DOMAIN_IDS, CONFIG_EXPORT_EXTENSION, defaultExportFileName, + exportConfigToFile, + importPickedConfig, + pickConfigFileToImport, summarizeCounts, } from "./config-sync" +import { isLocalDesktop } from "./platform" +import { getTransport } from "./transport" + +vi.mock("./platform", () => ({ isLocalDesktop: vi.fn() })) +vi.mock("./transport", () => ({ getTransport: vi.fn() })) + +const save = vi.fn() +const open = vi.fn() +vi.mock("@tauri-apps/plugin-dialog", () => ({ + save: (...args: unknown[]) => save(...args), + open: (...args: unknown[]) => open(...args), +})) + +const call = vi.fn() +const desktop = vi.mocked(isLocalDesktop) + +// jsdom implements neither half of the Blob-download dance. +const createObjectURL = vi.fn(() => "blob:codeg/1") +const revokeObjectURL = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getTransport).mockReturnValue({ call } as never) + call.mockResolvedValue({ content: "{}", counts: {}, path: "", manifest: {} }) + Object.assign(URL, { createObjectURL, revokeObjectURL }) +}) describe("defaultExportFileName", () => { it("is filesystem-safe and sorts chronologically", () => { @@ -34,3 +63,116 @@ describe("summarizeCounts", () => { expect(summarizeCounts({} as never)).toEqual([]) }) }) + +/** + * The runtime split lives HERE, not in the panel — the panel only calls these + * two functions, so a component test that mocks this module proves nothing + * about which branch a real browser takes. The commands are the observable + * difference: `*_file` variants take a server-side path and only exist as + * Tauri commands, so a browser reaching one is a hard failure rather than a + * degraded experience. + */ +describe("local file transfer picks its runtime", () => { + it("moves the document's text when there is no local desktop shell", async () => { + desktop.mockReturnValue(false) + call.mockResolvedValue({ content: '{"hello":1}', counts: { a: 1 } }) + + const summary = await exportConfigToFile() + + expect(call).toHaveBeenCalledWith("config_sync_export_content", {}) + expect(save).not.toHaveBeenCalled() + // The bytes went out as a download, and the object URL is released rather + // than leaked for the life of the document. + expect(createObjectURL).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith("blob:codeg/1")) + // The browser owns the destination, so the reported "path" is the offered + // file name rather than anything on a disk. + expect(summary?.path).toMatch(/^codeg-config-.*\.json$/) + }) + + it("uses the native save dialog on a local desktop window", async () => { + desktop.mockReturnValue(true) + save.mockResolvedValue("/Users/someone/config.json") + + await exportConfigToFile() + + expect(save).toHaveBeenCalledTimes(1) + expect(call).toHaveBeenCalledWith("config_sync_export_file", { + destPath: "/Users/someone/config.json", + }) + expect(call).not.toHaveBeenCalledWith("config_sync_export_content", {}) + }) + + it("does not call the backend at all when the save dialog is dismissed", async () => { + desktop.mockReturnValue(true) + save.mockResolvedValue(null) + + expect(await exportConfigToFile()).toBeNull() + expect(call).not.toHaveBeenCalled() + }) + + it("previews a desktop import by path and a browser import by content", async () => { + desktop.mockReturnValue(true) + open.mockResolvedValue("/Users/someone/incoming.json") + const picked = await pickConfigFileToImport() + expect(call).toHaveBeenCalledWith("config_sync_peek_file", { + srcPath: "/Users/someone/incoming.json", + }) + expect(picked?.source).toEqual({ + kind: "path", + path: "/Users/someone/incoming.json", + label: "/Users/someone/incoming.json", + }) + + // And the two sources stay apart on the way back in, so a path never + // reaches the by-content command or the reverse. + await importPickedConfig(picked!.source) + expect(call).toHaveBeenCalledWith("config_sync_import_file", { + srcPath: "/Users/someone/incoming.json", + }) + await importPickedConfig({ kind: "content", content: "{}", label: "f" }) + expect(call).toHaveBeenCalledWith("config_sync_import_content", { + content: "{}", + }) + }) + + it("does not open a native dialog from a browser", async () => { + desktop.mockReturnValue(false) + // No file is ever selected; the promise settles via the picker's own + // dismissal path rather than hanging (see `pickLocalFile`). + const pending = pickConfigFileToImport() + const input = document.querySelector('input[type="file"]') + expect(input, "a browser import must go through a file input").not.toBeNull() + expect(open).not.toHaveBeenCalled() + input!.dispatchEvent(new Event("cancel")) + + expect(await pending).toBeNull() + expect(call).not.toHaveBeenCalled() + expect(document.querySelector('input[type="file"]')).toBeNull() + }) + + /// The caller disables the whole panel while this promise is pending, so one + /// that never settles is not a leaked promise — it is a settings page with + /// every button dead until the page is remounted. `cancel` is not dispatched + /// by older engines (the WebKitGTK a Linux desktop build can be running is + /// the realistic one), so regaining window focus with no file attached has + /// to end it too. + it("settles when a picker without a cancel event is dismissed", async () => { + vi.useFakeTimers() + try { + desktop.mockReturnValue(false) + const pending = pickConfigFileToImport() + expect(document.querySelector('input[type="file"]')).not.toBeNull() + + // The picker closed: focus comes back, no file was chosen, and no + // `cancel` is coming. + window.dispatchEvent(new Event("focus")) + await vi.advanceTimersByTimeAsync(1000) + + expect(await pending).toBeNull() + expect(document.querySelector('input[type="file"]')).toBeNull() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/lib/config-sync.ts b/src/lib/config-sync.ts index 392ae7affb..6f3ffd978f 100644 --- a/src/lib/config-sync.ts +++ b/src/lib/config-sync.ts @@ -293,25 +293,46 @@ function downloadTextFile(fileName: string, content: string): void { setTimeout(() => URL.revokeObjectURL(url), 0) } -/** `null` when the picker was dismissed. There is no cancel event for a file - * input in every browser we target, so a dismissed dialog simply never - * resolves to a file — the `cancel` event covers the modern ones and the - * promise is abandoned otherwise, which is the same outcome the user sees. */ +/** How long to wait, after the window gets focus back, for a `change` event a + * slower browser has not dispatched yet. Focus returns the instant the picker + * closes, which is before the file is attached. */ +const FILE_PICKER_SETTLE_MS = 400 + +/** `null` when the picker was dismissed. + * + * This promise MUST settle. Callers disable the panel while it is pending, so + * one that never resolves leaves every button — export, save, test, upload, + * restore — dead until the page is remounted. `cancel` is the clean signal + * and covers current Chrome, Safari and Firefox; regaining window focus with + * no file attached is the fallback for anything older, which includes the + * WebKitGTK builds a Linux desktop can be running. */ function pickLocalFile(): Promise { return new Promise((resolve) => { const input = document.createElement("input") input.type = "file" input.accept = ".json,application/json" input.style.display = "none" - input.addEventListener("change", () => { - const file = input.files?.[0] ?? null + + let settled = false + const finish = (file: File | null) => { + if (settled) return + settled = true + window.removeEventListener("focus", onFocus) input.remove() resolve(file) - }) - input.addEventListener("cancel", () => { - input.remove() - resolve(null) - }) + } + function onFocus() { + window.setTimeout( + () => finish(input.files?.[0] ?? null), + FILE_PICKER_SETTLE_MS + ) + } + + input.addEventListener("change", () => finish(input.files?.[0] ?? null)) + input.addEventListener("cancel", () => finish(null)) + // Registered before the click, because opening the picker is what takes + // focus away in the first place. + window.addEventListener("focus", onFocus) document.body.appendChild(input) input.click() }) From 15063c9b499ba904dc59190211eafd81c9a58164 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 15:55:14 +0800 Subject: [PATCH 7/9] fix(config-sync): two regressions the fix review caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The save guard could lock the feature.** Refusing to save whenever the credential store would not open fixed the erasure, but it gated EVERY save — so on a Linux desktop with no Secret Service running, or after a denied macOS keychain prompt, the master switch and the interval could not be changed either, permanently. The root cause was narrower than the guard: the save wrote back secrets it had only just read. It now writes only what the input actually decided — a new password, a new passphrase, or the erasure an account change requires — so a save that carries no credential touches the store at all, and `""` can no longer travel out of a failed read as "delete this entry". `same_account` moves into one function, because the two callers now both act on it: `merge_settings` decides whether the password is kept, and `save_settings_core` decides whether it is erased. Those must not differ. **The file-picker fallback could eat a real selection.** Resolving on window focus cannot tell "the dialog closed" from "the dialog is open and the user alt-tabbed", so with a non-modal chooser it settled the promise under someone who was still choosing — and the file they then picked arrived on a dead promise and vanished silently. Guessing is the wrong fix for a promise that may not settle; not gating the UI on it is. `handlePickImport` no longer holds `busy` across the dialog, so an unanswered picker costs a hidden input instead of the whole panel, and `pickLocalFile` is back to `cancel` as its only dismissal signal. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/commands/config_sync/credentials.rs | 43 +++-- .../src/commands/config_sync/webdav_sync.rs | 160 ++++++++++++++---- .../settings/config-sync-settings.test.tsx | 23 +++ .../settings/config-sync-settings.tsx | 12 +- src/lib/config-sync.test.ts | 62 ++++--- src/lib/config-sync.ts | 34 ++-- 6 files changed, 230 insertions(+), 104 deletions(-) diff --git a/src-tauri/src/commands/config_sync/credentials.rs b/src-tauri/src/commands/config_sync/credentials.rs index 7769ff31e0..83b8ee95b7 100644 --- a/src-tauri/src/commands/config_sync/credentials.rs +++ b/src-tauri/src/commands/config_sync/credentials.rs @@ -18,14 +18,19 @@ pub const WEBDAV_PASSWORD: &str = "config-sync-webdav-password"; pub const SNAPSHOT_PASSPHRASE: &str = "config-sync-snapshot-passphrase"; /// Missing and unreadable collapse to "no secret". That is right for a caller -/// that is about to USE the secret — the next step, ask the user to type it -/// again, is the same either way — and wrong for one that is about to write it -/// back, which must call [`read`] instead. See [`ensure_readable`]. +/// about to USE the secret — the next step, ask the user to type it again, is +/// the same either way — and wrong for one about to write anything back, which +/// must call [`read`] instead. pub fn load(name: &str) -> String { read(name).ok().flatten().unwrap_or_default() } /// `Ok(None)` is "nothing stored"; `Err` is "the store would not open". +/// +/// Keeping the two apart is what lets the save path write only the secrets it +/// was actually given: a value that reads back as `""` because the store could +/// not be opened must never travel out again as "delete this entry". See +/// `webdav_sync::save_settings_core`. pub fn read(name: &str) -> Result, AppCommandError> { store::get(name).map_err(|e| { AppCommandError::io_error("Failed to read the config sync credentials") @@ -37,21 +42,6 @@ pub fn read(name: &str) -> Result, AppCommandError> { }) } -/// Refuse to go on when the secret store cannot be read. -/// -/// The save path reads the stored secrets, merges the user's edits over them, -/// and writes the result back — and an empty value means "delete this entry" on -/// the way back. So a store that reads as empty only because it would not open -/// turns any unrelated save (nudging the sync interval) into a permanent -/// erasure of the passphrase the snapshot already on the remote is encrypted -/// under. Stopping at the read is the difference between "try again" and "your -/// backup is now undecryptable". -pub fn ensure_readable() -> Result<(), AppCommandError> { - read(WEBDAV_PASSWORD)?; - read(SNAPSHOT_PASSPHRASE)?; - Ok(()) -} - /// Stores, or removes the entry entirely when `value` is empty, so "cleared" /// and "never set" stay the same state. pub fn store(name: &str, value: &str) -> Result<(), AppCommandError> { @@ -166,19 +156,26 @@ mod tests { fn an_unreadable_store_is_not_an_empty_one() { let _guard = test_guard(); store(WEBDAV_PASSWORD, "app-password").expect("store"); - assert_eq!(read(WEBDAV_PASSWORD).expect("readable"), Some("app-password".into())); - assert!(ensure_readable().is_ok()); + assert_eq!( + read(WEBDAV_PASSWORD).expect("readable"), + Some("app-password".into()) + ); + // Absent reads as absent, not as a failure — that is what lets a fresh + // install, and a server with no token file yet, save at all. + assert_eq!(read(SNAPSHOT_PASSPHRASE).expect("readable"), None); { let _unreadable = unreadable_store(); - assert!(read(WEBDAV_PASSWORD).is_err(), "a failed read must not read as absent"); - assert!(ensure_readable().is_err()); + assert!( + read(WEBDAV_PASSWORD).is_err(), + "a failed read must not read as absent" + ); // `load` still collapses the two on purpose: its callers are about // to ask the user for the secret either way. assert_eq!(load(WEBDAV_PASSWORD), ""); } - assert!(ensure_readable().is_ok(), "the guard must restore the store"); + assert!(read(WEBDAV_PASSWORD).is_ok(), "the guard must restore the store"); store(WEBDAV_PASSWORD, "").expect("clean up"); } diff --git a/src-tauri/src/commands/config_sync/webdav_sync.rs b/src-tauri/src/commands/config_sync/webdav_sync.rs index 9fbc9aa6a1..2f126c489b 100644 --- a/src-tauri/src/commands/config_sync/webdav_sync.rs +++ b/src-tauri/src/commands/config_sync/webdav_sync.rs @@ -384,20 +384,44 @@ pub async fn save_settings_core( conn: &DatabaseConnection, input: ConfigSyncSettingsInput, ) -> Result { - // Before anything else, because this save is a read-modify-write of the - // secret store and an unreadable store reads back as empty — which travels - // out as a deletion. Nudging the sync interval must not be able to destroy - // the passphrase the snapshot already on the remote is encrypted under. - credentials::ensure_readable()?; - let existing = load_settings(conn).await; + + // What this save DECIDES about the secrets, read off the input before it is + // merged. Writing back whatever `load_settings` returned — which is what + // this used to do — is a read-modify-write of the secret store, and an + // unreadable store returns `""`, which means DELETE on the way back out. A + // denied keychain prompt plus any unrelated save would have destroyed the + // passphrase the copy already on the remote is encrypted under. + // + // Saying nothing about a secret must therefore touch nothing. That also + // keeps the master switch and the interval working on a machine with no + // usable keyring at all: a setting that needs no credential no longer + // fails because a credential could not be reached. + let new_password = input.password.clone().filter(|value| !value.is_empty()); + let new_passphrase = input + .passphrase + .clone() + .filter(|value| !value.is_empty()); + let keeps_account = same_account(&existing, &input.server_url, &input.username); + let merged = merge_settings(&existing, input)?; // Secrets first. If the keyring refuses them the save fails outright rather // than persisting a configuration whose credentials went nowhere — that // would surface minutes later as "the server rejected your password". - credentials::store(WEBDAV_PASSWORD, &merged.password)?; - credentials::store(SNAPSHOT_PASSPHRASE, &merged.passphrase)?; + if let Some(password) = &new_password { + credentials::store(WEBDAV_PASSWORD, password)?; + } else if !keeps_account { + // The account moved, so the stored password does not belong to it any + // more — `merge_settings` drops it for the same reason. Unconditional + // rather than "only if one was read": deleting an absent entry is a + // no-op, and a store we could not READ may still be holding the old + // password for the old host. + credentials::store(WEBDAV_PASSWORD, "")?; + } + if let Some(passphrase) = &new_passphrase { + credentials::store(SNAPSHOT_PASSPHRASE, passphrase)?; + } let serialized = serde_json::to_string(&merged).map_err(|e| { AppCommandError::invalid_input("Failed to serialize config sync settings") @@ -421,6 +445,22 @@ pub async fn save_settings_core( Ok(ConfigSyncSettingsView::from(&merged)) } +/// Whether an incoming edit still names the account the stored password was +/// typed for. +/// +/// The stored password belongs to that account. Carrying it over to a different +/// host or user would mean an edit to the URL field alone is enough to make the +/// next request hand that password to another server — by accident (repointing +/// Jianguoyun at Nextcloud) or on purpose. Changing the folder or profile is not +/// a change of credential, so those are deliberately not part of the comparison. +/// +/// One definition, because two callers act on it: [`merge_settings`] decides +/// whether to keep the password, and [`save_settings_core`] decides whether to +/// erase it from the keyring. Those two answers must never differ. +fn same_account(existing: &ConfigSyncSettings, server_url: &str, username: &str) -> bool { + server_url.trim() == existing.server_url && username.trim() == existing.username +} + /// Pure so the password-retention and path-validation rules are testable /// without a database. pub fn merge_settings( @@ -429,13 +469,7 @@ pub fn merge_settings( ) -> Result { let server_url = input.server_url.trim().to_string(); let username = input.username.trim().to_string(); - // The stored password belongs to the account it was typed for. Carrying - // it over to a different host or user would mean an edit to the URL field - // alone is enough to make the next request hand that password to another - // server — by accident (repointing Jianguoyun at Nextcloud) or on purpose. - // Changing the folder or profile is not a change of credential, so those - // are deliberately not part of the comparison. - let same_account = server_url == existing.server_url && username == existing.username; + let same_account = same_account(existing, &input.server_url, &input.username); let password = match input.password { Some(value) if !value.is_empty() => value, // Both `None` and `Some("")` keep the stored password. An empty field @@ -1096,37 +1130,101 @@ mod tests { credentials::store(WEBDAV_PASSWORD, "").expect("clean up"); } - /// A keyring that will not open reads back as "no secret", and a save is a - /// read-modify-write: without a guard, changing the sync interval would - /// hand `""` to the credential store, which means DELETE. The passphrase - /// protecting the copy already on the remote would go with it, and no - /// retry brings it back. + /// A keyring that will not open reads back as "no secret", and the save + /// used to write whatever it had just read — so `""` went out as DELETE and + /// a change to the sync interval destroyed the passphrase protecting the + /// copy already on the remote. Nothing brings that back. + /// + /// The fix is that saying nothing about a secret touches nothing, which + /// also means the save still SUCCEEDS: settings that need no credential + /// must not fail because a credential could not be reached. A Linux desktop + /// with no Secret Service running would otherwise be unable to turn config + /// sync off, or change its interval, for as long as it stayed that way. #[tokio::test] - async fn an_unreadable_credential_store_refuses_the_save_instead_of_erasing_it() { + async fn a_save_that_carries_no_secret_leaves_an_unreadable_store_alone() { let _guard = credentials::test_guard(); let db = fresh_in_memory_db().await; credentials::store(WEBDAV_PASSWORD, "app-password").expect("seed"); credentials::store(SNAPSHOT_PASSPHRASE, "hunter2").expect("seed"); + // Establish the account first, so the save below is not an account + // change (which deliberately DOES erase the password). + save_settings_core(&db.conn, input()).await.expect("seed settings"); - let err = { + { let _unreadable = credentials::unreadable_store(); - save_settings_core(&db.conn, input()) - .await - .expect_err("a save that cannot read the store must not write to it") - }; - assert_eq!( - err.i18n_key.as_deref(), - Some(crate::app_error::CONFIG_SYNC_I18N_KEY_CREDENTIALS_UNREADABLE) - ); + save_settings_core( + &db.conn, + ConfigSyncSettingsInput { + interval_minutes: 30, + // "I did not retype them" — the state every save that is + // not about credentials is in. + password: None, + passphrase: None, + ..input() + }, + ) + .await + .expect("a setting that needs no credential must still save"); + } - // Both secrets are still there once the store opens again. + // Both secrets are untouched once the store opens again. assert_eq!(credentials::load(WEBDAV_PASSWORD), "app-password"); assert_eq!(credentials::load(SNAPSHOT_PASSPHRASE), "hunter2"); + assert_eq!(load_settings(&db.conn).await.interval_minutes, 30); credentials::store(WEBDAV_PASSWORD, "").expect("clean up"); credentials::store(SNAPSHOT_PASSPHRASE, "").expect("clean up"); } + /// The other half: a save that DOES carry a secret still writes it, and an + /// account change still erases the password that no longer belongs to the + /// account — unconditionally, because a store that could not be read may + /// still be holding the old host's copy. + #[tokio::test] + async fn a_save_writes_the_secrets_it_was_given_and_erases_an_orphaned_one() { + let _guard = credentials::test_guard(); + let db = fresh_in_memory_db().await; + credentials::store(WEBDAV_PASSWORD, "").expect("start clean"); + credentials::store(SNAPSHOT_PASSPHRASE, "").expect("start clean"); + + save_settings_core( + &db.conn, + ConfigSyncSettingsInput { + password: Some("typed".into()), + ..input() + }, + ) + .await + .expect("save"); + assert_eq!(credentials::load(WEBDAV_PASSWORD), "typed"); + + // Same account, nothing typed: the stored password stays. + save_settings_core( + &db.conn, + ConfigSyncSettingsInput { + password: None, + ..input() + }, + ) + .await + .expect("save"); + assert_eq!(credentials::load(WEBDAV_PASSWORD), "typed"); + + // A different host is a different account, so the password does not + // travel to it. + save_settings_core( + &db.conn, + ConfigSyncSettingsInput { + server_url: "https://other.example.com/dav".into(), + password: None, + ..input() + }, + ) + .await + .expect("save"); + assert_eq!(credentials::load(WEBDAV_PASSWORD), ""); + } + /// And when the two copies disagree — the user re-saved a new password /// after a partial migration — the keyring is the one every save writes to, /// so it wins and the stale row copy is erased rather than resurrected. diff --git a/src/components/settings/config-sync-settings.test.tsx b/src/components/settings/config-sync-settings.test.tsx index eda2e193bc..04ba3fabc9 100644 --- a/src/components/settings/config-sync-settings.test.tsx +++ b/src/components/settings/config-sync-settings.test.tsx @@ -460,6 +460,29 @@ describe("ConfigSyncSettings — encryption", () => { }) }) +describe("ConfigSyncSettings — a file dialog does not gate the panel", () => { + /// A dialog is open for as long as the user is reading their filesystem, and + /// on an engine with no `cancel` event a dismissed one never reports back at + /// all (see `pickLocalFile`). Disabling the section for the duration would + /// therefore mean a Settings page that can be bricked by pressing Import and + /// pressing Escape. Nothing is written until the confirmation dialog, so + /// there is nothing here worth locking. + it("leaves every other action usable while the picker is open", async () => { + // Never resolves — exactly the dismissed-picker case. + vi.mocked(pickConfigFileToImport).mockReturnValue(new Promise(() => {})) + await renderLoaded() + + fireEvent.click(screen.getByRole("button", { name: t.importButton })) + + await waitFor(() => + expect(screen.getByRole("button", { name: t.saveButton })).toBeEnabled() + ) + expect(screen.getByRole("button", { name: t.exportButton })).toBeEnabled() + expect(screen.getByRole("button", { name: t.testButton })).toBeEnabled() + expect(screen.getByRole("switch", { name: t.webdavTitle })).toBeEnabled() + }) +}) + describe("ConfigSyncSettings — the master switch is not a Save button", () => { /// It has to write something (see the handler's comment), but a credential /// typed into a field and never submitted is not part of that something. diff --git a/src/components/settings/config-sync-settings.tsx b/src/components/settings/config-sync-settings.tsx index f15bd3ebae..4be54be223 100644 --- a/src/components/settings/config-sync-settings.tsx +++ b/src/components/settings/config-sync-settings.tsx @@ -446,15 +446,21 @@ export function ConfigSyncSettings() { } }, [localize, t]) + /** + * Deliberately outside the `busy` gate, unlike every other action here. + * `pickConfigFileToImport` is pending for as long as a file dialog is open, + * and on an engine that does not dispatch `cancel` (see `pickLocalFile`) a + * dismissed dialog leaves it pending for good — which, gated, would mean a + * settings section whose every button stays disabled until the page is + * remounted. Nothing is written until the confirmation below, so there is + * nothing here that a second click could corrupt. + */ const handlePickImport = useCallback(async () => { - setBusy("import") try { const picked = await pickConfigFileToImport() if (picked && mounted.current) setPendingImport(picked) } catch (err) { toast.error(localize(err)) - } finally { - if (mounted.current) setBusy(null) } }, [localize]) diff --git a/src/lib/config-sync.test.ts b/src/lib/config-sync.test.ts index f9a1865063..196811da42 100644 --- a/src/lib/config-sync.test.ts +++ b/src/lib/config-sync.test.ts @@ -84,7 +84,9 @@ describe("local file transfer picks its runtime", () => { // The bytes went out as a download, and the object URL is released rather // than leaked for the life of the document. expect(createObjectURL).toHaveBeenCalledTimes(1) - await vi.waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith("blob:codeg/1")) + await vi.waitFor(() => + expect(revokeObjectURL).toHaveBeenCalledWith("blob:codeg/1") + ) // The browser owns the destination, so the reported "path" is the offered // file name rather than anything on a disk. expect(summary?.path).toMatch(/^codeg-config-.*\.json$/) @@ -142,7 +144,10 @@ describe("local file transfer picks its runtime", () => { // dismissal path rather than hanging (see `pickLocalFile`). const pending = pickConfigFileToImport() const input = document.querySelector('input[type="file"]') - expect(input, "a browser import must go through a file input").not.toBeNull() + expect( + input, + "a browser import must go through a file input" + ).not.toBeNull() expect(open).not.toHaveBeenCalled() input!.dispatchEvent(new Event("cancel")) @@ -151,28 +156,37 @@ describe("local file transfer picks its runtime", () => { expect(document.querySelector('input[type="file"]')).toBeNull() }) - /// The caller disables the whole panel while this promise is pending, so one - /// that never settles is not a leaked promise — it is a settings page with - /// every button dead until the page is remounted. `cancel` is not dispatched - /// by older engines (the WebKitGTK a Linux desktop build can be running is - /// the realistic one), so regaining window focus with no file attached has - /// to end it too. - it("settles when a picker without a cancel event is dismissed", async () => { - vi.useFakeTimers() - try { - desktop.mockReturnValue(false) - const pending = pickConfigFileToImport() - expect(document.querySelector('input[type="file"]')).not.toBeNull() - - // The picker closed: focus comes back, no file was chosen, and no - // `cancel` is coming. - window.dispatchEvent(new Event("focus")) - await vi.advanceTimersByTimeAsync(1000) - - expect(await pending).toBeNull() - expect(document.querySelector('input[type="file"]')).toBeNull() - } finally { - vi.useRealTimers() + /// Regaining window focus is the usual way to guess "the dialog closed", and + /// it guesses wrong: a non-modal chooser, or an alt-tab back to the app while + /// the dialog is still open, both raise it. Resolving there would settle the + /// promise under a user who is still choosing, and the file they then pick + /// would arrive on a dead promise and be dropped without a word. + it("does not treat window focus as a dismissal", async () => { + desktop.mockReturnValue(false) + let settled = false + const pending = pickConfigFileToImport().then((value) => { + settled = true + return value + }) + + window.dispatchEvent(new Event("focus")) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(settled, "the user may still be looking at the dialog").toBe(false) + + // The file they were choosing all along still lands. + const input = + document.querySelector('input[type="file"]')! + // jsdom's `File` has no `text()`, and the picker reads the bytes itself. + const file = { + name: "picked.json", + text: async () => '{"schemaVersion":1,"domains":{}}', } + Object.defineProperty(input, "files", { value: [file] }) + input.dispatchEvent(new Event("change")) + + expect((await pending)?.source).toMatchObject({ + kind: "content", + label: "picked.json", + }) }) }) diff --git a/src/lib/config-sync.ts b/src/lib/config-sync.ts index 6f3ffd978f..06f8cfc832 100644 --- a/src/lib/config-sync.ts +++ b/src/lib/config-sync.ts @@ -293,19 +293,18 @@ function downloadTextFile(fileName: string, content: string): void { setTimeout(() => URL.revokeObjectURL(url), 0) } -/** How long to wait, after the window gets focus back, for a `change` event a - * slower browser has not dispatched yet. Focus returns the instant the picker - * closes, which is before the file is attached. */ -const FILE_PICKER_SETTLE_MS = 400 - -/** `null` when the picker was dismissed. +/** `null` when the picker was dismissed — on the engines that say so. + * + * `cancel` covers current Chrome, Safari and Firefox and nothing else does: + * there is no other signal that distinguishes "dialog dismissed" from "dialog + * still open". A window-focus fallback is the usual trick and it is wrong + * here — a non-modal chooser, or an alt-tab back to the app while the dialog + * is open, would settle the promise under a user who then picks a file and + * watches nothing happen. * - * This promise MUST settle. Callers disable the panel while it is pending, so - * one that never resolves leaves every button — export, save, test, upload, - * restore — dead until the page is remounted. `cancel` is the clean signal - * and covers current Chrome, Safari and Firefox; regaining window focus with - * no file attached is the fallback for anything older, which includes the - * WebKitGTK builds a Linux desktop can be running. */ + * So on an older engine this promise may never settle, and CALLERS MUST NOT + * DISABLE ANYTHING WHILE IT IS PENDING. An abandoned promise costs a hidden + * input; an abandoned promise the UI is gated on costs the whole panel. */ function pickLocalFile(): Promise { return new Promise((resolve) => { const input = document.createElement("input") @@ -317,22 +316,11 @@ function pickLocalFile(): Promise { const finish = (file: File | null) => { if (settled) return settled = true - window.removeEventListener("focus", onFocus) input.remove() resolve(file) } - function onFocus() { - window.setTimeout( - () => finish(input.files?.[0] ?? null), - FILE_PICKER_SETTLE_MS - ) - } - input.addEventListener("change", () => finish(input.files?.[0] ?? null)) input.addEventListener("cancel", () => finish(null)) - // Registered before the click, because opening the picker is what takes - // focus away in the first place. - window.addEventListener("focus", onFocus) document.body.appendChild(input) input.click() }) From fb65546c51e79a719ce61154b574e38f963056e1 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 16:28:52 +0800 Subject: [PATCH 8/9] fix(config-sync): the test credential guard cannot be a std mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Almost every holder is an async test that awaits a database while it is held, and `clippy::await_holding_lock` is right to refuse that. A `tokio::sync::Mutex` is the one that may cross an await — and it has no poisoning either, so a failing test no longer risks cascading into the rest of the file. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/commands/config_sync/credentials.rs | 36 ++++++++++++------- .../src/commands/config_sync/local_io.rs | 2 +- .../src/commands/config_sync/webdav_sync.rs | 18 +++++----- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/commands/config_sync/credentials.rs b/src-tauri/src/commands/config_sync/credentials.rs index 83b8ee95b7..e3a3fe0413 100644 --- a/src-tauri/src/commands/config_sync/credentials.rs +++ b/src-tauri/src/commands/config_sync/credentials.rs @@ -114,17 +114,27 @@ mod store { /// The secret store is process-global by nature, so two tests that both save /// settings would overwrite each other's password. Any test that goes through /// [`store`] must hold this for its duration. +/// +/// A `tokio` mutex rather than a `std` one because almost every holder is an +/// async test that awaits a database while holding it, which `std`'s guard is +/// not allowed to do. It also has no poisoning, so one failing test cannot +/// cascade into the rest of the file. #[cfg(test)] -pub fn test_guard() -> std::sync::MutexGuard<'static, ()> { - use std::sync::{Mutex, OnceLock}; - static GUARD: OnceLock> = OnceLock::new(); - // A poisoned guard means an unrelated test panicked while holding it; the - // secrets are re-seeded by every test that takes it, so recovering is - // correct and keeps one failure from cascading into the whole file. - GUARD - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) +fn guard_lock() -> &'static tokio::sync::Mutex<()> { + static GUARD: std::sync::OnceLock> = std::sync::OnceLock::new(); + GUARD.get_or_init(|| tokio::sync::Mutex::new(())) +} + +#[cfg(test)] +pub async fn test_guard() -> tokio::sync::MutexGuard<'static, ()> { + guard_lock().lock().await +} + +/// For the plain `#[test]` cases in this module, which have no runtime to +/// await on. +#[cfg(test)] +pub fn test_guard_blocking() -> tokio::sync::MutexGuard<'static, ()> { + guard_lock().blocking_lock() } /// Make every read fail for as long as the returned value is alive. RAII so a @@ -154,7 +164,7 @@ mod tests { /// travels back out as a deletion. #[test] fn an_unreadable_store_is_not_an_empty_one() { - let _guard = test_guard(); + let _guard = test_guard_blocking(); store(WEBDAV_PASSWORD, "app-password").expect("store"); assert_eq!( read(WEBDAV_PASSWORD).expect("readable"), @@ -181,7 +191,7 @@ mod tests { #[test] fn a_secret_round_trips_and_clearing_removes_it() { - let _guard = test_guard(); + let _guard = test_guard_blocking(); store(WEBDAV_PASSWORD, "app-password").expect("store"); assert_eq!(load(WEBDAV_PASSWORD), "app-password"); @@ -193,7 +203,7 @@ mod tests { #[test] fn the_two_secrets_do_not_share_an_entry() { - let _guard = test_guard(); + let _guard = test_guard_blocking(); store(WEBDAV_PASSWORD, "password").expect("store"); store(SNAPSHOT_PASSPHRASE, "passphrase").expect("store"); assert_eq!(load(WEBDAV_PASSWORD), "password"); diff --git a/src-tauri/src/commands/config_sync/local_io.rs b/src-tauri/src/commands/config_sync/local_io.rs index ffc17c2b05..cc939a3bfe 100644 --- a/src-tauri/src/commands/config_sync/local_io.rs +++ b/src-tauri/src/commands/config_sync/local_io.rs @@ -400,7 +400,7 @@ mod tests { /// should not be told their own file is not a codeg config. #[tokio::test] async fn an_encrypted_remote_config_json_is_accepted_with_the_stored_passphrase() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; let source = fresh_in_memory_db().await; seed_message(&source.conn, "Sealed").await; let snapshot = collect_snapshot_core(&source.conn).await.expect("collect"); diff --git a/src-tauri/src/commands/config_sync/webdav_sync.rs b/src-tauri/src/commands/config_sync/webdav_sync.rs index 2f126c489b..ce5576abc9 100644 --- a/src-tauri/src/commands/config_sync/webdav_sync.rs +++ b/src-tauri/src/commands/config_sync/webdav_sync.rs @@ -977,7 +977,7 @@ mod tests { #[tokio::test] async fn the_view_never_carries_the_password() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; let db = fresh_in_memory_db().await; let view = save_settings_core(&db.conn, input()).await.expect("save"); assert!(view.has_password); @@ -999,7 +999,7 @@ mod tests { /// either. #[tokio::test] async fn the_settings_row_holds_no_secret() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; let db = fresh_in_memory_db().await; save_settings_core( &db.conn, @@ -1032,7 +1032,7 @@ mod tests { /// into the keyring, and erased. #[tokio::test] async fn a_password_written_by_an_older_build_is_migrated_out_of_the_row() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; credentials::store(WEBDAV_PASSWORD, "").expect("start clean"); let db = fresh_in_memory_db().await; app_metadata_service::upsert_value( @@ -1068,7 +1068,7 @@ mod tests { /// archive taken from it) for the life of the install. #[tokio::test] async fn an_interrupted_migration_is_finished_by_the_next_load() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; credentials::store(WEBDAV_PASSWORD, "legacy-secret").expect("keyring half succeeded"); let db = fresh_in_memory_db().await; app_metadata_service::upsert_value( @@ -1105,7 +1105,7 @@ mod tests { /// create, arrived at behind its back. #[tokio::test] async fn the_migration_removes_the_password_and_nothing_else() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; credentials::store(WEBDAV_PASSWORD, "").expect("start clean"); let db = fresh_in_memory_db().await; app_metadata_service::upsert_value( @@ -1142,7 +1142,7 @@ mod tests { /// sync off, or change its interval, for as long as it stayed that way. #[tokio::test] async fn a_save_that_carries_no_secret_leaves_an_unreadable_store_alone() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; let db = fresh_in_memory_db().await; credentials::store(WEBDAV_PASSWORD, "app-password").expect("seed"); credentials::store(SNAPSHOT_PASSPHRASE, "hunter2").expect("seed"); @@ -1182,7 +1182,7 @@ mod tests { /// still be holding the old host's copy. #[tokio::test] async fn a_save_writes_the_secrets_it_was_given_and_erases_an_orphaned_one() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; let db = fresh_in_memory_db().await; credentials::store(WEBDAV_PASSWORD, "").expect("start clean"); credentials::store(SNAPSHOT_PASSPHRASE, "").expect("start clean"); @@ -1230,7 +1230,7 @@ mod tests { /// so it wins and the stale row copy is erased rather than resurrected. #[tokio::test] async fn the_keyring_copy_wins_over_a_stale_row_copy() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; credentials::store(WEBDAV_PASSWORD, "current").expect("store"); let db = fresh_in_memory_db().await; app_metadata_service::upsert_value( @@ -1526,7 +1526,7 @@ mod tests { /// baseline has no such window. #[tokio::test] async fn a_baseline_does_not_carry_over_to_a_new_remote() { - let _guard = credentials::test_guard(); + let _guard = credentials::test_guard().await; let db = fresh_in_memory_db().await; save_settings_core(&db.conn, input()).await.expect("save"); let hash = seed_uploaded_baseline(&db).await; From 20074cb751e9546a4f915a4d350af54e9e105dca Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 16:45:56 +0800 Subject: [PATCH 9/9] fix(config-sync): the update test's transport double ignored the event name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `system-network-settings.test.tsx` captures the provider's live `app_update_state` handler by overwriting a single module-level slot from its `subscribe` double — for any event, because the double took `_event` and dropped it. That held only while the page had exactly one subscriber. This PR embeds `` in that page, and the panel subscribes to `config_sync_status` on mount; whichever landed last won the slot, and the panel's did. `closes a stale rollback dialog when an upgrade becomes staged` then pushed `ready_to_restart` into the config-sync status handler instead of the update provider's. The update state never advanced, so `canRollback` stayed true, the auto-close effect never fired, and the case failed on a dialog that was still open — an assertion nowhere near the cause. The real `Transport.subscribe(event, handler)` routes per event, so both handlers stay separate in production: the double was lying, not covering for a product bug. Route by name in the double too, which also keeps the next section added to this page from silently stealing the handle again. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/system-network-settings.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/settings/system-network-settings.test.tsx b/src/components/settings/system-network-settings.test.tsx index 32be7afe13..03f75dd694 100644 --- a/src/components/settings/system-network-settings.test.tsx +++ b/src/components/settings/system-network-settings.test.tsx @@ -5,10 +5,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const call = vi.fn() // Capture the provider's app_update_state handler so tests can push live // lifecycle transitions. +// +// Route by event name, exactly as the real transport does. A double that +// captured every subscription into this one slot would hand `liveHandler` to +// whichever descendant of this page happened to subscribe LAST — the page +// embeds other sections, and one of them listening for its own events would +// silently take over the lifecycle handle. The push below would then land on +// a stranger, the update state would never advance, and the failure would +// surface as an unrelated assertion about the UI. let liveHandler: ((s: unknown) => void) | null = null const subscribe = vi.fn( - async (_event: string, handler: (s: unknown) => void) => { - liveHandler = handler + async (event: string, handler: (s: unknown) => void) => { + if (event === "app_update_state") liveHandler = handler return () => {} } )