diff --git a/src-tauri/src/app_error.rs b/src-tauri/src/app_error.rs index c88b8c4881..468c874c75 100644 --- a/src-tauri/src/app_error.rs +++ b/src-tauri/src/app_error.rs @@ -68,6 +68,53 @@ 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"; +/// 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")] pub enum AppErrorCode { 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/auto_sync.rs b/src-tauri/src/commands/config_sync/auto_sync.rs new file mode 100644 index 0000000000..2e1dd6f6c4 --- /dev/null +++ b/src-tauri/src/commands/config_sync/auto_sync.rs @@ -0,0 +1,243 @@ +//! 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. +/// +/// `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 +/// 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, + settings.is_configured(), + 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, 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/credentials.rs b/src-tauri/src/commands/config_sync/credentials.rs new file mode 100644 index 0000000000..e3a3fe0413 --- /dev/null +++ b/src-tauri/src/commands/config_sync/credentials.rs @@ -0,0 +1,215 @@ +//! 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 +/// 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") + .with_detail(e) + .with_i18n( + crate::app_error::CONFIG_SYNC_I18N_KEY_CREDENTIALS_UNREADABLE, + std::collections::BTreeMap::new(), + ) + }) +} + +/// 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. +/// +/// 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)] +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 +/// 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_blocking(); + store(WEBDAV_PASSWORD, "app-password").expect("store"); + 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" + ); + // `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!(read(WEBDAV_PASSWORD).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_blocking(); + 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_blocking(); + 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 new file mode 100644 index 0000000000..e122a9746d --- /dev/null +++ b/src-tauri/src/commands/config_sync/domains.rs @@ -0,0 +1,924 @@ +//! 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>; +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. +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, + /// 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, +} + +/// 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, + 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, + }, +]; + +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(()) +} + +/// 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(), + _ => 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)) +} + +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() { + 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 + /// 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 + ); + } + } + } + + /// 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. + #[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 new file mode 100644 index 0000000000..cc939a3bfe --- /dev/null +++ b/src-tauri/src/commands/config_sync/local_io.rs @@ -0,0 +1,611 @@ +//! 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, 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, 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; + +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, read_rollback, resolve_rollback, + 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 { + /// 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(), ENCRYPTION_NONE); + Ok(ConfigExportFile { + codeg_config_export: EXPORT_FORMAT_VERSION, + manifest, + config: snapshot, + }) +} + +/// 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, + 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()) + })?; + let counts = export.config.counts(); + + 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, + }) +} + +/// 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()) + .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 — and a domain payload that would abort the + // apply — 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); + } + + if crypto::is_encrypted_value(&value) { + let payload = crypto::parse_envelope(value)?; + let plain = crypto::decrypt(&payload, passphrase)?; + return synthesize_export(&plain); + } + + synthesize_export(bytes) +} + +/// Wrap a bare `config.json` in the envelope the rest of the import path +/// expects, with a manifest describing the snapshot as it now stands in memory. +fn synthesize_export(snapshot_bytes: &[u8]) -> 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, + config: snapshot, + }) +} + +/// The passphrase the user configured for WebDAV snapshots, reused here so a +/// `config.json` pulled out of a cloud drive's web UI imports without a second +/// place to type it. +fn stored_passphrase() -> 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, &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 { + 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, + }) +} + +pub async fn import_from_file_core( + conn: &DatabaseConnection, + path: &Path, + rollbacks: &Path, +) -> 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, 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, 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 + // 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, rollbacks).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, dir).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`]. +/// +/// `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) => { + tracing::warn!("[CONFIG-SYNC] rollback snapshot not collected: {err}"); + return None; + } + }; + 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 rollbacks = tempfile::tempdir().expect("tempdir"); + let result = import_from_file_core(&target.conn, &dest, rollbacks.path()) + .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)); + } + + /// 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().await; + 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 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"); + } + + /// 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; + let rollbacks = tempfile::tempdir().expect("tempdir"); + import_bytes_core(&target.conn, &bytes, rollbacks.path()) + .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] + 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, dir.path()) + .await + .expect("import compact"); + assert_eq!( + quick_message::Entity::find() + .all(&target.conn) + .await + .expect("messages") + .len(), + 1 + ); + } + + /// 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() + ); + + // 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] + 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, dir.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..14dedd3a38 --- /dev/null +++ b/src-tauri/src/commands/config_sync/mod.rs @@ -0,0 +1,220 @@ +//! 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. +//! +//! **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 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 — except in a browser, +// which has no path to pass and posts the bytes instead (`*_content`). + +#[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::{ + 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::{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, + }; + + use super::APP_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), &rollback_dir()).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 + } + + // ── 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(), &rollback_dir()).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")] +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..eac2202810 --- /dev/null +++ b/src-tauri/src/commands/config_sync/portable_keys.rs @@ -0,0 +1,101 @@ +//! 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. + +/// `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. +/// +/// 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] = &[ + super::webdav_sync::CONFIG_SYNC_SETTINGS_KEY, + super::webdav_sync::CONFIG_SYNC_STATE_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..ad32ed177a --- /dev/null +++ b/src-tauri/src/commands/config_sync/snapshot.rs @@ -0,0 +1,1048 @@ +//! 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_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 +/// 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; + +/// 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"; + +/// 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(id, 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)?; + 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") + .with_detail(e.to_string()) + .with_i18n(CONFIG_SYNC_I18N_KEY_INVALID_SNAPSHOT, BTreeMap::new()) + }) +} + +/// `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.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 !matches!( + manifest.encryption.as_str(), + ENCRYPTION_NONE | ENCRYPTION_AES_GCM + ) { + 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, 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 Some(value); + } + } + } + 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. +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}"); + } + } +} + +/// 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 +} + +/// 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()) +} + +/// 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 { + if !is_rollback_id(id) { + 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::*; + 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(), ENCRYPTION_NONE); + + 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 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:?}" + ); + } + + /// 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"); + + // 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)); + 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() + ); + + // 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] + 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..ce5576abc9 --- /dev/null +++ b/src-tauri/src/commands/config_sync/webdav_sync.rs @@ -0,0 +1,1576 @@ +//! 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::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, 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}; + +/// 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"; + +/// 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, + /// 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, + 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() + } + + /// 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, 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: + /// "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}{}\u{0}{}", + self.server_url, + self.remote_dir, + self.profile, + self.protection() + ) + } +} + +impl Default for ConfigSyncSettings { + fn default() -> Self { + Self { + enabled: false, + 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, + 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 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, + 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(), + encrypt: settings.encrypt, + has_passphrase: !settings.passphrase.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, + /// 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)] + 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, + /// 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, +} + +#[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. 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, + Ok(None) => return ConfigSyncSettings::default(), + Err(err) => { + tracing::warn!("[CONFIG-SYNC] failed to read sync settings: {err}"); + return ConfigSyncSettings::default(); + } + }; + 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}"), + } +} + +pub async fn save_settings_core( + conn: &DatabaseConnection, + input: ConfigSyncSettingsInput, +) -> Result { + 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". + 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") + .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)?; + + // 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)) +} + +/// 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( + existing: &ConfigSyncSettings, + input: ConfigSyncSettingsInput, +) -> Result { + let server_url = input.server_url.trim().to_string(); + let username = input.username.trim().to_string(); + 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 + // means "I did not retype it", which is what an empty password field + // means to every user who has ever seen one. + _ if same_account => existing.password.clone(), + _ => 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)?; + + Ok(ConfigSyncSettings { + enabled: input.enabled, + server_url, + username, + password, + passphrase, + encrypt: input.encrypt, + passphrase_id, + 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(); + + // 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; + 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 { + 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)?; + // 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()) + })?; + + let result = async { + let _guard = remote_lock().lock().await; + client.ensure_dir(&dir).await?; + client + .put(&format!("{dir}/{CONFIG_FILE_NAME}"), payload) + .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_uploaded_target = Some(target); + 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) + } + } +} + +/// 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 +/// 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 — 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 + // 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, &super::snapshot::rollback_dir()).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()), + passphrase: None, + encrypt: false, + remote_dir: Some("codeg".to_string()), + profile: Some("work".to_string()), + auto_sync: true, + interval_minutes: 5, + } + } + + /// 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( + &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"); + + // 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] + 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 _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); + + let serialized = serde_json::to_string(&view).expect("serialize"); + assert!( + !serialized.contains("app-password"), + "password leaked to the frontend: {serialized}" + ); + + // 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().await; + 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().await; + 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().await; + 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().await; + 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 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 a_save_that_carries_no_secret_leaves_an_unreadable_store_alone() { + 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"); + // 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 _unreadable = credentials::unreadable_store(); + 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 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().await; + 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. + #[tokio::test] + async fn the_keyring_copy_wins_over_a_stale_row_copy() { + 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( + &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 { + 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_uploaded_target: Some(load_settings(&db.conn).await.remote_target()), + last_sync_at: Some("2026-01-01T00:00:00Z".to_string()), + last_error: None, + }, + ) + .await; + hash + } + + #[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_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() + ); + } + + /// 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 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")); + app_metadata_service::upsert_value( + &db.conn, + CONFIG_SYNC_STATE_KEY, + &format!(r#"{{"lastUploadedSha256":"{hash}"}}"#), + ) + .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. + let outcome = upload_snapshot_core(&db.conn, "1.0.0", false) + .await + .expect("skip without network"); + 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 _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; + + // 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-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/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 b33af7db67..dd37c3b498 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,21 @@ 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, + 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/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-tauri/src/web/handlers/config_sync.rs b/src-tauri/src/web/handlers/config_sync.rs new file mode 100644 index 0000000000..d6da73a148 --- /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(), &rollback_dir()) + .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 new file mode 100644 index 0000000000..04ba3fabc9 --- /dev/null +++ b/src/components/settings/config-sync-settings.test.tsx @@ -0,0 +1,610 @@ +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 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, +})) + +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(), + importPickedConfig: vi.fn(), + listConfigRollbacks: vi.fn(), + applyConfigRollback: 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 { + applyConfigRollback, + downloadAndApplyConfig, + getConfigSyncSettings, + getConfigSyncState, + importPickedConfig, + listConfigRollbacks, + 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, + 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, + 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, + lastUploadedTarget: null, + lastSyncAt: null, + lastError: null, + }) + vi.mocked(updateConfigSyncSettings).mockResolvedValue({ ...SAVED }) + vi.mocked(listConfigRollbacks).mockResolvedValue([]) +}) + +describe("ConfigSyncSettings — availability", () => { + /// 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 + renderPanel() + await screen.findByRole("button", { name: t.saveButton }) + expect(getConfigSyncSettings).toHaveBeenCalled() + }) + + it("renders for a remote-desktop window", async () => { + env.remoteId = "remote-1" + renderPanel() + await screen.findByRole("button", { name: t.saveButton }) + }) + + 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("")) + }) + + /// 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() + fireEvent.click(screen.getByRole("switch", { name: t.webdavTitle })) + 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, + 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 () => { + 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(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 + // and importing was impossible. + const confirm = screen.getByRole("button", { + name: t.importConfirmAction, + }) + expect(confirm).toBeEnabled() + fireEvent.click(confirm) + await waitFor(() => + expect(importPickedConfig).toHaveBeenCalledWith(picked.source) + ) + }) + + /// 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, + 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({ + ...pickedPath({ modelProviders: 2 }), + preview: { + manifest: manifest({ counts: { modelProviders: 99 } }), + counts: { modelProviders: 2 }, + }, + }) + await renderLoaded() + fireEvent.click(screen.getByRole("button", { name: t.importButton })) + 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 () => { + 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 — 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)) + }) + + /// 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 — 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. + /// 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", () => { + 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() + 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..4be54be223 --- /dev/null +++ b/src/components/settings/config-sync-settings.tsx @@ -0,0 +1,1092 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { + Check, + CloudUpload, + FileDown, + FileUp, + Loader2, + RefreshCw, + ShieldAlert, + ShieldCheck, + Undo2, + 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 { + applyConfigRollback, + downloadAndApplyConfig, + exportConfigToFile, + getConfigSyncSettings, + getConfigSyncState, + importPickedConfig, + listConfigRollbacks, + listenConfigSyncStatus, + peekRemoteConfig, + pickConfigFileToImport, + summarizeCounts, + testConfigSyncConnection, + updateConfigSyncSettings, + uploadConfigNow, + 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 + * "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" +} + +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] + ) + + 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) + // 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: "", + }) + // 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) + 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" + | "rollback" + >(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. + const mounted = useRef(true) + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) + + /** 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(() => { + let cancelled = false + void (async () => { + try { + const [settings, state] = await Promise.all([ + getConfigSyncSettings(), + getConfigSyncState(), + refreshRollbacks(), + ]) + if (cancelled) return + setEnabled(settings.enabled) + setServerUrl(settings.serverUrl) + setPreset(presetFromUrl(settings.serverUrl)) + setUsername(settings.username) + setHasPassword(settings.hasPassword) + setSavedAccount({ + serverUrl: settings.serverUrl, + username: settings.username, + }) + setEncrypt(settings.encrypt) + setHasPassphrase(settings.hasPassphrase) + 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 + } + }, [refreshRollbacks]) + + // The background uploader reports here; without this the panel would show a + // stale "last synced" until the page is reopened. + useEffect(() => { + 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?.() + } + }, []) + + const currentInput = useCallback( + ( + overrides?: Partial + ): ConfigSyncSettingsInput => ({ + enabled, + 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, + intervalMinutes, + ...overrides, + }), + [ + enabled, + serverUrl, + username, + password, + passphrase, + encrypt, + remoteDir, + profile, + autoSync, + intervalMinutes, + ] + ) + + const handleSave = useCallback(async () => { + setBusy("save") + try { + const saved = await updateConfigSyncSettings(currentInput()) + if (!mounted.current) return + setHasPassword(saved.hasPassword) + setHasPassphrase(saved.hasPassphrase) + setSavedAccount({ + serverUrl: saved.serverUrl, + username: saved.username, + }) + setRemoteDir(saved.remoteDir) + setProfile(saved.profile) + setIntervalMinutes(saved.intervalMinutes) + // 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)) + } finally { + if (mounted.current) setBusy(null) + } + }, [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 + * 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 { + // 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, password: null, passphrase: null }) + ) + if (!mounted.current) return + setHasPassword(saved.hasPassword) + setHasPassphrase(saved.hasPassphrase) + setSavedAccount({ + serverUrl: saved.serverUrl, + username: saved.username, + }) + } 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 { + 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 + // 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"), + }) + // 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, refreshRollbacks, 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]) + + /** + * 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 () => { + try { + const picked = await pickConfigFileToImport() + if (picked && mounted.current) setPendingImport(picked) + } catch (err) { + toast.error(localize(err)) + } + }, [localize]) + + const handleConfirmImport = useCallback(async () => { + if (!pendingImport) return + const source = pendingImport.source + setPendingImport(null) + setBusy("import") + try { + 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, refreshRollbacks, t]) + + /** + * 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. + const presetHint = preset === "custom" ? null : t(`presetHint.${preset}`) + 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 ( +
+
+ +

{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")}

+
+ + {/* 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} + /> +
+ + {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={ + keepsStoredPassword + ? t("passwordKeep") + : t("passwordPlaceholder") + } + autoComplete="new-password" + /> +
+
+ +
+
+ + setRemoteDir(e.target.value)} + spellCheck={false} + /> +
+
+ + setProfile(e.target.value)} + spellCheck={false} + /> +
+
+

{t("profileHint")}

+ +
+
+ +

+ {t("encryptHint")} +

+
+ +
+ + {encrypt ? ( +
+ + setPassphrase(e.target.value)} + placeholder={ + hasPassphrase + ? t("passphraseKeep") + : t("passphrasePlaceholder") + } + autoComplete="new-password" + /> +

+ {t("passphraseHint")} +

+
+ ) : null} + +
+
+ +

+ {t("autoSyncHint")} +

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

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

+ {lastError ? ( +

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

+ ) : null} + {remoteBusy ?

{t("working")}

: null} +
+ + {/* 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} +
+ + { + if (!open) setPendingImport(null) + }} + > + + + {t("importConfirmTitle")} + +
+

{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")}

+
+
+
+ + {t("cancel")} + { + e.preventDefault() + void handleConfirmImport() + }} + > + {t("importConfirmAction")} + + +
+
+ + { + 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")} + + +
+
+ + + + + {t("restoreConfirmTitle")} + +
+

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

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

{t("restartHint")}

+
+
+
+ + {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.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 () => {} } ) 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() { + + ({ 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", () => { + 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([]) + }) +}) + +/** + * 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() + }) + + /// 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 new file mode 100644 index 0000000000..06f8cfc832 --- /dev/null +++ b/src/lib/config-sync.ts @@ -0,0 +1,393 @@ +/** + * 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. + * + * 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. */ +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 + 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 + /** 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 { + applied: ApplyReport + rollbackPath: string | null +} + +export interface ConfigSyncSettingsView { + enabled: boolean + serverUrl: string + username: string + /** 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 + 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 + /** 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 + * 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 +} + +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}` +} + +/** 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: fileName, + 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 { + 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, + 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 { source: { kind: "path", path: srcPath, label: srcPath }, preview } +} + +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: 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 — 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. + * + * 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") + input.type = "file" + input.accept = ".json,application/json" + input.style.display = "none" + + let settled = false + const finish = (file: File | null) => { + if (settled) return + settled = true + input.remove() + resolve(file) + } + input.addEventListener("change", () => finish(input.files?.[0] ?? null)) + input.addEventListener("cancel", () => finish(null)) + document.body.appendChild(input) + input.click() + }) +} + +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 + ) +}