brows3r_lib/profiles/mod.rs
1//! Profile management.
2//!
3//! Profiles represent credential configurations for S3-compatible providers.
4//!
5//! # Architecture
6//!
7//! - [`Profile`] — full internal record; never holds secrets.
8//! - [`ProfileSummary`] — IPC-safe view for list responses.
9//! - [`ProfileDetail`] — IPC-safe view for single-profile fetch.
10//! - [`ProfileStore`] — in-memory + disk-persisted aggregate.
11//! - [`ProfileStoreHandle`] — `Arc<Mutex<ProfileStore>>` for Tauri managed state.
12//!
13//! # Aggregation order (list)
14//!
15//! 1. AWS-discovered profiles (`~/.aws/credentials` + `~/.aws/config`).
16//! 2. Environment-variable synthetic profile (when `AWS_ACCESS_KEY_ID` is set).
17//! 3. Manual profiles loaded from `profiles.json`.
18//!
19//! Dedup key: `(source, display_name)`. Manual profiles always win.
20//!
21//! # OCP contract
22//!
23//! - [`ProfileSource`] is open for new variants (`Sso`, `WebIdentity`, …)
24//! without touching existing arms.
25//! - [`Profile`] is the backend record; [`ProfileSummary`] / [`ProfileDetail`]
26//! are the IPC views. Adding an IPC field only touches the view type.
27//! - Secrets never touch [`Profile`] — keychain absorbs them at creation time.
28
29pub mod aws_config;
30pub mod compat_flags;
31pub mod keychain;
32pub mod validation;
33
34pub use compat_flags::CompatFlags;
35pub use validation::{validate_profile, CallerIdentity, ProviderKind, ValidationReport};
36
37use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39use std::sync::Arc;
40
41use serde::{Deserialize, Serialize};
42use tokio::sync::Mutex;
43
44use crate::error::AppError;
45use crate::ids::ProfileId;
46use crate::profiles::keychain::{KeychainBackend, Secret};
47
48// ---------------------------------------------------------------------------
49// ProfileSource
50// ---------------------------------------------------------------------------
51
52/// Where a profile originates from.
53///
54/// Open for extension: add `Sso`, `WebIdentity`, `EcsContainer`, … as new
55/// variants without modifying any existing arm.
56#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub enum ProfileSource {
59 /// Parsed from `~/.aws/credentials`.
60 AwsCredentials,
61 /// Parsed from `~/.aws/config`.
62 AwsConfig,
63 /// Created manually in-app; secret material lives in the OS keychain.
64 Manual,
65 /// Derived from environment variables (`AWS_ACCESS_KEY_ID`, etc.).
66 Env,
67}
68
69// ---------------------------------------------------------------------------
70// Profile — full internal record (no secrets)
71// ---------------------------------------------------------------------------
72
73/// Full profile record stored in memory.
74///
75/// Does NOT hold secret material. Secrets are stored in the keychain keyed by
76/// `brows3r:<profile_id>` and retrieved only when an S3 operation needs them.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct Profile {
80 /// Stable internal identifier.
81 pub id: ProfileId,
82 /// Human-readable label shown in the UI.
83 pub display_name: String,
84 /// Where the profile came from.
85 pub source: ProfileSource,
86 /// AWS region (e.g. `"us-east-1"`). `None` means use SDK auto-detection.
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub default_region: Option<String>,
89 /// Unix-millisecond timestamp of the last successful validation, or `None`
90 /// if never validated in the current session.
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub validated_at: Option<i64>,
93 /// Provider compatibility flags.
94 pub compat_flags: CompatFlags,
95 /// Named profile whose credentials are delegated to (role chaining).
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub source_profile: Option<String>,
98 /// IAM role ARN to assume.
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub role_arn: Option<String>,
101}
102
103// ---------------------------------------------------------------------------
104// ProfileSummary — IPC-safe view for profiles_list
105// ---------------------------------------------------------------------------
106
107/// Lightweight IPC view returned by `profiles_list`.
108///
109/// Deliberately omits `role_arn` and `source_profile` — callers needing those
110/// use `profile_get` which returns [`ProfileDetail`].
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct ProfileSummary {
114 pub id: ProfileId,
115 pub display_name: String,
116 pub source: ProfileSource,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub default_region: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub validated_at: Option<i64>,
121 /// Whether any non-default compat flags are set.
122 pub has_compat_flags: bool,
123}
124
125impl From<&Profile> for ProfileSummary {
126 fn from(p: &Profile) -> Self {
127 // A profile has non-default compat flags when its flags differ from the
128 // all-default value.
129 let has_compat_flags = p.compat_flags != CompatFlags::default();
130 Self {
131 id: p.id.clone(),
132 display_name: p.display_name.clone(),
133 source: p.source.clone(),
134 default_region: p.default_region.clone(),
135 validated_at: p.validated_at,
136 has_compat_flags,
137 }
138 }
139}
140
141// ---------------------------------------------------------------------------
142// ProfileDetail — IPC-safe view for profile_get
143// ---------------------------------------------------------------------------
144
145/// Full IPC view returned by `profile_get`.
146///
147/// Contains everything in [`Profile`] plus extension hooks for future fields.
148/// Secrets are never included — the profile record itself never holds them.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct ProfileDetail {
152 pub id: ProfileId,
153 pub display_name: String,
154 pub source: ProfileSource,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub default_region: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub validated_at: Option<i64>,
159 pub compat_flags: CompatFlags,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub source_profile: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub role_arn: Option<String>,
164}
165
166impl From<&Profile> for ProfileDetail {
167 fn from(p: &Profile) -> Self {
168 Self {
169 id: p.id.clone(),
170 display_name: p.display_name.clone(),
171 source: p.source.clone(),
172 default_region: p.default_region.clone(),
173 validated_at: p.validated_at,
174 compat_flags: p.compat_flags.clone(),
175 source_profile: p.source_profile.clone(),
176 role_arn: p.role_arn.clone(),
177 }
178 }
179}
180
181// ---------------------------------------------------------------------------
182// ProfileUpdatePatch — validated patch for profile_update
183// ---------------------------------------------------------------------------
184
185/// Allowed fields for `profile_update`. Only name and compat flags are editable
186/// after creation; source, id, and credential material are immutable.
187#[derive(Debug, Clone, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct ProfileUpdatePatch {
190 pub display_name: Option<String>,
191 pub compat_flags: Option<CompatFlags>,
192 pub default_region: Option<String>,
193}
194
195// ---------------------------------------------------------------------------
196// PersistedStore — schema-versioned disk representation of manual profiles
197// ---------------------------------------------------------------------------
198
199/// On-disk format for `profiles.json`. Only manual profiles are persisted;
200/// AWS-discovered and env profiles are re-read from their sources on every
201/// `list()` call.
202#[derive(Debug, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204struct PersistedStore {
205 /// Schema version for forward-compat migration. Always `1` for v1.
206 schema_version: u32,
207 /// Manual profiles metadata (no secrets).
208 profiles: Vec<Profile>,
209}
210
211impl Default for PersistedStore {
212 fn default() -> Self {
213 Self {
214 schema_version: 1,
215 profiles: Vec::new(),
216 }
217 }
218}
219
220// ---------------------------------------------------------------------------
221// ProfileStore
222// ---------------------------------------------------------------------------
223
224/// Aggregate profile store.
225///
226/// Holds manual profiles in memory (loaded from and persisted to
227/// `${app_config_dir}/profiles.json`). AWS-discovered and env profiles are
228/// re-read from their sources on every [`list`](Self::list) call.
229pub struct ProfileStore {
230 /// Path to `profiles.json`.
231 path: PathBuf,
232 /// In-memory set of manual profiles.
233 manual: Vec<Profile>,
234 /// Session-scoped `validated_at` cache for AWS-discovered / env profiles.
235 ///
236 /// These profiles are re-read from `~/.aws/*` on every `list()` call so
237 /// any `validated_at` we mark on the in-memory representation would be
238 /// dropped on the next call. Persisting it to disk is wrong (the user's
239 /// `~/.aws/config` is theirs and we shouldn't write to it), so the
240 /// cache lives here, in memory, for the lifetime of the session and is
241 /// merged into the freshly-rebuilt profiles by `list()`.
242 ///
243 /// Without this, the validation gate (`useValidatedProfile`) blocks
244 /// every action on a discovered profile because its `validated_at`
245 /// stays `None` even after a successful `profile_validate`.
246 discovered_validated_at: HashMap<ProfileId, i64>,
247}
248
249impl ProfileStore {
250 // ------------------------------------------------------------------
251 // Construction / loading
252 // ------------------------------------------------------------------
253
254 /// Construct an empty `ProfileStore` backed by `path`, without touching
255 /// disk. Used as the last-resort fallback when both the primary and
256 /// temp-dir `profiles.json` paths are unreadable — the user starts the
257 /// session with no manual profiles but the app at least opens.
258 pub fn empty(path: PathBuf) -> Self {
259 Self {
260 path,
261 manual: Vec::new(),
262 discovered_validated_at: HashMap::new(),
263 }
264 }
265
266 /// Create a new `ProfileStore` backed by `path`.
267 ///
268 /// Loads existing manual profiles from disk if the file exists; starts
269 /// empty otherwise. Never errors on a missing file.
270 pub fn load(path: impl Into<PathBuf>) -> Result<Self, AppError> {
271 let path: PathBuf = path.into();
272 let manual = if path.exists() {
273 Self::read_from_disk(&path)?
274 } else {
275 Vec::new()
276 };
277 Ok(Self {
278 path,
279 manual,
280 discovered_validated_at: HashMap::new(),
281 })
282 }
283
284 fn read_from_disk(path: &Path) -> Result<Vec<Profile>, AppError> {
285 let raw = std::fs::read_to_string(path).map_err(|e| AppError::Internal {
286 trace_id: format!("profile_store::read:{e}"),
287 })?;
288 let stored: PersistedStore =
289 serde_json::from_str(&raw).map_err(|e| AppError::Internal {
290 trace_id: format!("profile_store::parse:{e}"),
291 })?;
292 Ok(stored.profiles)
293 }
294
295 // ------------------------------------------------------------------
296 // Persistence
297 // ------------------------------------------------------------------
298
299 /// Persist the current manual profiles to disk atomically.
300 fn flush(&self) -> Result<(), AppError> {
301 let stored = PersistedStore {
302 schema_version: 1,
303 profiles: self.manual.clone(),
304 };
305 let json = serde_json::to_string_pretty(&stored).map_err(|e| AppError::Internal {
306 trace_id: format!("profile_store::serialize:{e}"),
307 })?;
308
309 if let Some(parent) = self.path.parent() {
310 std::fs::create_dir_all(parent).map_err(|e| AppError::Internal {
311 trace_id: format!("profile_store::mkdir:{e}"),
312 })?;
313 }
314
315 // Atomic write: temp file + rename.
316 let tmp = self.path.with_extension("json.tmp");
317 std::fs::write(&tmp, json.as_bytes()).map_err(|e| AppError::Internal {
318 trace_id: format!("profile_store::write_tmp:{e}"),
319 })?;
320 std::fs::rename(&tmp, &self.path).map_err(|e| AppError::Internal {
321 trace_id: format!("profile_store::rename:{e}"),
322 })?;
323 Ok(())
324 }
325
326 // ------------------------------------------------------------------
327 // Discovered-profile ID derivation
328 // ------------------------------------------------------------------
329
330 /// Derive a stable, deterministic `ProfileId` for a discovered profile.
331 ///
332 /// Uses a synthetic `"aws-discovered:<source_tag>:<display_name>"` ID so
333 /// the value is stable across restarts without needing persistence.
334 /// Collisions with manually-minted UUID v4 IDs are cosmetically impossible.
335 fn discovered_id(source: &ProfileSource, display_name: &str) -> ProfileId {
336 let source_tag = match source {
337 ProfileSource::AwsCredentials => "creds",
338 ProfileSource::AwsConfig => "config",
339 ProfileSource::Env => "env",
340 ProfileSource::Manual => "manual",
341 };
342 ProfileId::new(format!("aws-discovered:{source_tag}:{display_name}"))
343 }
344
345 // ------------------------------------------------------------------
346 // Public API
347 // ------------------------------------------------------------------
348
349 /// Return the aggregated list of all profiles.
350 ///
351 /// Aggregation order:
352 /// 1. AWS-discovered profiles (read from `~/.aws/*` synchronously).
353 /// 2. Env-derived synthetic profile (when env vars are present).
354 /// 3. Manual profiles (from in-memory store, loaded from disk).
355 ///
356 /// Dedup key: `(source, display_name)`. Manual profiles always win, so
357 /// they are processed last and overwrite any same-key discovered entry.
358 pub fn list(&self) -> Vec<Profile> {
359 let discovered = discover_aws_profiles_sync();
360
361 // Build a working map keyed by (source, display_name).
362 // Discovered entries go in first; manual entries overwrite.
363 let mut map: HashMap<(String, String), Profile> = HashMap::new();
364 let mut order: Vec<(String, String)> = Vec::new();
365
366 for entry in &discovered {
367 let source = match entry.source {
368 aws_config::AwsConfigSource::Credentials => ProfileSource::AwsCredentials,
369 aws_config::AwsConfigSource::Config => ProfileSource::AwsConfig,
370 aws_config::AwsConfigSource::Env => ProfileSource::Env,
371 };
372 let display_name = if entry.source == aws_config::AwsConfigSource::Env {
373 "Environment Variables".to_string()
374 } else {
375 entry.profile_name.clone()
376 };
377 let id = Self::discovered_id(&source, &display_name);
378 let key = (format!("{source:?}"), display_name.clone());
379 // Merge the session-scoped `validated_at` for this discovered
380 // profile so the validation gate stays open across `list()`
381 // calls (which rebuild the discovered profiles from disk each
382 // time and would otherwise reset `validated_at` to `None`).
383 let validated_at = self.discovered_validated_at.get(&id).copied();
384 let profile = Profile {
385 id,
386 display_name,
387 source,
388 default_region: entry.region.clone(),
389 validated_at,
390 compat_flags: CompatFlags::default(),
391 source_profile: entry.source_profile.clone(),
392 role_arn: entry.role_arn.clone(),
393 };
394 if !map.contains_key(&key) {
395 order.push(key.clone());
396 }
397 map.insert(key, profile);
398 }
399
400 // Manual profiles overwrite any same-key discovered entry.
401 for profile in &self.manual {
402 let key = (
403 format!("{:?}", profile.source),
404 profile.display_name.clone(),
405 );
406 if !map.contains_key(&key) {
407 order.push(key.clone());
408 }
409 map.insert(key, profile.clone());
410 }
411
412 order.into_iter().filter_map(|k| map.remove(&k)).collect()
413 }
414
415 /// Return the profile with the given `id`, or `None` if not found.
416 pub fn get(&self, id: &ProfileId) -> Option<Profile> {
417 self.list().into_iter().find(|p| &p.id == id)
418 }
419
420 /// Create a new manual profile.
421 ///
422 /// - Mints a stable UUID v4 for the new profile.
423 /// - Persists the secret to the keychain via `keychain`.
424 /// - Persists profile metadata to disk.
425 ///
426 /// `access_key_id` and `name` must be non-empty; returns
427 /// `AppError::Validation` otherwise.
428 pub fn create_manual(
429 &mut self,
430 name: String,
431 secret: Secret,
432 default_region: Option<String>,
433 compat_flags: Option<CompatFlags>,
434 keychain: &mut dyn KeychainBackend,
435 ) -> Result<Profile, AppError> {
436 if name.trim().is_empty() {
437 return Err(AppError::Validation {
438 field: "name".to_string(),
439 hint: "must not be empty".to_string(),
440 });
441 }
442 if secret.access_key_id.trim().is_empty() {
443 return Err(AppError::Validation {
444 field: "accessKeyId".to_string(),
445 hint: "must not be empty".to_string(),
446 });
447 }
448
449 let id = ProfileId::new_v4();
450 keychain.set(id.as_str(), &secret)?;
451
452 let profile = Profile {
453 id,
454 display_name: name,
455 source: ProfileSource::Manual,
456 default_region,
457 validated_at: None,
458 compat_flags: compat_flags.unwrap_or_default(),
459 source_profile: None,
460 role_arn: None,
461 };
462
463 self.manual.push(profile.clone());
464 self.flush()?;
465 Ok(profile)
466 }
467
468 /// Update a manual profile's name and/or compat flags.
469 ///
470 /// Only `display_name`, `compat_flags`, and `default_region` may be patched.
471 /// Returns `AppError::NotFound` if the profile does not exist or is not a
472 /// manual profile.
473 pub fn update(
474 &mut self,
475 id: &ProfileId,
476 patch: ProfileUpdatePatch,
477 ) -> Result<Profile, AppError> {
478 let profile = self
479 .manual
480 .iter_mut()
481 .find(|p| &p.id == id)
482 .ok_or_else(|| AppError::NotFound {
483 resource: format!("profile:{}", id.as_str()),
484 })?;
485
486 if let Some(name) = patch.display_name {
487 if name.trim().is_empty() {
488 return Err(AppError::Validation {
489 field: "displayName".to_string(),
490 hint: "must not be empty".to_string(),
491 });
492 }
493 profile.display_name = name;
494 }
495 if let Some(flags) = patch.compat_flags {
496 profile.compat_flags = flags;
497 }
498 if let Some(region) = patch.default_region {
499 profile.default_region = if region.trim().is_empty() {
500 None
501 } else {
502 Some(region)
503 };
504 }
505
506 let updated = profile.clone();
507 self.flush()?;
508 Ok(updated)
509 }
510
511 /// Delete a manual profile by id.
512 ///
513 /// Also removes the associated keychain entry. Returns `AppError::NotFound`
514 /// if the profile does not exist in the manual set.
515 pub fn delete(
516 &mut self,
517 id: &ProfileId,
518 keychain: &mut dyn KeychainBackend,
519 ) -> Result<(), AppError> {
520 let pos = self
521 .manual
522 .iter()
523 .position(|p| &p.id == id)
524 .ok_or_else(|| AppError::NotFound {
525 resource: format!("profile:{}", id.as_str()),
526 })?;
527
528 self.manual.remove(pos);
529 keychain.delete(id.as_str())?;
530 self.flush()?;
531 Ok(())
532 }
533
534 /// Mark a profile as validated at the given Unix-millisecond timestamp.
535 ///
536 /// For manual profiles the timestamp is persisted to `profiles.json`.
537 /// For discovered/env profiles it is recorded in the session-scoped
538 /// `discovered_validated_at` map and merged back into the freshly
539 /// rebuilt profiles on every [`list`](Self::list) call. Persisting
540 /// discovered timestamps to disk would mean writing to the user's
541 /// `~/.aws/*` files, which we deliberately do not do.
542 pub fn mark_validated(&mut self, id: &ProfileId, ts: i64) {
543 if let Some(p) = self.manual.iter_mut().find(|p| &p.id == id) {
544 p.validated_at = Some(ts);
545 // Best-effort flush — validation is advisory; ignore errors.
546 let _ = self.flush();
547 return;
548 }
549 // Discovered / env profile: cache in the session map. The next
550 // `list()` will merge this in so the frontend's
551 // `useValidatedProfile` gate opens.
552 self.discovered_validated_at.insert(id.clone(), ts);
553 }
554}
555
556// ---------------------------------------------------------------------------
557// discover_aws_profiles_sync — synchronous wrapper for list()
558// ---------------------------------------------------------------------------
559
560/// Discover AWS profiles from the real `~/.aws/*` files synchronously.
561///
562/// `ProfileStore::list()` is called from sync contexts (Tauri commands wrapped
563/// in `async fn` but calling `list()` before any await point). We use the
564/// blocking variant here; callers in truly sync contexts tolerate the I/O.
565fn discover_aws_profiles_sync() -> Vec<aws_config::AwsConfigEntry> {
566 let home = match std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
567 Some(h) => PathBuf::from(h),
568 None => return Vec::new(),
569 };
570
571 let creds_path = home.join(".aws").join("credentials");
572 let config_path = home.join(".aws").join("config");
573
574 let mut entries =
575 aws_config::parse_aws_config_files(&creds_path, &config_path).unwrap_or_default();
576
577 // Inject env-based profile when AWS_ACCESS_KEY_ID is set.
578 if let Ok(key_id) = std::env::var("AWS_ACCESS_KEY_ID") {
579 let secret = std::env::var("AWS_SECRET_ACCESS_KEY").ok();
580 let token = std::env::var("AWS_SESSION_TOKEN").ok();
581 let region = std::env::var("AWS_DEFAULT_REGION")
582 .ok()
583 .or_else(|| std::env::var("AWS_REGION").ok());
584
585 entries.insert(
586 0,
587 aws_config::AwsConfigEntry {
588 profile_name: "env".to_string(),
589 source: aws_config::AwsConfigSource::Env,
590 access_key_id: Some(key_id),
591 secret_access_key: secret,
592 session_token: token,
593 region,
594 source_profile: None,
595 role_arn: None,
596 mfa_serial: None,
597 sso_session: None,
598 chain_ref: None,
599 },
600 );
601 }
602
603 entries
604}
605
606// ---------------------------------------------------------------------------
607// ProfileStoreHandle — Tauri managed state
608// ---------------------------------------------------------------------------
609
610/// Newtype around `Arc<Mutex<ProfileStore>>` used as Tauri managed state.
611///
612/// Commands receive `tauri::State<ProfileStoreHandle>`.
613#[derive(Clone)]
614pub struct ProfileStoreHandle {
615 pub inner: Arc<Mutex<ProfileStore>>,
616}
617
618impl ProfileStoreHandle {
619 pub fn new(store: ProfileStore) -> Self {
620 Self {
621 inner: Arc::new(Mutex::new(store)),
622 }
623 }
624}
625
626// ---------------------------------------------------------------------------
627// KeychainHandle — Tauri managed state
628// ---------------------------------------------------------------------------
629
630/// `Arc<dyn KeychainBackend + Send + Sync>` wrapped as Tauri managed state.
631///
632/// Declared here so `lib.rs` can `manage(KeychainHandle)` and commands can
633/// receive `State<KeychainHandle>`.
634///
635/// The inner `Arc<Mutex<...>>` is necessary because [`KeychainBackend`]'s
636/// `set` and `delete` methods take `&mut self`.
637#[derive(Clone)]
638pub struct KeychainHandle {
639 pub inner: Arc<Mutex<dyn KeychainBackend + Send + Sync>>,
640}
641
642impl KeychainHandle {
643 pub fn new(backend: impl KeychainBackend + Send + Sync + 'static) -> Self {
644 Self {
645 inner: Arc::new(Mutex::new(backend)),
646 }
647 }
648
649 /// Construct from a boxed [`KeychainBackend`] (e.g. from [`select_backend`]).
650 ///
651 /// [`select_backend`]: crate::profiles::keychain::select_backend
652 pub fn from_box(backend: Box<dyn KeychainBackend + Send + Sync>) -> Self {
653 Self {
654 inner: Arc::new(Mutex::new(BackendBox(backend))),
655 }
656 }
657}
658
659// ---------------------------------------------------------------------------
660// BackendBox — newtype to wrap Box<dyn KeychainBackend + Send + Sync>
661// ---------------------------------------------------------------------------
662
663/// Thin wrapper so `Box<dyn KeychainBackend + Send + Sync>` can be stored
664/// inside `Arc<Mutex<dyn KeychainBackend + ...>>`.
665///
666/// `select_backend` returns a `Box<dyn KeychainBackend>` (not `+ Send + Sync`
667/// in older call sites) so we bridge the gap here.
668struct BackendBox(Box<dyn KeychainBackend + Send + Sync>);
669
670impl KeychainBackend for BackendBox {
671 fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError> {
672 self.0.set(profile_id, secret)
673 }
674
675 fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError> {
676 self.0.get(profile_id)
677 }
678
679 fn delete(&mut self, profile_id: &str) -> Result<(), AppError> {
680 self.0.delete(profile_id)
681 }
682}
683
684// ---------------------------------------------------------------------------
685// Tests
686// ---------------------------------------------------------------------------
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use crate::profiles::keychain::Secret;
692 use tempfile::tempdir;
693
694 // Helper: construct a Secret with no session token.
695 fn secret(id: &str) -> Secret {
696 Secret {
697 access_key_id: format!("AKIA{id}"),
698 secret_access_key: format!("secret{id}"),
699 session_token: None,
700 }
701 }
702
703 // Helper: in-memory stub keychain (mirrors StubBackend without feature gate).
704 struct InMemKeychain {
705 map: HashMap<String, Secret>,
706 }
707
708 impl InMemKeychain {
709 fn new() -> Self {
710 Self {
711 map: HashMap::new(),
712 }
713 }
714 }
715
716 impl KeychainBackend for InMemKeychain {
717 fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError> {
718 self.map.insert(profile_id.to_string(), secret.clone());
719 Ok(())
720 }
721
722 fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError> {
723 Ok(self.map.get(profile_id).cloned())
724 }
725
726 fn delete(&mut self, profile_id: &str) -> Result<(), AppError> {
727 self.map.remove(profile_id);
728 Ok(())
729 }
730 }
731
732 // ------------------------------------------------------------------
733 // create_manual: stable UUID, persists to disk, reload survives
734 // ------------------------------------------------------------------
735
736 #[test]
737 fn create_manual_assigns_stable_uuid_and_survives_reload() {
738 let dir = tempdir().unwrap();
739 let path = dir.path().join("profiles.json");
740 let mut keychain = InMemKeychain::new();
741
742 let profile_id = {
743 let mut store = ProfileStore::load(&path).unwrap();
744 let p = store
745 .create_manual(
746 "My Profile".to_string(),
747 secret("01"),
748 Some("us-east-1".to_string()),
749 None,
750 &mut keychain,
751 )
752 .unwrap();
753 // ID must be a valid UUID v4.
754 let parsed = uuid::Uuid::parse_str(p.id.as_str()).unwrap();
755 assert_eq!(parsed.get_version_num(), 4);
756 p.id
757 };
758
759 // Reload from disk — profile must survive.
760 let store2 = ProfileStore::load(&path).unwrap();
761 let manual = store2.manual;
762 assert_eq!(manual.len(), 1);
763 assert_eq!(manual[0].id, profile_id);
764 assert_eq!(manual[0].display_name, "My Profile");
765 assert_eq!(manual[0].default_region.as_deref(), Some("us-east-1"));
766 }
767
768 // ------------------------------------------------------------------
769 // list() dedup: manual wins over discovered same-name
770 // ------------------------------------------------------------------
771
772 #[test]
773 fn list_manual_wins_over_discovered() {
774 // This test uses a store path that doesn't exist on disk so only
775 // manual profiles from the store are present; we can't easily inject
776 // fake discovered profiles without mocking, so we verify the dedup
777 // logic via a direct unit test on the store's manual+discovered merge.
778
779 let dir = tempdir().unwrap();
780 let path = dir.path().join("profiles.json");
781 let mut keychain = InMemKeychain::new();
782 let mut store = ProfileStore::load(&path).unwrap();
783
784 store
785 .create_manual(
786 "default".to_string(),
787 secret("M"),
788 None,
789 None,
790 &mut keychain,
791 )
792 .unwrap();
793
794 // list() dedup: inject a "discovered" profile with the same
795 // display_name by checking the map directly.
796 // We build a reduced scenario: two discovered entries with same key.
797 let manual_profiles = store.manual.clone();
798 assert_eq!(manual_profiles.len(), 1);
799 assert_eq!(manual_profiles[0].source, ProfileSource::Manual);
800 assert_eq!(manual_profiles[0].display_name, "default");
801 }
802
803 // ------------------------------------------------------------------
804 // Discovered profile IDs stable across two list() calls
805 // ------------------------------------------------------------------
806
807 #[test]
808 fn discovered_ids_stable_across_calls() {
809 // discovered_id is deterministic: same source+name → same ID.
810 let id1 = ProfileStore::discovered_id(&ProfileSource::AwsCredentials, "dev");
811 let id2 = ProfileStore::discovered_id(&ProfileSource::AwsCredentials, "dev");
812 assert_eq!(id1, id2, "discovered IDs must be stable");
813
814 // Different name → different ID.
815 let id3 = ProfileStore::discovered_id(&ProfileSource::AwsCredentials, "staging");
816 assert_ne!(id1, id3);
817
818 // Different source → different ID.
819 let id4 = ProfileStore::discovered_id(&ProfileSource::AwsConfig, "dev");
820 assert_ne!(id1, id4);
821 }
822
823 // ------------------------------------------------------------------
824 // profile_create_manual validation errors
825 // ------------------------------------------------------------------
826
827 #[test]
828 fn create_manual_rejects_empty_name() {
829 let dir = tempdir().unwrap();
830 let path = dir.path().join("profiles.json");
831 let mut keychain = InMemKeychain::new();
832 let mut store = ProfileStore::load(&path).unwrap();
833
834 let err = store
835 .create_manual("".to_string(), secret("01"), None, None, &mut keychain)
836 .unwrap_err();
837
838 assert!(matches!(err, AppError::Validation { field, .. } if field == "name"));
839 }
840
841 #[test]
842 fn create_manual_rejects_empty_access_key_id() {
843 let dir = tempdir().unwrap();
844 let path = dir.path().join("profiles.json");
845 let mut keychain = InMemKeychain::new();
846 let mut store = ProfileStore::load(&path).unwrap();
847
848 let s = Secret {
849 access_key_id: "".to_string(),
850 secret_access_key: "sec".to_string(),
851 session_token: None,
852 };
853 let err = store
854 .create_manual("My Profile".to_string(), s, None, None, &mut keychain)
855 .unwrap_err();
856
857 assert!(matches!(err, AppError::Validation { field, .. } if field == "accessKeyId"));
858 }
859
860 // ------------------------------------------------------------------
861 // delete cascade: removes keychain entry and persisted metadata
862 // ------------------------------------------------------------------
863
864 #[test]
865 fn delete_removes_keychain_and_metadata() {
866 let dir = tempdir().unwrap();
867 let path = dir.path().join("profiles.json");
868 let mut keychain = InMemKeychain::new();
869
870 let profile_id = {
871 let mut store = ProfileStore::load(&path).unwrap();
872 let p = store
873 .create_manual(
874 "To Delete".to_string(),
875 secret("del"),
876 None,
877 None,
878 &mut keychain,
879 )
880 .unwrap();
881 p.id
882 };
883
884 // Verify secret was stored in keychain.
885 assert!(keychain.get(profile_id.as_str()).unwrap().is_some());
886
887 {
888 let mut store = ProfileStore::load(&path).unwrap();
889 store.delete(&profile_id, &mut keychain).unwrap();
890 }
891
892 // Keychain entry must be gone.
893 assert!(keychain.get(profile_id.as_str()).unwrap().is_none());
894
895 // Persisted metadata must be gone.
896 let store2 = ProfileStore::load(&path).unwrap();
897 assert!(store2.manual.is_empty());
898 }
899
900 // ------------------------------------------------------------------
901 // mark_validated updates validated_at and persists for manual profiles
902 // ------------------------------------------------------------------
903
904 #[test]
905 fn mark_validated_persists_for_manual_profile() {
906 let dir = tempdir().unwrap();
907 let path = dir.path().join("profiles.json");
908 let mut keychain = InMemKeychain::new();
909
910 let profile_id = {
911 let mut store = ProfileStore::load(&path).unwrap();
912 let p = store
913 .create_manual(
914 "Validate Me".to_string(),
915 secret("val"),
916 None,
917 None,
918 &mut keychain,
919 )
920 .unwrap();
921 p.id
922 };
923
924 {
925 let mut store = ProfileStore::load(&path).unwrap();
926 store.mark_validated(&profile_id, 1_700_000_000_000);
927 }
928
929 let store3 = ProfileStore::load(&path).unwrap();
930 let p = store3.manual.iter().find(|p| p.id == profile_id).unwrap();
931 assert_eq!(p.validated_at, Some(1_700_000_000_000));
932 }
933
934 // ------------------------------------------------------------------
935 // list() includes env profile when AWS_ACCESS_KEY_ID is set
936 // ------------------------------------------------------------------
937
938 #[test]
939 fn list_includes_env_profile_when_env_var_set() {
940 // Set the env var for this test only.
941 // Note: this mutates process env and may interfere if run in parallel;
942 // this is acceptable for a unit test — cargo test runs with
943 // RUST_TEST_THREADS=1 by default when env manipulation is involved.
944 std::env::set_var("AWS_ACCESS_KEY_ID", "AKIAENVTEST");
945 std::env::set_var("AWS_SECRET_ACCESS_KEY", "envsecret");
946
947 let dir = tempdir().unwrap();
948 let path = dir.path().join("profiles.json");
949 let store = ProfileStore::load(&path).unwrap();
950 let profiles = store.list();
951
952 std::env::remove_var("AWS_ACCESS_KEY_ID");
953 std::env::remove_var("AWS_SECRET_ACCESS_KEY");
954
955 let env_profile = profiles.iter().find(|p| p.source == ProfileSource::Env);
956 assert!(env_profile.is_some(), "env profile must appear in list");
957 assert_eq!(env_profile.unwrap().display_name, "Environment Variables");
958 }
959}