From 664697b15128743d3d2f53167f02459b81049fe4 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 15:43:41 -0800 Subject: [PATCH] feat(aws): disclose imported AWS connections and keys in the import preview Co-Authored-By: Claude Opus 5.5 --- .../src/commands/settings_export_commands.rs | 3 +- app/src-tauri/src/models/settings_export.rs | 130 ++++++++++++++++++ app/src-tauri/src/storage/secure.rs | 8 ++ .../settings/ImportSettingsModal.test.tsx | 37 +++++ app/src/lib/settingsImportPreview.test.ts | 2 + app/src/lib/settingsImportPreview.ts | 16 +++ app/src/lib/types.ts | 15 ++ 7 files changed, 210 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/commands/settings_export_commands.rs b/app/src-tauri/src/commands/settings_export_commands.rs index 0dac02f..cb42c4b 100644 --- a/app/src-tauri/src/commands/settings_export_commands.rs +++ b/app/src-tauri/src/commands/settings_export_commands.rs @@ -363,7 +363,8 @@ pub async fn preview_settings_import( let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?; let payload = read_and_decrypt_bytes(&encrypted, &password)?; - let preview = SettingsImportPreview::from_payload(&payload); + let local_hosts = state.settings_store.get().hosts; + let preview = SettingsImportPreview::from_payload_against(&payload, &local_hosts, secure::aws_keys_entry_exists); *state.pending_settings_import.lock().await = Some(PendingSettingsImport { path, diff --git a/app/src-tauri/src/models/settings_export.rs b/app/src-tauri/src/models/settings_export.rs index 0631e95..801f178 100644 --- a/app/src-tauri/src/models/settings_export.rs +++ b/app/src-tauri/src/models/settings_export.rs @@ -177,6 +177,30 @@ pub struct SettingsImportPreview { /// Non-blank marketplace account tokens the import restores. #[serde(default)] pub marketplace_account_token_count: usize, + /// The AWS connections the import brings. Applied wholesale, so a crafted + /// export could otherwise add or replace one unannounced. Names and + /// profile names are cleaned; no key values, only the count below. + #[serde(default)] + pub aws_connections: Vec, + /// Non-blank AWS access-key entries the import restores to the keychain. + #[serde(default)] + pub aws_connection_key_count: usize, +} + +/// One imported AWS connection as the confirmation dialog shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AwsConnectionPreview { + pub name: String, + /// `"profile"` or `"keys"`. + pub auth: String, + /// The profile name, for a `profile` connection. + pub profile_name: Option, + pub default_region: String, + /// A connection with this id exists here with different content. + pub overwrites_existing: bool, + /// The import carries keys for this id and a keychain entry for it + /// already exists here (its value is never read into the preview). + pub overwrites_keys: bool, } /// A cap on how much of a decrypted, not-yet-trusted string gets echoed back @@ -202,6 +226,45 @@ fn sanitize_for_preview(value: &str) -> String { impl SettingsImportPreview { pub fn from_payload(payload: &SettingsExportPayload) -> Self { + Self::from_payload_against(payload, &[], |_| false) + } + + /// As [`Self::from_payload`], also flagging what the import would + /// overwrite: `local_hosts` are the connections held now and + /// `local_has_keys` says whether a keychain entry exists for an id. + pub fn from_payload_against( + payload: &SettingsExportPayload, + local_hosts: &[crate::models::host_connection::HostConnection], + local_has_keys: impl Fn(&str) -> bool, + ) -> Self { + use crate::models::host_connection::{AwsAuth, HostKind}; + let carried_keys = |id: &str| { + payload + .secrets + .aws_connection_keys + .get(id) + .is_some_and(|v| !v.trim().is_empty()) + }; + let aws_connections = payload + .settings + .hosts + .iter() + .map(|h| { + let HostKind::Aws { auth, default_region } = &h.kind; + let (auth_kind, profile_name) = match auth { + AwsAuth::Profile { name } => ("profile", Some(sanitize_for_preview(name))), + AwsAuth::Keys { .. } => ("keys", None), + }; + AwsConnectionPreview { + name: sanitize_for_preview(&h.name), + auth: auth_kind.to_string(), + profile_name, + default_region: sanitize_for_preview(default_region), + overwrites_existing: local_hosts.iter().any(|l| l.id == h.id && l != h), + overwrites_keys: carried_keys(&h.id) && local_has_keys(&h.id), + } + }) + .collect(); let non_blank = |s: &Option| s.as_deref().is_some_and(|v| !v.trim().is_empty()); let sanitized_non_blank = |s: &Option| { s.as_deref() @@ -246,6 +309,13 @@ impl SettingsImportPreview { .values() .filter(|v| !v.trim().is_empty()) .count(), + aws_connections, + aws_connection_key_count: payload + .secrets + .aws_connection_keys + .values() + .filter(|v| !v.trim().is_empty()) + .count(), } } } @@ -483,4 +553,64 @@ mod tests { assert_eq!(preview.global_plugin_install_count, 2); assert_eq!(preview.global_hook_install_count, 1); } + + fn aws_host(id: &str, name: &str, keys: bool) -> crate::models::host_connection::HostConnection { + use crate::models::host_connection::*; + HostConnection { + id: id.into(), + name: name.into(), + kind: HostKind::Aws { + auth: if keys { + AwsAuth::Keys { keyring_ref: id.into() } + } else { + AwsAuth::Profile { name: "prod-profile".into() } + }, + default_region: "us-west-2".into(), + }, + } + } + + #[test] + fn import_preview_discloses_aws_connections_and_key_count() { + let mut payload = payload_with(ExportedSecrets { + aws_connection_keys: BTreeMap::from([ + ("k1".to_string(), "{\"secret_access_key\":\"test-secret-not-real\"}".to_string()), + ("k2".to_string(), " ".to_string()), + ]), + ..Default::default() + }); + payload.settings.hosts = vec![aws_host("k1", "Ke\nys", true), aws_host("p1", "Prof", false)]; + let preview = SettingsImportPreview::from_payload(&payload); + assert_eq!(preview.aws_connection_key_count, 1); + assert_eq!(preview.aws_connections.len(), 2); + assert_eq!(preview.aws_connections[0].name, "Keys"); + assert_eq!(preview.aws_connections[0].auth, "keys"); + assert_eq!(preview.aws_connections[1].auth, "profile"); + assert_eq!(preview.aws_connections[1].profile_name.as_deref(), Some("prod-profile")); + assert_eq!(preview.aws_connections[1].default_region, "us-west-2"); + assert!(!serde_json::to_string(&preview).unwrap().contains("test-secret-not-real")); + } + + #[test] + fn import_preview_flags_aws_overwrites() { + let mut payload = payload_with(ExportedSecrets { + aws_connection_keys: BTreeMap::from([("k1".to_string(), "{}".to_string())]), + ..Default::default() + }); + payload.settings.hosts = vec![ + aws_host("k1", "Keys", true), + aws_host("same", "Same", false), + aws_host("changed", "New name", false), + aws_host("fresh", "Fresh", false), + ]; + let local = vec![aws_host("same", "Same", false), aws_host("changed", "Old name", false)]; + let preview = + SettingsImportPreview::from_payload_against(&payload, &local, |id| id == "k1"); + let flags: Vec<_> = preview + .aws_connections + .iter() + .map(|c| (c.overwrites_existing, c.overwrites_keys)) + .collect(); + assert_eq!(flags, vec![(false, true), (false, false), (true, false), (false, false)]); + } } diff --git a/app/src-tauri/src/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index 7c63c4c..c001221 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -474,6 +474,14 @@ pub fn get_aws_keys(connection_id: &str) -> Result, String> { .map_err(|_| "The stored AWS access keys are unreadable".to_string()) } +/// Whether any entry exists for the connection, readable or not. Never +/// returns or logs a value. +pub fn aws_keys_entry_exists(connection_id: &str) -> bool { + aws_connection_service(connection_id) + .and_then(|svc| read_entry(&svc, "the AWS access keys")) + .is_ok_and(|v| v.is_some()) +} + /// Missing entry is success. Unused until connection removal lands. #[allow(dead_code)] pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> { diff --git a/app/src/components/settings/ImportSettingsModal.test.tsx b/app/src/components/settings/ImportSettingsModal.test.tsx index 34e8753..a1d1bdb 100644 --- a/app/src/components/settings/ImportSettingsModal.test.tsx +++ b/app/src/components/settings/ImportSettingsModal.test.tsx @@ -36,6 +36,8 @@ const samplePreview: SettingsImportPreview = { global_hook_install_count: 0, global_plugin_install_count: 0, marketplace_account_token_count: 0, + aws_connections: [], + aws_connection_key_count: 0, }; function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome { @@ -80,6 +82,41 @@ describe("ImportSettingsModal", () => { expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument(); }); + it("shows the AWS connections and key count, and flags overwrites", async () => { + previewSettingsImport.mockResolvedValue({ + ...samplePreview, + aws_connections: [ + { + name: "Prod", + auth: "keys", + profile_name: null, + default_region: "us-west-2", + overwrites_existing: true, + overwrites_keys: true, + }, + { + name: "Dev", + auth: "profile", + profile_name: "dev-profile", + default_region: "eu-west-1", + overwrites_existing: false, + overwrites_keys: false, + }, + ], + aws_connection_key_count: 1, + }); + render(); + + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } }); + fireEvent.click(screen.getByRole("button", { name: /choose file/i })); + + expect(await screen.findByText(/AWS connection "Prod" \(access keys, us-west-2\)/)).toBeInTheDocument(); + expect(screen.getByText(/AWS connection "Dev" \(profile dev-profile, eu-west-1\)/)).toBeInTheDocument(); + expect(screen.getByText(/1 AWS access key set$/)).toBeInTheDocument(); + expect(screen.getByText(/Overwrites the AWS access keys stored for "Prod"/)).toBeInTheDocument(); + expect(screen.getByText(/Replaces your existing AWS connection.*"Prod"/)).toBeInTheDocument(); + }); + it("warns about a custom Docker image every time, not just on change", async () => { previewSettingsImport.mockResolvedValue({ ...samplePreview, diff --git a/app/src/lib/settingsImportPreview.test.ts b/app/src/lib/settingsImportPreview.test.ts index ddec04a..6973618 100644 --- a/app/src/lib/settingsImportPreview.test.ts +++ b/app/src/lib/settingsImportPreview.test.ts @@ -24,6 +24,8 @@ function preview(overrides: Partial = {}): SettingsImport global_hook_install_count: 0, global_plugin_install_count: 0, marketplace_account_token_count: 0, + aws_connections: [], + aws_connection_key_count: 0, ...overrides, }; } diff --git a/app/src/lib/settingsImportPreview.ts b/app/src/lib/settingsImportPreview.ts index 0c1ea76..aa31429 100644 --- a/app/src/lib/settingsImportPreview.ts +++ b/app/src/lib/settingsImportPreview.ts @@ -35,6 +35,14 @@ export function describeImport(preview: SettingsImportPreview): string[] { const n = preview.marketplace_account_token_count; items.push(`${n} marketplace account token${n === 1 ? "" : "s"}`); } + for (const c of preview.aws_connections) { + const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim(); + items.push(`AWS connection "${c.name}" (${auth}, ${c.default_region})`); + } + if (preview.aws_connection_key_count > 0) { + const n = preview.aws_connection_key_count; + items.push(`${n} AWS access key set${n === 1 ? "" : "s"}`); + } return items; } @@ -82,6 +90,14 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[] `Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.`, ); } + for (const c of preview.aws_connections) { + if (c.overwrites_existing) { + warnings.push(`Replaces your existing AWS connection with different settings: "${c.name}".`); + } + if (c.overwrites_keys) { + warnings.push(`Overwrites the AWS access keys stored for "${c.name}".`); + } + } if (preview.image_source === "custom") { warnings.push( `Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`, diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index c2b77f7..e1ff4a7 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -425,6 +425,21 @@ export interface SettingsImportPreview { global_plugin_install_count: number; /** Marketplace account tokens the import restores to the keychain. */ marketplace_account_token_count: number; + /** AWS connections the import brings (names cleaned; no key values). */ + aws_connections: AwsConnectionPreview[]; + /** AWS access-key entries the import restores to the keychain. */ + aws_connection_key_count: number; +} + +export interface AwsConnectionPreview { + name: string; + auth: "profile" | "keys"; + profile_name: string | null; + default_region: string; + /** A connection with this id exists here with different content. */ + overwrites_existing: boolean; + /** The import carries keys for an id that already has keys stored here. */ + overwrites_keys: boolean; } /** What `apply_settings_import` returns: the settings that were actually