use aes_gcm::{ aead::{rand_core::RngCore, Aead, OsRng}, Aes256Gcm, KeyInit, Nonce, }; use argon2::{Algorithm, Argon2, Params, Version}; use base64::{engine::general_purpose::STANDARD as BASE64, engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; use reqwest::{header, Client, Method, StatusCode, Url}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::ai::AiConfig; use crate::connection_secrets::{ MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY, }; use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig}; use crate::saved_sql::SavedSqlLibrary; use crate::storage::{DesktopSettings, Storage}; const SNAPSHOT_SCHEMA_VERSION: u32 = 1; const DEFAULT_REMOTE_PATH: &str = "DBX/sync/snapshot.json"; const DEFAULT_SNIPPET_FILE_NAME: &str = "dbx-sync.json"; const GITHUB_API_BASE: &str = "https://api.github.com"; const GITEE_API_BASE: &str = "https://gitee.com/api/v5"; const SECRET_KEYS: &[&str] = &[ "password", "ssh_password", "ssh_key_passphrase", "proxy_password", "redis_sentinel_password", "connection_string", MQ_AUTH_TOKEN_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY, ]; const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels."; const TRANSPORT_LAYER_SECRET_PREFIX: &str = "transport_layers."; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WebDavConfig { pub endpoint: String, pub username: Option, pub password: Option, pub remote_path: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SnippetProvider { #[serde(rename = "github", alias = "git_hub")] GitHub, #[serde(rename = "gitee")] Gitee, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SnippetSyncConfig { pub provider: SnippetProvider, pub token: Option, pub snippet_id: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SnippetTokenStatus { pub has_saved_token: bool, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WebDavPasswordStatus { pub has_saved_password: bool, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WebDavSyncSecretsStatus { pub enabled: bool, pub has_saved_passphrase: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncSnapshot { pub schema_version: u32, pub exported_at: String, pub app_version: String, pub connections: Vec, /// Shared tunnel profiles (secrets scrubbed). `None` means the snapshot /// predates tunnel profiles — applying it leaves local profiles alone. #[serde(default)] pub tunnel_profiles: Option>, pub sidebar_layout: Option, pub pinned_tree_node_ids: Vec, pub saved_sql: SavedSqlLibrary, pub desktop_settings: DesktopSettings, pub editor_settings: Option, pub encrypted_secrets: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EncryptedSecretsBlob { pub version: u32, pub kdf: String, pub cipher: String, pub salt: String, pub nonce: String, pub ciphertext: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SensitiveSyncPayload { pub connection_secrets: Vec, pub ai_config: Option, /// Full tunnel profiles including their secrets. #[serde(default)] pub tunnel_profiles: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConnectionSecretSnapshot { pub connection_id: String, pub key: String, pub secret: String, } #[derive(Debug, Clone, Copy, Default)] pub struct ApplySnapshotOptions<'a> { pub secrets_passphrase: Option<&'a str>, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplySnapshotSummary { pub encrypted_secrets_present: bool, pub secrets_applied: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WebDavSyncSummary { pub remote_path: String, pub bytes: usize, pub exported_at: Option, pub app_version: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SnippetSyncSummary { pub provider: SnippetProvider, pub snippet_id: String, pub bytes: usize, pub exported_at: Option, pub app_version: Option, } pub async fn build_sync_snapshot( storage: &Storage, app_version: impl Into, editor_settings: Option, secrets_passphrase: Option<&str>, ) -> Result { let mut connections = storage.load_connections().await?; let mut tunnel_profiles = storage.load_tunnel_profiles().await?; let encrypted_secrets = match normalized_passphrase(secrets_passphrase) { Some(passphrase) => Some(encrypt_sensitive_payload( &build_sensitive_payload(storage, &connections, &tunnel_profiles).await?, passphrase, )?), None => None, }; for config in &mut connections { scrub_connection_secrets(config); } for profile in &mut tunnel_profiles { profile.scrub_secrets(); } Ok(SyncSnapshot { schema_version: SNAPSHOT_SCHEMA_VERSION, exported_at: Utc::now().to_rfc3339(), app_version: app_version.into(), connections, tunnel_profiles: Some(tunnel_profiles), sidebar_layout: storage.load_sidebar_layout().await?, pinned_tree_node_ids: storage.load_pinned_tree_node_ids().await?, saved_sql: storage.load_saved_sql_library().await?, desktop_settings: storage.load_desktop_settings().await?, editor_settings, encrypted_secrets, }) } pub async fn build_sync_snapshot_with_saved_secrets( storage: &Storage, app_version: impl Into, editor_settings: Option, secrets_passphrase: Option<&str>, ) -> Result { match normalized_passphrase(secrets_passphrase) { Some(passphrase) => build_sync_snapshot(storage, app_version, editor_settings, Some(passphrase)).await, None => { let saved_passphrase = resolve_webdav_sync_secrets_passphrase(storage).await?; build_sync_snapshot(storage, app_version, editor_settings, saved_passphrase.as_deref()).await } } } pub async fn apply_sync_snapshot( storage: &Storage, snapshot: &SyncSnapshot, options: ApplySnapshotOptions<'_>, ) -> Result { if snapshot.schema_version != SNAPSHOT_SCHEMA_VERSION { return Err(format!("Unsupported sync snapshot schema version: {}", snapshot.schema_version)); } let encrypted_secrets_present = snapshot.encrypted_secrets.is_some(); let sensitive_payload = match (&snapshot.encrypted_secrets, normalized_passphrase(options.secrets_passphrase)) { (Some(blob), Some(passphrase)) => Some(decrypt_sensitive_payload(blob, passphrase)?), _ => None, }; let mut connections = snapshot.connections.clone(); for config in &mut connections { scrub_connection_secrets(config); } storage.save_connection_metadata_preserving_secrets(&connections).await?; if let Some(profiles) = &snapshot.tunnel_profiles { storage.save_tunnel_profiles_preserving_secrets(profiles).await?; } if let Some(layout) = &snapshot.sidebar_layout { storage.save_sidebar_layout(layout).await?; } storage.save_pinned_tree_node_ids(&snapshot.pinned_tree_node_ids).await?; storage.replace_saved_sql_library(&snapshot.saved_sql).await?; storage.save_desktop_settings(&snapshot.desktop_settings).await?; if let Some(payload) = &sensitive_payload { clear_connection_secrets(storage, &connections).await?; apply_sensitive_payload(storage, payload).await?; } Ok(ApplySnapshotSummary { encrypted_secrets_present, secrets_applied: sensitive_payload.is_some() }) } pub struct WebDavClient { http: Client, config: WebDavConfig, } pub struct SnippetSyncClient { http: Client, config: SnippetSyncConfig, } pub async fn webdav_saved_password_status( storage: &Storage, config: &WebDavConfig, ) -> Result { let account = webdav_password_account(config); Ok(WebDavPasswordStatus { has_saved_password: storage.load_webdav_password_blob(&account).await?.is_some() }) } pub async fn save_webdav_password(storage: &Storage, config: &WebDavConfig, password: &str) -> Result<(), String> { let secret = storage.load_or_create_local_device_secret().await?; let blob = encrypt_text_with_secret(password, &secret)?; let value = serde_json::to_value(blob).map_err(|e| e.to_string())?; storage.save_webdav_password_blob(&webdav_password_account(config), &value).await } pub async fn forget_webdav_password(storage: &Storage, config: &WebDavConfig) -> Result<(), String> { storage.delete_webdav_password_blob(&webdav_password_account(config)).await } pub async fn resolve_webdav_password(storage: &Storage, config: &mut WebDavConfig) -> Result<(), String> { if config.password.as_deref().is_some_and(|password| !password.is_empty()) { return Ok(()); } let Some(value) = storage.load_webdav_password_blob(&webdav_password_account(config)).await? else { return Ok(()); }; let blob: EncryptedSecretsBlob = serde_json::from_value(value).map_err(|e| e.to_string())?; let secret = storage.load_or_create_local_device_secret().await?; config.password = Some(decrypt_text_with_secret(&blob, &secret)?); Ok(()) } pub async fn snippet_saved_token_status( storage: &Storage, config: &SnippetSyncConfig, ) -> Result { let account = snippet_token_account(config.provider); Ok(SnippetTokenStatus { has_saved_token: storage.load_webdav_password_blob(&account).await?.is_some() }) } pub async fn save_snippet_token(storage: &Storage, config: &SnippetSyncConfig, token: &str) -> Result<(), String> { let secret = storage.load_or_create_local_device_secret().await?; let blob = encrypt_text_with_secret(token, &secret)?; let value = serde_json::to_value(blob).map_err(|e| e.to_string())?; storage.save_webdav_password_blob(&snippet_token_account(config.provider), &value).await } pub async fn forget_snippet_token(storage: &Storage, config: &SnippetSyncConfig) -> Result<(), String> { storage.delete_webdav_password_blob(&snippet_token_account(config.provider)).await } pub async fn resolve_snippet_token(storage: &Storage, config: &mut SnippetSyncConfig) -> Result<(), String> { if config.token.as_deref().is_some_and(|token| !token.trim().is_empty()) { return Ok(()); } let Some(value) = storage.load_webdav_password_blob(&snippet_token_account(config.provider)).await? else { return Ok(()); }; let blob: EncryptedSecretsBlob = serde_json::from_value(value).map_err(|e| e.to_string())?; let secret = storage.load_or_create_local_device_secret().await?; config.token = Some(decrypt_text_with_secret(&blob, &secret)?); Ok(()) } pub async fn webdav_sync_secrets_status(storage: &Storage) -> Result { Ok(WebDavSyncSecretsStatus { enabled: storage.load_webdav_sync_secrets_enabled().await?, has_saved_passphrase: storage.load_webdav_sync_secrets_passphrase_blob().await?.is_some(), }) } pub async fn save_webdav_sync_secrets_preference( storage: &Storage, enabled: bool, passphrase: Option<&str>, ) -> Result<(), String> { let normalized = normalized_passphrase(passphrase); let blob = match normalized { Some(passphrase) => { let secret = storage.load_or_create_local_device_secret().await?; let blob = encrypt_text_with_secret(passphrase, &secret)?; Some(serde_json::to_value(blob).map_err(|e| e.to_string())?) } None => None, }; storage.save_webdav_sync_secrets_preference(enabled, blob.as_ref()).await } pub async fn forget_webdav_sync_secrets_passphrase(storage: &Storage) -> Result<(), String> { storage.delete_webdav_sync_secrets_passphrase_blob().await } pub async fn resolve_webdav_sync_secrets_passphrase(storage: &Storage) -> Result, String> { if !storage.load_webdav_sync_secrets_enabled().await? { return Ok(None); } let Some(value) = storage.load_webdav_sync_secrets_passphrase_blob().await? else { return Ok(None); }; let blob: EncryptedSecretsBlob = serde_json::from_value(value).map_err(|e| e.to_string())?; let secret = storage.load_or_create_local_device_secret().await?; decrypt_text_with_secret(&blob, &secret).map(Some) } impl WebDavClient { pub fn new(config: WebDavConfig) -> Self { Self { http: Client::new(), config } } pub fn remote_path(&self) -> String { normalized_remote_path(self.config.remote_path.as_deref()) } pub async fn test(&self) -> Result<(), String> { let method = Method::from_bytes(b"PROPFIND").map_err(|e| e.to_string())?; let response = self.request(method, "")?.header("Depth", "0").send().await.map_err(|e| e.to_string())?; let status = response.status(); if status.is_success() { Ok(()) } else { Err(format!("WebDAV test failed with HTTP {status}")) } } pub async fn put_snapshot(&self, snapshot: &SyncSnapshot) -> Result { let remote_path = self.remote_path(); self.ensure_parent_collections(&remote_path).await?; let bytes = serde_json::to_vec_pretty(snapshot).map_err(|e| e.to_string())?; let response = self .request(Method::PUT, &remote_path)? .header(header::CONTENT_TYPE, "application/json") .body(bytes.clone()) .send() .await .map_err(|e| e.to_string())?; let status = response.status(); if !status.is_success() { return Err(format!("WebDAV upload failed with HTTP {status}")); } Ok(WebDavSyncSummary { remote_path, bytes: bytes.len(), exported_at: Some(snapshot.exported_at.clone()), app_version: Some(snapshot.app_version.clone()), }) } pub async fn get_snapshot(&self) -> Result<(SyncSnapshot, WebDavSyncSummary), String> { let remote_path = self.remote_path(); let response = self.request(Method::GET, &remote_path)?.send().await.map_err(|e| e.to_string())?; let status = response.status(); if !status.is_success() { return Err(format!("WebDAV download failed with HTTP {status}")); } let bytes = response.bytes().await.map_err(|e| e.to_string())?; let snapshot: SyncSnapshot = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; let summary = WebDavSyncSummary { remote_path, bytes: bytes.len(), exported_at: Some(snapshot.exported_at.clone()), app_version: Some(snapshot.app_version.clone()), }; Ok((snapshot, summary)) } async fn ensure_parent_collections(&self, remote_path: &str) -> Result<(), String> { let method = Method::from_bytes(b"MKCOL").map_err(|e| e.to_string())?; for parent in parent_collection_paths(remote_path) { let response = self.request(method.clone(), &parent)?.send().await.map_err(|e| e.to_string())?; let status = response.status(); if status.is_success() || status == StatusCode::METHOD_NOT_ALLOWED { continue; } return Err(format!("Failed to create WebDAV collection '{parent}' with HTTP {status}")); } Ok(()) } fn request(&self, method: Method, remote_path: &str) -> Result { let url = self.remote_url(remote_path)?; let mut request = self.http.request(method, url); if let Some(username) = self.config.username.as_deref().filter(|value| !value.is_empty()) { request = request.basic_auth(username, self.config.password.clone()); } Ok(request) } fn remote_url(&self, remote_path: &str) -> Result { let endpoint = self.config.endpoint.trim(); if endpoint.is_empty() { return Err("WebDAV endpoint is required".to_string()); } let base = if endpoint.ends_with('/') { endpoint.to_string() } else { format!("{endpoint}/") }; let base = Url::parse(&base).map_err(|e| e.to_string())?; base.join(remote_path.trim_start_matches('/')).map_err(|e| e.to_string()) } } impl SnippetSyncClient { pub fn new(config: SnippetSyncConfig) -> Self { Self { http: Client::new(), config } } pub async fn test(&self) -> Result<(), String> { self.require_token()?; let url = match (self.config.provider, normalized_snippet_id(self.config.snippet_id.as_deref())) { (SnippetProvider::GitHub, Some(id)) => format!("{GITHUB_API_BASE}/gists/{id}"), (SnippetProvider::GitHub, None) => format!("{GITHUB_API_BASE}/user"), (SnippetProvider::Gitee, Some(id)) => format!("{GITEE_API_BASE}/gists/{id}"), (SnippetProvider::Gitee, None) => format!("{GITEE_API_BASE}/user"), }; let response = self.request(Method::GET, &url)?.send().await.map_err(|e| e.to_string())?; ensure_snippet_success(response.status(), "test") } pub async fn put_snapshot(&self, snapshot: &SyncSnapshot) -> Result { self.require_token()?; let bytes = serde_json::to_vec_pretty(snapshot).map_err(|e| e.to_string())?; let content = String::from_utf8(bytes.clone()).map_err(|e| e.to_string())?; let existing_id = normalized_snippet_id(self.config.snippet_id.as_deref()); let (method, url) = match (self.config.provider, existing_id) { (SnippetProvider::GitHub, Some(id)) => (Method::PATCH, format!("{GITHUB_API_BASE}/gists/{id}")), (SnippetProvider::GitHub, None) => (Method::POST, format!("{GITHUB_API_BASE}/gists")), (SnippetProvider::Gitee, Some(id)) => (Method::PATCH, format!("{GITEE_API_BASE}/gists/{id}")), (SnippetProvider::Gitee, None) => (Method::POST, format!("{GITEE_API_BASE}/gists")), }; let response = match self.config.provider { SnippetProvider::GitHub => { let payload = serde_json::json!({ "description": "DBX encrypted configuration sync", "public": false, "files": { DEFAULT_SNIPPET_FILE_NAME: { "content": content } } }); self.request(method, &url)?.json(&payload).send().await } SnippetProvider::Gitee => { let files = serde_json::json!({ DEFAULT_SNIPPET_FILE_NAME: { "content": content } }).to_string(); self.request(method, &url)? .form(&[ ("files", files), ("description", "DBX configuration sync".to_string()), ("public", "false".to_string()), ]) .send() .await } } .map_err(|e| e.to_string())?; let status = response.status(); let response_body = response.text().await.map_err(|e| e.to_string())?; ensure_snippet_response_success(status, "upload", &response_body)?; let value: serde_json::Value = serde_json::from_str(&response_body).map_err(|e| e.to_string())?; let snippet_id = snippet_response_id(&value) .or_else(|| existing_id.map(str::to_string)) .ok_or_else(|| "Snippet API response did not include an id".to_string())?; Ok(SnippetSyncSummary { provider: self.config.provider, snippet_id, bytes: bytes.len(), exported_at: Some(snapshot.exported_at.clone()), app_version: Some(snapshot.app_version.clone()), }) } pub async fn get_snapshot(&self) -> Result<(SyncSnapshot, SnippetSyncSummary), String> { self.require_token()?; let snippet_id = normalized_snippet_id(self.config.snippet_id.as_deref()) .ok_or_else(|| "Snippet id is required for download".to_string())?; let url = match self.config.provider { SnippetProvider::GitHub => format!("{GITHUB_API_BASE}/gists/{snippet_id}"), SnippetProvider::Gitee => format!("{GITEE_API_BASE}/gists/{snippet_id}"), }; let response = self.request(Method::GET, &url)?.send().await.map_err(|e| e.to_string())?; let status = response.status(); let response_body = response.text().await.map_err(|e| e.to_string())?; ensure_snippet_response_success(status, "download", &response_body)?; let value: serde_json::Value = serde_json::from_str(&response_body).map_err(|e| e.to_string())?; let (content, raw_url) = snippet_file_content(&value, DEFAULT_SNIPPET_FILE_NAME)?; let content = match content { Some(content) => content, None => { let raw_url = raw_url.ok_or_else(|| "Snippet file content is unavailable".to_string())?; let response = self.request(Method::GET, &raw_url)?.send().await.map_err(|e| e.to_string())?; ensure_snippet_success(response.status(), "raw download")?; response.text().await.map_err(|e| e.to_string())? } }; let snapshot: SyncSnapshot = serde_json::from_str(&content).map_err(|e| e.to_string())?; let summary = SnippetSyncSummary { provider: self.config.provider, snippet_id: snippet_id.to_string(), bytes: content.len(), exported_at: Some(snapshot.exported_at.clone()), app_version: Some(snapshot.app_version.clone()), }; Ok((snapshot, summary)) } fn require_token(&self) -> Result<&str, String> { self.config .token .as_deref() .map(str::trim) .filter(|token| !token.is_empty()) .ok_or_else(|| "Access token is required".to_string()) } fn request(&self, method: Method, url: &str) -> Result { let token = self.require_token()?; let request = self.http.request(method, url); Ok(match self.config.provider { SnippetProvider::GitHub => request .header(header::ACCEPT, "application/vnd.github+json") .header(header::USER_AGENT, "DBX") .header("X-GitHub-Api-Version", "2022-11-28") .bearer_auth(token), // Gitee API v5 documents access_token as a request parameter rather than an Authorization header. SnippetProvider::Gitee => request.query(&[("access_token", token)]), }) } } fn scrub_connection_secrets(config: &mut ConnectionConfig) { config.password.clear(); for layer in &mut config.transport_layers { match layer { TransportLayerConfig::Ssh(ssh) => { ssh.password.clear(); ssh.key_passphrase.clear(); } TransportLayerConfig::Proxy(proxy) => { proxy.password.clear(); } TransportLayerConfig::HttpTunnel(http) => { http.token.clear(); } } } config.redis_sentinel_password.clear(); config.connection_string = None; scrub_mq_external_config_secrets(config); scrub_nacos_auth_secrets(config); } fn webdav_password_account(config: &WebDavConfig) -> String { let mut hasher = Sha256::new(); hasher.update(config.endpoint.trim().as_bytes()); hasher.update(b"\n"); hasher.update(config.username.as_deref().unwrap_or("").trim().as_bytes()); URL_SAFE_NO_PAD.encode(hasher.finalize()) } async fn build_sensitive_payload( storage: &Storage, connections: &[ConnectionConfig], tunnel_profiles: &[TransportLayerConfig], ) -> Result { let mut connection_secrets = Vec::new(); for config in connections { push_secret(&mut connection_secrets, &config.id, "password", &config.password); for (index, layer) in config.transport_layers.iter().enumerate() { match layer { TransportLayerConfig::Ssh(ssh) => { push_secret( &mut connection_secrets, &config.id, &transport_layer_ssh_password_key(index, layer), &ssh.password, ); push_secret( &mut connection_secrets, &config.id, &transport_layer_ssh_key_passphrase_key(index, layer), &ssh.key_passphrase, ); } TransportLayerConfig::Proxy(proxy) => { push_secret( &mut connection_secrets, &config.id, &transport_layer_proxy_password_key(index, layer), &proxy.password, ); } TransportLayerConfig::HttpTunnel(http) => { push_secret( &mut connection_secrets, &config.id, &transport_layer_http_tunnel_token_key(index, layer), &http.token, ); } } } push_secret(&mut connection_secrets, &config.id, "redis_sentinel_password", &config.redis_sentinel_password); if let Some(connection_string) = &config.connection_string { push_secret(&mut connection_secrets, &config.id, "connection_string", connection_string); } push_mq_external_config_secrets(&mut connection_secrets, config); push_nacos_external_config_secrets(&mut connection_secrets, config); } Ok(SensitiveSyncPayload { connection_secrets, ai_config: storage.load_ai_config().await?, tunnel_profiles: Some(tunnel_profiles.to_vec()), }) } fn push_mq_external_config_secrets(secrets: &mut Vec, config: &ConnectionConfig) { let Some(external_config) = config.external_config.as_ref() else { return; }; if let Some(auth) = external_config.get("auth").and_then(serde_json::Value::as_object) { match auth.get("kind").and_then(serde_json::Value::as_str) { Some("token") => push_json_secret(secrets, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token"), Some("basic") => push_json_secret(secrets, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password"), Some("apiKey") | Some("api_key") | Some("apikey") => { push_json_secret(secrets, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value") } Some("oauth2") => push_json_secret(secrets, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret"), _ => {} } } if let Some(signing) = external_config.get("tokenSigning").and_then(serde_json::Value::as_object) { push_json_secret(secrets, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key"); } } fn scrub_mq_external_config_secrets(config: &mut ConnectionConfig) { if config.db_type != DatabaseType::MessageQueue { return; } let Some(external_config) = config.external_config.as_mut() else { return; }; if let Some(auth) = external_config.get_mut("auth").and_then(serde_json::Value::as_object_mut) { match auth.get("kind").and_then(serde_json::Value::as_str) { Some("token") => scrub_json_secret(auth, "token"), Some("basic") => scrub_json_secret(auth, "password"), Some("apiKey") | Some("api_key") | Some("apikey") => scrub_json_secret(auth, "value"), Some("oauth2") => scrub_json_secret(auth, "clientSecret"), _ => {} } } if let Some(signing) = external_config.get_mut("tokenSigning").and_then(serde_json::Value::as_object_mut) { scrub_json_secret(signing, "key"); } } fn push_nacos_external_config_secrets(secrets: &mut Vec, config: &ConnectionConfig) { if config.db_type != DatabaseType::Nacos { return; } let Some(auth) = config .external_config .as_ref() .and_then(|external_config| external_config.get("auth")) .and_then(serde_json::Value::as_object) else { return; }; if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") { push_json_secret(secrets, &config.id, NACOS_AUTH_PASSWORD_KEY, auth, "password"); } } fn push_json_secret( secrets: &mut Vec, connection_id: &str, key: &str, object: &serde_json::Map, field: &str, ) { if let Some(secret) = object.get(field).and_then(serde_json::Value::as_str) { push_secret(secrets, connection_id, key, secret); } } fn push_secret(secrets: &mut Vec, connection_id: &str, key: &str, secret: &str) { if secret.is_empty() { return; } secrets.push(ConnectionSecretSnapshot { connection_id: connection_id.to_string(), key: key.to_string(), secret: secret.to_string(), }); } fn scrub_nacos_auth_secrets(config: &mut ConnectionConfig) { if config.db_type != DatabaseType::Nacos { return; } let Some(auth) = config .external_config .as_mut() .and_then(|external_config| external_config.get_mut("auth")) .and_then(serde_json::Value::as_object_mut) else { return; }; if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") && auth.contains_key("password") { scrub_json_secret(auth, "password"); } } fn scrub_json_secret(object: &mut serde_json::Map, field: &str) { if object.contains_key(field) { object.insert(field.to_string(), serde_json::Value::String(String::new())); } } async fn apply_sensitive_payload(storage: &Storage, payload: &SensitiveSyncPayload) -> Result<(), String> { for secret in &payload.connection_secrets { if !SECRET_KEYS.contains(&secret.key.as_str()) && !secret.key.starts_with(SSH_TUNNEL_SECRET_PREFIX) && !secret.key.starts_with(TRANSPORT_LAYER_SECRET_PREFIX) { continue; } storage.set_secret(&secret.connection_id, &secret.key, &secret.secret).await?; } if let Some(ai_config) = &payload.ai_config { storage.save_ai_config(ai_config).await?; } if let Some(profiles) = &payload.tunnel_profiles { storage.save_tunnel_profiles(profiles).await?; } Ok(()) } async fn clear_connection_secrets(storage: &Storage, connections: &[ConnectionConfig]) -> Result<(), String> { for config in connections { for key in SECRET_KEYS { storage.delete_secret(&config.id, key).await?; } for (index, layer) in config.transport_layers.iter().enumerate() { match layer { TransportLayerConfig::Ssh(_) => { storage.delete_secret(&config.id, &transport_layer_ssh_password_key(index, layer)).await?; storage.delete_secret(&config.id, &transport_layer_ssh_key_passphrase_key(index, layer)).await?; } TransportLayerConfig::Proxy(_) => { storage.delete_secret(&config.id, &transport_layer_proxy_password_key(index, layer)).await?; } TransportLayerConfig::HttpTunnel(_) => { storage.delete_secret(&config.id, &transport_layer_http_tunnel_token_key(index, layer)).await?; } } } } Ok(()) } fn transport_layer_secret_segment(index: usize, layer: &TransportLayerConfig) -> String { let id = layer.id().trim(); if id.is_empty() { index.to_string() } else { id.to_string() } } fn transport_layer_ssh_password_key(index: usize, layer: &TransportLayerConfig) -> String { format!("{}{}.ssh_password", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } fn transport_layer_ssh_key_passphrase_key(index: usize, layer: &TransportLayerConfig) -> String { format!("{}{}.ssh_key_passphrase", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } fn transport_layer_proxy_password_key(index: usize, layer: &TransportLayerConfig) -> String { format!("{}{}.proxy_password", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } fn transport_layer_http_tunnel_token_key(index: usize, layer: &TransportLayerConfig) -> String { format!("{}{}.http_tunnel_token", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } fn encrypt_sensitive_payload(payload: &SensitiveSyncPayload, passphrase: &str) -> Result { let plaintext = serde_json::to_vec(payload).map_err(|e| e.to_string())?; encrypt_bytes_with_secret(&plaintext, passphrase) } fn decrypt_sensitive_payload(blob: &EncryptedSecretsBlob, passphrase: &str) -> Result { let plaintext = decrypt_bytes_with_secret(blob, passphrase) .map_err(|_| "Failed to decrypt synced secrets. Check the sync password.".to_string())?; serde_json::from_slice(&plaintext).map_err(|e| e.to_string()) } fn encrypt_text_with_secret(value: &str, secret: &str) -> Result { encrypt_bytes_with_secret(value.as_bytes(), secret) } fn decrypt_text_with_secret(blob: &EncryptedSecretsBlob, secret: &str) -> Result { let plaintext = decrypt_bytes_with_secret(blob, secret)?; String::from_utf8(plaintext).map_err(|e| e.to_string()) } fn encrypt_bytes_with_secret(plaintext: &[u8], secret: &str) -> Result { let mut salt = [0u8; 16]; let mut nonce = [0u8; 12]; OsRng.fill_bytes(&mut salt); OsRng.fill_bytes(&mut nonce); let key = derive_secret_key(secret, &salt)?; let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; let ciphertext = cipher.encrypt(Nonce::from_slice(&nonce), plaintext).map_err(|e| e.to_string())?; Ok(EncryptedSecretsBlob { version: 1, kdf: "argon2id".to_string(), cipher: "aes-256-gcm".to_string(), salt: BASE64.encode(salt), nonce: BASE64.encode(nonce), ciphertext: BASE64.encode(ciphertext), }) } fn decrypt_bytes_with_secret(blob: &EncryptedSecretsBlob, secret: &str) -> Result, String> { if blob.version != 1 || blob.kdf != "argon2id" || blob.cipher != "aes-256-gcm" { return Err("Unsupported encrypted secrets format".to_string()); } let salt = BASE64.decode(&blob.salt).map_err(|e| e.to_string())?; let nonce = BASE64.decode(&blob.nonce).map_err(|e| e.to_string())?; let ciphertext = BASE64.decode(&blob.ciphertext).map_err(|e| e.to_string())?; if nonce.len() != 12 { return Err("Invalid encrypted secrets nonce".to_string()); } let key = derive_secret_key(secret, &salt)?; let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; cipher .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref()) .map_err(|_| "Failed to decrypt saved secret.".to_string()) } fn derive_secret_key(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> { let params = Params::new(19 * 1024, 2, 1, Some(32)).map_err(|e| e.to_string())?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let mut key = [0u8; 32]; argon2.hash_password_into(passphrase.as_bytes(), salt, &mut key).map_err(|e| e.to_string())?; Ok(key) } fn normalized_passphrase(passphrase: Option<&str>) -> Option<&str> { passphrase.map(str::trim).filter(|value| !value.is_empty()) } fn normalized_snippet_id(snippet_id: Option<&str>) -> Option<&str> { snippet_id.map(str::trim).filter(|value| !value.is_empty()) } fn snippet_token_account(provider: SnippetProvider) -> String { match provider { SnippetProvider::GitHub => "snippet-token:github".to_string(), SnippetProvider::Gitee => "snippet-token:gitee".to_string(), } } fn ensure_snippet_success(status: StatusCode, operation: &str) -> Result<(), String> { if status.is_success() { Ok(()) } else { Err(format!("Snippet {operation} failed with HTTP {status}")) } } fn ensure_snippet_response_success(status: StatusCode, operation: &str, response_body: &str) -> Result<(), String> { if status.is_success() { return Ok(()); } let message = serde_json::from_str::(response_body) .ok() .and_then(|value| { value .get("message") .or_else(|| value.get("error_description")) .and_then(serde_json::Value::as_str) .map(str::to_string) }) .unwrap_or_else(|| response_body.trim().chars().take(300).collect()); if message.is_empty() { Err(format!("Snippet {operation} failed with HTTP {status}")) } else { Err(format!("Snippet {operation} failed with HTTP {status}: {message}")) } } fn snippet_response_id(value: &serde_json::Value) -> Option { if let Some(id) = value.get("id").and_then(serde_json::Value::as_str) { return Some(id.to_string()); } // Some Gitee endpoints historically document an array response despite returning one created snippet. value.as_array()?.first()?.get("id")?.as_str().map(str::to_string) } fn snippet_file_content( value: &serde_json::Value, file_name: &str, ) -> Result<(Option, Option), String> { let files = value.get("files").ok_or_else(|| "Snippet response did not include files".to_string())?; let files = if let Some(value) = files.as_str() { serde_json::from_str::(value).map_err(|e| e.to_string())? } else { files.clone() }; let file = files .get(file_name) .or_else(|| files.as_object().and_then(|files| files.values().next())) .ok_or_else(|| format!("Snippet does not contain {file_name}"))?; let truncated = file.get("truncated").and_then(serde_json::Value::as_bool).unwrap_or(false); let content = if truncated { None } else { file.get("content").and_then(serde_json::Value::as_str).map(str::to_string) }; let raw_url = file.get("raw_url").and_then(serde_json::Value::as_str).map(str::to_string); Ok((content, raw_url)) } fn normalized_remote_path(value: Option<&str>) -> String { let value = value.unwrap_or(DEFAULT_REMOTE_PATH).trim().replace('\\', "/"); let mut parts: Vec<&str> = Vec::new(); for part in value.split('/') { let part = part.trim(); if part.is_empty() || part == "." { continue; } if part == ".." { // Keep the WebDAV target inside the configured endpoint when users paste OS paths. parts.pop(); continue; } parts.push(part); } if parts.is_empty() { DEFAULT_REMOTE_PATH.to_string() } else { parts.join("/") } } fn parent_collection_paths(remote_path: &str) -> Vec { let parts = remote_path.trim_matches('/').split('/').filter(|part| !part.is_empty()).collect::>(); if parts.len() <= 1 { return Vec::new(); } let mut paths = Vec::with_capacity(parts.len() - 1); for index in 1..parts.len() { paths.push(parts[..index].join("/")); } paths } #[cfg(test)] mod tests { use super::{ apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets, decrypt_sensitive_payload, encrypt_sensitive_payload, forget_webdav_sync_secrets_passphrase, normalized_remote_path, parent_collection_paths, resolve_webdav_sync_secrets_passphrase, save_webdav_sync_secrets_preference, scrub_connection_secrets, snippet_file_content, snippet_response_id, webdav_sync_secrets_status, ApplySnapshotOptions, ConnectionSecretSnapshot, SensitiveSyncPayload, }; use crate::connection_secrets::NACOS_AUTH_PASSWORD_KEY; use crate::models::connection::{ default_redis_key_separator, ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig, }; use crate::storage::Storage; fn temp_db_path(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("dbx-cloud-sync-{name}-{}.db", uuid::Uuid::new_v4())) } fn postgres_connection(id: &str, password: &str) -> ConnectionConfig { ConnectionConfig { id: id.to_string(), name: "Postgres".to_string(), db_type: DatabaseType::Postgres, driver_profile: None, driver_label: None, url_params: None, agent_java_options: Vec::new(), host: "127.0.0.1".to_string(), port: 5432, username: "app".to_string(), password: password.to_string(), database: Some("app_db".to_string()), visible_databases: None, visible_schemas: None, attached_databases: Vec::new(), color: None, transport_layers: Vec::new(), connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), client_cert_path: String::new(), client_key_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, redis_connection_mode: None, redis_sentinel_master: String::new(), redis_sentinel_nodes: String::new(), redis_sentinel_username: String::new(), redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), redis_scan_page_size: None, etcd_endpoints: String::new(), gbase_server: String::new(), informix_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, read_only: false, is_production: false, production_databases: vec![], } } fn nacos_connection(id: &str, password: &str) -> ConnectionConfig { ConnectionConfig { id: id.to_string(), name: "Nacos".to_string(), db_type: DatabaseType::Nacos, driver_profile: None, driver_label: None, url_params: None, agent_java_options: Vec::new(), host: "127.0.0.1".to_string(), port: 8848, username: "nacos".to_string(), password: String::new(), database: None, visible_databases: None, visible_schemas: None, attached_databases: Vec::new(), color: None, transport_layers: Vec::new(), connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), client_cert_path: String::new(), client_key_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, redis_connection_mode: None, redis_sentinel_master: String::new(), redis_sentinel_nodes: String::new(), redis_sentinel_username: String::new(), redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), redis_scan_page_size: None, etcd_endpoints: String::new(), gbase_server: String::new(), informix_server: String::new(), external_config: Some(serde_json::json!({ "namespace": "public", "group": "DEFAULT_GROUP", "auth": { "kind": "usernamePassword", "username": "nacos", "password": password } })), jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, read_only: false, is_production: false, production_databases: vec![], } } fn nacos_auth_password(config: &ConnectionConfig) -> Option<&str> { config.external_config.as_ref()?.get("auth")?.get("password")?.as_str() } #[test] fn normalizes_empty_remote_path_to_default() { assert_eq!(normalized_remote_path(None), "DBX/sync/snapshot.json"); assert_eq!(normalized_remote_path(Some("")), "DBX/sync/snapshot.json"); assert_eq!(normalized_remote_path(Some("///\\\\//")), "DBX/sync/snapshot.json"); } #[test] fn normalizes_remote_path_separators() { assert_eq!(normalized_remote_path(Some("/custom/snapshot.json")), "custom/snapshot.json"); assert_eq!(normalized_remote_path(Some(r"\DBX\sync\snapshot.json")), "DBX/sync/snapshot.json"); assert_eq!(normalized_remote_path(Some("///DBX//sync/./snapshot.json")), "DBX/sync/snapshot.json"); assert_eq!(normalized_remote_path(Some("DBX/sync/../snapshot.json")), "DBX/snapshot.json"); } #[test] fn returns_parent_collection_paths_from_leaf() { assert_eq!(parent_collection_paths("dbx/sync/snapshot.json"), vec!["dbx".to_string(), "dbx/sync".to_string()]); assert_eq!( parent_collection_paths(&normalized_remote_path(Some(r"\DBX\sync\snapshot.json"))), vec!["DBX".to_string(), "DBX/sync".to_string()] ); } #[test] fn scrubs_connection_secret_fields() { let mut config = ConnectionConfig { id: "id".to_string(), name: "name".to_string(), db_type: DatabaseType::Postgres, driver_profile: None, driver_label: None, url_params: None, agent_java_options: Vec::new(), host: "localhost".to_string(), port: 5432, username: "user".to_string(), password: "secret".to_string(), database: None, visible_databases: None, visible_schemas: None, attached_databases: Vec::new(), color: None, transport_layers: vec![ TransportLayerConfig::Ssh(crate::models::connection::SshTunnelConfig { profile_id: String::new(), id: "hop-1".to_string(), name: String::new(), enabled: true, host: "bastion".to_string(), port: 22, user: "user".to_string(), password: "hop-password".to_string(), key_path: String::new(), key_passphrase: "hop-passphrase".to_string(), connect_timeout_secs: 5, expose_lan: false, use_ssh_agent: false, ssh_agent_sock_path: String::new(), auth_method: "password".to_string(), }), TransportLayerConfig::HttpTunnel(crate::models::connection::HttpTunnelConfig { profile_id: String::new(), id: "http".to_string(), name: String::new(), enabled: true, url: "https://dbx.example.com/dbx_tunnel.php".to_string(), token: "tunnel-token".to_string(), connect_timeout_secs: 10, }), ], connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), client_cert_path: String::new(), client_key_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: Some("postgres://secret".to_string()), redis_connection_mode: None, redis_sentinel_master: String::new(), redis_sentinel_nodes: String::new(), redis_sentinel_username: String::new(), redis_sentinel_password: "sentinel".to_string(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), redis_scan_page_size: None, etcd_endpoints: String::new(), gbase_server: String::new(), informix_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, read_only: false, is_production: false, production_databases: vec![], }; scrub_connection_secrets(&mut config); assert!(config.password.is_empty()); match &config.transport_layers[0] { TransportLayerConfig::Ssh(ssh) => { assert!(ssh.password.is_empty()); assert!(ssh.key_passphrase.is_empty()); } _ => panic!("expected ssh layer"), } match &config.transport_layers[1] { TransportLayerConfig::HttpTunnel(http) => assert!(http.token.is_empty()), _ => panic!("expected http tunnel layer"), } assert!(config.redis_sentinel_password.is_empty()); assert!(config.connection_string.is_none()); } #[test] fn encrypted_sensitive_payload_round_trips() { let payload = SensitiveSyncPayload { tunnel_profiles: None, connection_secrets: vec![ ConnectionSecretSnapshot { connection_id: "c1".to_string(), key: "password".to_string(), secret: "secret".to_string(), }, ConnectionSecretSnapshot { connection_id: "c1".to_string(), key: "transport_layers.hop-1.ssh_password".to_string(), secret: "hop-secret".to_string(), }, ], ai_config: None, }; let encrypted = encrypt_sensitive_payload(&payload, "sync-pass").unwrap(); assert_ne!(encrypted.ciphertext, "secret"); let decrypted = decrypt_sensitive_payload(&encrypted, "sync-pass").unwrap(); assert_eq!(decrypted.connection_secrets[0].secret, "secret"); assert_eq!(decrypted.connection_secrets[1].secret, "hop-secret"); } #[test] fn encrypted_sensitive_payload_rejects_wrong_passphrase() { let payload = SensitiveSyncPayload { tunnel_profiles: None, connection_secrets: vec![ConnectionSecretSnapshot { connection_id: "c1".to_string(), key: "password".to_string(), secret: "secret".to_string(), }], ai_config: None, }; let encrypted = encrypt_sensitive_payload(&payload, "sync-pass").unwrap(); assert!(decrypt_sensitive_payload(&encrypted, "wrong-pass").is_err()); } #[test] fn snippet_response_id_supports_github_object_and_gitee_array() { assert_eq!(snippet_response_id(&serde_json::json!({ "id": "github-id" })).as_deref(), Some("github-id")); assert_eq!(snippet_response_id(&serde_json::json!([{ "id": "gitee-id" }])).as_deref(), Some("gitee-id")); } #[test] fn snippet_provider_uses_frontend_wire_values() { assert_eq!(serde_json::to_string(&super::SnippetProvider::GitHub).unwrap(), "\"github\""); assert_eq!( serde_json::from_str::("\"github\"").unwrap(), super::SnippetProvider::GitHub ); assert_eq!( serde_json::from_str::("\"git_hub\"").unwrap(), super::SnippetProvider::GitHub ); assert_eq!(serde_json::to_string(&super::SnippetProvider::Gitee).unwrap(), "\"gitee\""); } #[test] fn snippet_file_content_uses_raw_url_for_truncated_github_files() { let value = serde_json::json!({ "files": { "dbx-sync.json": { "content": "truncated", "truncated": true, "raw_url": "https://example.com/raw" } } }); assert_eq!( snippet_file_content(&value, "dbx-sync.json").unwrap(), (None, Some("https://example.com/raw".to_string())) ); } #[test] fn snippet_file_content_parses_gitee_string_files() { let files = serde_json::json!({ "dbx-sync.json": { "content": "{}" } }).to_string(); let value = serde_json::json!({ "files": files }); assert_eq!(snippet_file_content(&value, "dbx-sync.json").unwrap(), (Some("{}".to_string()), None)); } #[tokio::test] async fn webdav_sync_secrets_preference_round_trips_and_clears_passphrase() { let storage = Storage::open(&temp_db_path("sync-secrets-preference")).await.unwrap(); let status = webdav_sync_secrets_status(&storage).await.unwrap(); assert!(!status.enabled); assert!(!status.has_saved_passphrase); assert_eq!(resolve_webdav_sync_secrets_passphrase(&storage).await.unwrap(), None); save_webdav_sync_secrets_preference(&storage, true, Some("sync-pass")).await.unwrap(); let status = webdav_sync_secrets_status(&storage).await.unwrap(); assert!(status.enabled); assert!(status.has_saved_passphrase); assert_eq!(resolve_webdav_sync_secrets_passphrase(&storage).await.unwrap().as_deref(), Some("sync-pass")); forget_webdav_sync_secrets_passphrase(&storage).await.unwrap(); let status = webdav_sync_secrets_status(&storage).await.unwrap(); assert!(status.enabled); assert!(!status.has_saved_passphrase); assert_eq!(resolve_webdav_sync_secrets_passphrase(&storage).await.unwrap(), None); } #[tokio::test] async fn saved_sync_passphrase_encrypts_snapshot_secrets_without_exposing_connection_passwords() { let storage = Storage::open(&temp_db_path("saved-sync-snapshot")).await.unwrap(); storage.save_connections(&[postgres_connection("pg", "db-secret")]).await.unwrap(); let plain_snapshot = build_sync_snapshot_with_saved_secrets(&storage, "test-version", None, None).await.unwrap(); assert!(plain_snapshot.encrypted_secrets.is_none()); assert_eq!(plain_snapshot.connections[0].password, ""); save_webdav_sync_secrets_preference(&storage, true, Some("sync-pass")).await.unwrap(); let encrypted_snapshot = build_sync_snapshot_with_saved_secrets(&storage, "test-version", None, None).await.unwrap(); assert_eq!(encrypted_snapshot.connections[0].password, ""); let encrypted = encrypted_snapshot.encrypted_secrets.as_ref().expect("encrypted secrets"); let decrypted = decrypt_sensitive_payload(encrypted, "sync-pass").unwrap(); assert!(decrypted.connection_secrets.iter().any(|secret| { secret.connection_id == "pg" && secret.key == "password" && secret.secret == "db-secret" })); } #[tokio::test] async fn saved_sync_passphrase_encrypts_nacos_auth_password_without_exposing_it() { let storage = Storage::open(&temp_db_path("saved-sync-nacos-snapshot")).await.unwrap(); storage.save_connections(&[nacos_connection("nacos", "nacos-secret")]).await.unwrap(); save_webdav_sync_secrets_preference(&storage, true, Some("sync-pass")).await.unwrap(); let encrypted_snapshot = build_sync_snapshot_with_saved_secrets(&storage, "test-version", None, None).await.unwrap(); assert_eq!(nacos_auth_password(&encrypted_snapshot.connections[0]), Some("")); let public_json = serde_json::to_string(&encrypted_snapshot.connections).unwrap(); assert!(!public_json.contains("nacos-secret")); let encrypted = encrypted_snapshot.encrypted_secrets.as_ref().expect("encrypted secrets"); let decrypted = decrypt_sensitive_payload(encrypted, "sync-pass").unwrap(); assert!(decrypted.connection_secrets.iter().any(|secret| { secret.connection_id == "nacos" && secret.key == NACOS_AUTH_PASSWORD_KEY && secret.secret == "nacos-secret" })); } #[tokio::test] async fn sync_snapshot_round_trips_tunnel_profiles() { let storage = Storage::open(&temp_db_path("tunnel-profiles-src")).await.unwrap(); let profile = TransportLayerConfig::Ssh(SshTunnelConfig { id: "profile-1".to_string(), name: "Bastion".to_string(), enabled: true, host: "bastion.example.com".to_string(), port: 22, user: "deploy".to_string(), password: "tunnel-secret".to_string(), key_path: String::new(), key_passphrase: String::new(), connect_timeout_secs: 5, expose_lan: false, use_ssh_agent: false, ssh_agent_sock_path: String::new(), auth_method: "password".to_string(), profile_id: String::new(), }); storage.save_tunnel_profiles(std::slice::from_ref(&profile)).await.unwrap(); let snapshot = build_sync_snapshot(&storage, "test-version", None, Some("sync-pass")).await.unwrap(); // The plain snapshot carries the profiles with secrets scrubbed. let public_profiles = snapshot.tunnel_profiles.as_ref().expect("tunnel profiles in snapshot"); let public_json = serde_json::to_string(public_profiles).unwrap(); assert!(!public_json.contains("tunnel-secret")); // Applying with the passphrase restores the full profile on the target. let target = Storage::open(&temp_db_path("tunnel-profiles-dst")).await.unwrap(); apply_sync_snapshot(&target, &snapshot, ApplySnapshotOptions { secrets_passphrase: Some("sync-pass") }) .await .unwrap(); assert_eq!(target.load_tunnel_profiles().await.unwrap(), vec![profile]); } }