brows3r_lib/s3/client.rs
1//! S3 client construction and per-(profile, region) client pool.
2//!
3//! # Design
4//!
5//! Each `(ProfileId, region)` pair maps to a long-lived, `Arc`-wrapped
6//! `aws_sdk_s3::Client`. The pool is the single owner of all clients and is
7//! intended to live inside the Tauri `AppState` for the process lifetime.
8//!
9//! Credential resolution is delegated to the AWS SDK credential-provider chain;
10//! no credentials are stored in the pool itself.
11//!
12//! # Proxy wiring
13//!
14//! `ProxyConfig` controls the HTTP connector:
15//!
16//! - `System` (default) — the SDK default connector reads `HTTP_PROXY` /
17//! `HTTPS_PROXY` / `NO_PROXY` from the environment automatically. No
18//! custom connector is injected.
19//! - `Explicit(url)` — a `ConnectorBuilder` with `ProxyConfig::all(url)` is
20//! injected via `Builder::build_with_connector_fn`, routing all traffic
21//! through the given proxy.
22//! - `None` — a `ConnectorBuilder` with `ProxyConfig::disabled()` is
23//! injected, explicitly ignoring any proxy env vars.
24//!
25//! # OCP
26//!
27//! Adding a `ProxyConfig` variant (e.g. `Pac(url)`, `PerHost { ... }`) is the
28//! only change needed to support a new proxy mode. The internal `build_http_client`
29//! function has one match arm per variant.
30
31use std::{collections::HashMap, sync::Arc};
32
33use aws_config::BehaviorVersion;
34use aws_credential_types::provider::SharedCredentialsProvider;
35use aws_sdk_s3::config::Builder as S3ConfigBuilder;
36use aws_smithy_http_client::{
37 proxy::ProxyConfig as SmithyProxyConfig,
38 tls::{self, rustls_provider::CryptoMode},
39 Builder as HttpBuilder, Connector,
40};
41use aws_smithy_runtime_api::client::http::SharedHttpClient;
42use tokio::sync::RwLock;
43
44use crate::{
45 ids::ProfileId,
46 notifications::NotificationLogHandle,
47 profiles::compat_flags::{apply_to_s3_config_builder, CompatFlags},
48};
49
50// ---------------------------------------------------------------------------
51// ProxyConfig — the public enum consumed by the pool and by Settings (task 8)
52// ---------------------------------------------------------------------------
53
54/// Controls the HTTP proxy used for all S3 requests built by this pool.
55///
56/// OCP: adding a variant here (e.g. `Pac(String)`) is the only change needed
57/// to support a new proxy mode — add the variant and one arm in
58/// `build_http_client`.
59#[derive(Debug, Clone, Default)]
60pub enum ProxyConfig {
61 /// Inherit proxy settings from the environment (`HTTP_PROXY` /
62 /// `HTTPS_PROXY` / `NO_PROXY`). This is the default — no custom
63 /// connector is injected.
64 #[default]
65 System,
66 /// Route all S3 traffic through the given proxy URL.
67 /// Example: `"http://proxy.internal:3128"`.
68 Explicit(String),
69 /// Disable proxy entirely, regardless of environment variables.
70 None,
71}
72
73// ---------------------------------------------------------------------------
74// Internal helper — build a SharedHttpClient for a given ProxyConfig
75// ---------------------------------------------------------------------------
76
77fn build_http_client(proxy: &ProxyConfig) -> SharedHttpClient {
78 match proxy {
79 // System: use the SDK default connector; it picks up proxy env vars.
80 ProxyConfig::System => HttpBuilder::new()
81 .tls_provider(tls::Provider::Rustls(CryptoMode::Ring))
82 .build_https(),
83
84 // Explicit: inject a ConnectorBuilder with an explicit proxy URL.
85 // ProxyConfig::all(url) proxies both HTTP and HTTPS traffic.
86 ProxyConfig::Explicit(url) => {
87 let smithy_proxy =
88 SmithyProxyConfig::all(url).unwrap_or_else(|_| SmithyProxyConfig::from_env());
89 HttpBuilder::new().build_with_connector_fn(move |_settings, _rt| {
90 Connector::builder()
91 .proxy_config(smithy_proxy.clone())
92 .tls_provider(tls::Provider::Rustls(CryptoMode::Ring))
93 .build()
94 })
95 }
96
97 // None: inject a connector with proxy explicitly disabled.
98 ProxyConfig::None => {
99 let smithy_proxy = SmithyProxyConfig::disabled();
100 HttpBuilder::new().build_with_connector_fn(move |_settings, _rt| {
101 Connector::builder()
102 .proxy_config(smithy_proxy.clone())
103 .tls_provider(tls::Provider::Rustls(CryptoMode::Ring))
104 .build()
105 })
106 }
107 }
108}
109
110// ---------------------------------------------------------------------------
111// surface_compat_warnings — push warnings into the notification log or log
112// ---------------------------------------------------------------------------
113
114/// Push each warning string into `log` (if provided) as a `Severity::Warning`
115/// notification, or fall through to `tracing::warn!` when `log` is `None`.
116///
117/// Errors from the notification log are not propagated — warning delivery is
118/// best-effort and must not block client construction.
119async fn surface_compat_warnings(warnings: Vec<String>, log: Option<&NotificationLogHandle>) {
120 if warnings.is_empty() {
121 return;
122 }
123 match log {
124 Some(handle) => {
125 use crate::notifications::{Notification, NotificationCategory, Severity};
126 use uuid::Uuid;
127
128 let mut guard = handle.0.write().await;
129 for msg in warnings {
130 let notification = Notification {
131 id: Uuid::new_v4().to_string(),
132 severity: Severity::Warning,
133 category: NotificationCategory::Background,
134 title: "S3 compat flag warning".to_string(),
135 message: msg,
136 resource: None,
137 operation: Some("client_build".to_string()),
138 timestamp: std::time::SystemTime::now()
139 .duration_since(std::time::UNIX_EPOCH)
140 .map(|d| d.as_millis() as i64)
141 .unwrap_or(0),
142 details: None,
143 };
144 guard.push(notification);
145 }
146 }
147 None => {
148 // No notification handle — emit to stderr via eprintln as a
149 // fallback (tracing crate may not be configured in unit tests).
150 for msg in warnings {
151 eprintln!("[compat_flags warning] {msg}");
152 }
153 }
154 }
155}
156
157// ---------------------------------------------------------------------------
158// ClientBuilder — builds a single configured aws_sdk_s3::Client
159// ---------------------------------------------------------------------------
160
161/// Parameters needed to build one `aws_sdk_s3::Client`.
162///
163/// Separated from `ClientPool` so tests can construct clients directly
164/// without standing up a pool.
165pub struct ClientBuilder<'a> {
166 region: &'a str,
167 compat: &'a CompatFlags,
168 proxy: &'a ProxyConfig,
169 /// Optional explicit credentials provider. When `None` the SDK default
170 /// provider chain is used (env → profile → IMDSv2 → …).
171 credentials_provider: Option<SharedCredentialsProvider>,
172 /// Optional notification log to push compat-flag warnings into.
173 /// When `None`, warnings are only emitted via `tracing::warn!`.
174 notification_log: Option<NotificationLogHandle>,
175}
176
177impl<'a> ClientBuilder<'a> {
178 /// Create a builder for the given region and compatibility flags.
179 pub fn new(region: &'a str, compat: &'a CompatFlags, proxy: &'a ProxyConfig) -> Self {
180 Self {
181 region,
182 compat,
183 proxy,
184 credentials_provider: Option::None,
185 notification_log: Option::None,
186 }
187 }
188
189 /// Override the credential provider (useful in tests or for manual profiles).
190 pub fn credentials_provider(mut self, provider: SharedCredentialsProvider) -> Self {
191 self.credentials_provider = Some(provider);
192 self
193 }
194
195 /// Attach a notification log handle so compat-flag warnings are surfaced
196 /// to the user through the in-app notification system (task 9).
197 ///
198 /// When not set, warnings are logged via `tracing::warn!` only.
199 pub fn notification_log(mut self, handle: NotificationLogHandle) -> Self {
200 self.notification_log = Some(handle);
201 self
202 }
203
204 /// Build the `aws_sdk_s3::Client`.
205 ///
206 /// This is `async` because `aws_config::load_from_env()` is async.
207 pub async fn build(self) -> aws_sdk_s3::Client {
208 // ------------------------------------------------------------------
209 // 1. Build the shared HTTP client with proxy wiring.
210 // ------------------------------------------------------------------
211 let http_client = build_http_client(self.proxy);
212
213 // ------------------------------------------------------------------
214 // 2. Construct the base AWS SdkConfig using aws_config.
215 // ------------------------------------------------------------------
216 let region_obj = aws_config::Region::new(self.region.to_owned());
217
218 let mut sdk_loader = aws_config::defaults(BehaviorVersion::latest())
219 .region(region_obj)
220 .http_client(http_client);
221
222 // Apply a custom endpoint URL when the compat flags request one.
223 // NOTE: endpoint_url and region_override are applied at the loader
224 // level (here) rather than inside apply_to_s3_config_builder because
225 // they affect SDK credential and endpoint resolution, which happens
226 // before the S3ConfigBuilder is constructed.
227 if let Some(ref endpoint) = self.compat.endpoint_url {
228 sdk_loader = sdk_loader.endpoint_url(endpoint.clone());
229 }
230
231 // region_override: pin to a fixed region, overriding auto-detection.
232 if let Some(ref region) = self.compat.region_override {
233 sdk_loader = sdk_loader.region(aws_config::Region::new(region.clone()));
234 }
235
236 // Inject explicit credentials if provided (manual profiles / tests).
237 let sdk_config = if let Some(creds) = self.credentials_provider {
238 sdk_loader.credentials_provider(creds).load().await
239 } else {
240 sdk_loader.load().await
241 };
242
243 // ------------------------------------------------------------------
244 // 3. Apply v1 compat flags to the S3-specific config builder.
245 // ------------------------------------------------------------------
246 let s3_builder = S3ConfigBuilder::from(&sdk_config);
247 let applied = apply_to_s3_config_builder(self.compat, s3_builder);
248
249 // Surface warnings: push into notification log when available,
250 // otherwise fall back to tracing::warn.
251 surface_compat_warnings(applied.warnings, self.notification_log.as_ref()).await;
252
253 aws_sdk_s3::Client::from_conf(applied.builder.build())
254 }
255}
256
257// ---------------------------------------------------------------------------
258// ClientPool — per-(ProfileId, region) cache
259// ---------------------------------------------------------------------------
260
261/// Pool of `Arc<aws_sdk_s3::Client>` instances, keyed by `(ProfileId, region)`.
262///
263/// Clients are built on first access and cached for the process lifetime.
264/// The pool is safe to share across threads via `Arc<ClientPool>`.
265///
266/// ## Proxy
267///
268/// A single `ProxyConfig` is applied to every client built by this pool.
269/// Per-profile proxy overrides are a task-8 concern (settings store); this
270/// pool accepts the pool-wide proxy at construction time.
271pub struct ClientPool {
272 /// Shared proxy configuration applied to every client built by this pool.
273 ///
274 /// Wrapped in a `std::sync::RwLock` so `set_proxy` can hot-swap the value
275 /// when the user changes the Settings → Proxy mode. Reads clone the value
276 /// out of the lock before any `.await` to avoid holding a guard across an
277 /// async point.
278 proxy: std::sync::RwLock<ProxyConfig>,
279
280 /// Per-profile compat flags registry. Profiles must be registered before
281 /// `get_or_build` is called for them.
282 flags: RwLock<HashMap<ProfileId, CompatFlags>>,
283
284 /// Per-profile explicit credentials. Populated by `register_credentials`
285 /// after `profile_validate` builds a provider (from the keychain secret for
286 /// manual profiles, or from the named ~/.aws/credentials entry for
287 /// AWS-discovered profiles). When absent, `get_or_build` falls through to
288 /// the SDK's default credentials chain, which only works for the user's
289 /// default profile and is the source of "dispatch failure" errors against
290 /// non-default profiles.
291 credentials: RwLock<HashMap<ProfileId, SharedCredentialsProvider>>,
292
293 /// Cached clients. Built lazily on first `get_or_build` call.
294 cache: RwLock<HashMap<(ProfileId, String), Arc<aws_sdk_s3::Client>>>,
295
296 /// Optional notification log for surfacing compat-flag warnings.
297 /// When `None`, warnings fall back to stderr/tracing.
298 notification_log: Option<NotificationLogHandle>,
299
300 /// Test-only: last proxy URL passed to the builder (from `Explicit`).
301 #[cfg(test)]
302 last_explicit_proxy: std::sync::Mutex<Option<String>>,
303}
304
305impl ClientPool {
306 /// Create a new pool with the given proxy configuration.
307 pub fn new(proxy: ProxyConfig) -> Self {
308 Self {
309 proxy: std::sync::RwLock::new(proxy),
310 flags: RwLock::new(HashMap::new()),
311 credentials: RwLock::new(HashMap::new()),
312 cache: RwLock::new(HashMap::new()),
313 notification_log: None,
314 #[cfg(test)]
315 last_explicit_proxy: std::sync::Mutex::new(Option::None),
316 }
317 }
318
319 /// Replace the proxy configuration and evict every cached client so the
320 /// next `get_or_build` rebuilds with the new connector.
321 ///
322 /// Wired to `settings_update` so the user can toggle proxy modes without
323 /// restarting the app. The connector itself is rebuilt per-client at
324 /// `get_or_build` time; evicting the cache is what makes the new value
325 /// visible to callers.
326 pub async fn set_proxy(&self, new_proxy: ProxyConfig) {
327 {
328 let mut guard = self.proxy.write().expect("ClientPool.proxy poisoned");
329 *guard = new_proxy;
330 }
331 let mut cache = self.cache.write().await;
332 cache.clear();
333 }
334
335 /// Attach a notification log so compat-flag warnings emitted during client
336 /// construction are pushed into the in-app notification system.
337 pub fn with_notification_log(mut self, handle: NotificationLogHandle) -> Self {
338 self.notification_log = Some(handle);
339 self
340 }
341
342 /// Register `CompatFlags` for a profile. Must be called before
343 /// `get_or_build` for the same profile. Calling again for an existing
344 /// profile updates the flags and evicts any cached clients for that
345 /// profile so they are rebuilt with the new flags.
346 pub async fn register_profile(&self, profile_id: ProfileId, compat: CompatFlags) {
347 // Evict stale cache entries for this profile.
348 {
349 let mut cache = self.cache.write().await;
350 cache.retain(|(pid, _), _| pid != &profile_id);
351 }
352 let mut flags = self.flags.write().await;
353 flags.insert(profile_id, compat);
354 }
355
356 /// Attach (or replace) the credentials provider used to build clients for
357 /// `profile_id`. Evicts cached clients so the next `get_or_build` rebuilds
358 /// with the new credentials.
359 ///
360 /// Without this, `get_or_build` falls back to the SDK's default chain,
361 /// which only loads the user's default profile — non-default profiles
362 /// then return "dispatch failure" because the chain has no credentials
363 /// to sign with.
364 pub async fn register_credentials(
365 &self,
366 profile_id: ProfileId,
367 creds: SharedCredentialsProvider,
368 ) {
369 {
370 let mut cache = self.cache.write().await;
371 cache.retain(|(pid, _), _| pid != &profile_id);
372 }
373 let mut credentials = self.credentials.write().await;
374 credentials.insert(profile_id, creds);
375 }
376
377 /// Return the cached `Arc<Client>` for `(profile_id, region)`, building
378 /// one if it does not yet exist.
379 ///
380 /// Returns `None` if `profile_id` has not been registered via
381 /// `register_profile`.
382 pub async fn get_or_build(
383 &self,
384 profile_id: &ProfileId,
385 region: &str,
386 ) -> Option<Arc<aws_sdk_s3::Client>> {
387 let cache_key = (profile_id.clone(), region.to_owned());
388
389 // Fast path: read lock — client already in cache.
390 {
391 let cache = self.cache.read().await;
392 if let Some(client) = cache.get(&cache_key) {
393 return Some(Arc::clone(client));
394 }
395 }
396
397 // Slow path: not in cache — need to build. Look up compat flags first.
398 let compat = {
399 let flags = self.flags.read().await;
400 flags.get(profile_id)?.clone()
401 };
402
403 // Clone the proxy config out of the lock before any await point.
404 let proxy_snapshot = {
405 let guard = self.proxy.read().expect("ClientPool.proxy poisoned");
406 guard.clone()
407 };
408
409 // Record the explicit proxy URL for test observability.
410 #[cfg(test)]
411 if let ProxyConfig::Explicit(ref url) = proxy_snapshot {
412 if let Ok(mut guard) = self.last_explicit_proxy.lock() {
413 *guard = Some(url.clone());
414 }
415 }
416
417 // Look up any explicit credentials for this profile. When absent we
418 // fall through to the SDK's default chain.
419 let explicit_creds = {
420 let credentials = self.credentials.read().await;
421 credentials.get(profile_id).cloned()
422 };
423
424 // Build outside the write lock to avoid holding it across the async
425 // aws_config loader. Pass the notification log so compat-flag warnings
426 // are surfaced to the user through the in-app notification system.
427 let mut cb = ClientBuilder::new(region, &compat, &proxy_snapshot);
428 if let Some(creds) = explicit_creds {
429 cb = cb.credentials_provider(creds);
430 }
431 if let Some(ref log) = self.notification_log {
432 cb = cb.notification_log(log.clone());
433 }
434 let client = cb.build().await;
435 let client_arc = Arc::new(client);
436
437 // Write the result into the cache. Another task may have raced and
438 // inserted the same key while we were building; prefer the existing
439 // entry (first writer wins for consistency).
440 let mut cache = self.cache.write().await;
441 let stored = cache
442 .entry(cache_key)
443 .or_insert_with(|| Arc::clone(&client_arc));
444 Some(Arc::clone(stored))
445 }
446
447 /// Test-only accessor: returns the last explicit proxy URL stored when
448 /// `ProxyConfig::Explicit` was active during a `get_or_build` call.
449 #[cfg(test)]
450 pub(crate) fn last_proxy_for_test(&self) -> Option<String> {
451 self.last_explicit_proxy.lock().ok().and_then(|g| g.clone())
452 }
453}
454
455// ---------------------------------------------------------------------------
456// Tests
457// ---------------------------------------------------------------------------
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use crate::profiles::compat_flags::AddressingStyle;
463 use aws_credential_types::Credentials;
464
465 /// Minimal compat flags for AWS standard S3.
466 fn aws_compat() -> CompatFlags {
467 CompatFlags::default()
468 }
469
470 /// Compat flags for a local provider (MinIO / LocalStack) with path-style
471 /// addressing and a custom endpoint URL.
472 fn local_compat(endpoint: &str) -> CompatFlags {
473 CompatFlags {
474 endpoint_url: Some(endpoint.to_owned()),
475 addressing_style: AddressingStyle::Path,
476 ..Default::default()
477 }
478 }
479
480 /// A static test credentials provider that never makes network calls.
481 fn test_creds() -> SharedCredentialsProvider {
482 SharedCredentialsProvider::new(Credentials::new(
483 "AKIATEST000000000000",
484 "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
485 None,
486 None,
487 "test",
488 ))
489 }
490
491 // (a) Client builder for AWS — verify it constructs without panic.
492 // No network call is made; the SDK only connects on the first request.
493 #[tokio::test]
494 async fn builds_standard_aws_client() {
495 let compat = aws_compat();
496 let client = ClientBuilder::new("us-east-1", &compat, &ProxyConfig::None)
497 .credentials_provider(test_creds())
498 .build()
499 .await;
500
501 let config = client.config();
502 let region = config.region().expect("region must be set");
503 assert_eq!(region.as_ref(), "us-east-1");
504 }
505
506 // (b) Client builder with custom endpoint + path_style=true.
507 #[tokio::test]
508 async fn builds_custom_endpoint_path_style_client() {
509 // Use a non-routable address — the SDK will not attempt to connect
510 // until the first API call.
511 let compat = local_compat("http://127.0.0.1:9999");
512 let client = ClientBuilder::new("us-east-1", &compat, &ProxyConfig::None)
513 .credentials_provider(test_creds())
514 .build()
515 .await;
516
517 // aws-sdk-s3 v1's `Config` does not expose getters; assert via Debug.
518 let dump = format!("{:?}", client.config());
519 assert!(
520 dump.contains("ForcePathStyle(true)"),
521 "force_path_style must be true for path addressing style; got: {dump}"
522 );
523 }
524
525 // (b2) endpoint_url from compat flags is reflected in the built client config.
526 #[tokio::test]
527 async fn endpoint_url_reflected_in_client_config() {
528 let endpoint = "http://127.0.0.1:9999";
529 let compat = local_compat(endpoint);
530 let client = ClientBuilder::new("us-east-1", &compat, &ProxyConfig::None)
531 .credentials_provider(test_creds())
532 .build()
533 .await;
534
535 // The SDK stores the endpoint_url; assert via Debug since v1 Config
536 // exposes no getter. We just check the literal endpoint string is
537 // present somewhere in the formatted config.
538 let dump = format!("{:?}", client.config());
539 assert!(
540 dump.contains(endpoint),
541 "config must contain endpoint_url={endpoint}; got: {dump}"
542 );
543 }
544
545 // (c) Proxy URL is applied and observable via the test accessor.
546 #[tokio::test]
547 async fn explicit_proxy_url_applied_to_pool() {
548 let proxy_url = "http://proxy.example.com:3128";
549 let pool = ClientPool::new(ProxyConfig::Explicit(proxy_url.to_owned()));
550
551 let profile_id = ProfileId::new("test-profile");
552 pool.register_profile(profile_id.clone(), local_compat("http://127.0.0.1:9999"))
553 .await;
554
555 let client = pool.get_or_build(&profile_id, "us-east-1").await;
556 assert!(
557 client.is_some(),
558 "pool must return a client for a registered profile"
559 );
560
561 // Verify the explicit proxy URL was recorded during construction.
562 let recorded_proxy = pool.last_proxy_for_test();
563 assert_eq!(
564 recorded_proxy.as_deref(),
565 Some(proxy_url),
566 "proxy URL must be applied to the connector"
567 );
568 }
569
570 // (d) Pool returns the same Arc<Client> for repeat (profile, region) calls.
571 #[tokio::test]
572 async fn pool_deduplicates_same_profile_region() {
573 let pool = ClientPool::new(ProxyConfig::None);
574 let profile_id = ProfileId::new("dedup-profile");
575
576 // Custom endpoint avoids any real DNS / AWS calls.
577 pool.register_profile(profile_id.clone(), local_compat("http://127.0.0.1:9998"))
578 .await;
579
580 let c1 = pool
581 .get_or_build(&profile_id, "eu-west-1")
582 .await
583 .expect("first call must succeed");
584 let c2 = pool
585 .get_or_build(&profile_id, "eu-west-1")
586 .await
587 .expect("second call must succeed");
588
589 // Both arcs must point to the same allocation.
590 assert!(
591 Arc::ptr_eq(&c1, &c2),
592 "repeat (profile, region) must return the cached Arc<Client>"
593 );
594 }
595
596 // (e) Different (profile, region) combos produce independent clients.
597 #[tokio::test]
598 async fn pool_creates_distinct_clients_for_different_regions() {
599 let pool = ClientPool::new(ProxyConfig::None);
600 let profile_id = ProfileId::new("multi-region-profile");
601
602 pool.register_profile(profile_id.clone(), local_compat("http://127.0.0.1:9997"))
603 .await;
604
605 let c_east = pool
606 .get_or_build(&profile_id, "us-east-1")
607 .await
608 .expect("us-east-1 client");
609 let c_west = pool
610 .get_or_build(&profile_id, "us-west-2")
611 .await
612 .expect("us-west-2 client");
613
614 assert!(
615 !Arc::ptr_eq(&c_east, &c_west),
616 "different regions must produce distinct cached clients"
617 );
618 }
619
620 // (f) Pool returns None for an unregistered profile.
621 #[tokio::test]
622 async fn pool_returns_none_for_unregistered_profile() {
623 let pool = ClientPool::new(ProxyConfig::System);
624 let result = pool
625 .get_or_build(&ProfileId::new("unknown"), "us-east-1")
626 .await;
627 assert!(result.is_none(), "unregistered profile must return None");
628 }
629}