Skip to main content

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}