Skip to main content

s3_wire/config/
mod.rs

1//! Typed client configuration.
2
3use std::fmt;
4use std::sync::Arc;
5use std::time::Duration;
6
7use http::HeaderValue;
8
9use crate::credentials::{CredentialsProvider, EnvironmentCredentialsProvider};
10use crate::endpoint::Endpoint;
11use crate::error::S3Error;
12use crate::observer::RequestObserver;
13use crate::retry::RetryPolicy;
14
15pub use crate::endpoint::AddressingStyle;
16
17/// Validated configuration used to construct an S3 client.
18#[derive(Clone)]
19pub struct S3Config {
20    endpoint: Endpoint,
21    region: String,
22    bucket: String,
23    addressing_style: AddressingStyle,
24    connect_timeout: Duration,
25    attempt_timeout: Duration,
26    operation_timeout: Duration,
27    idle_body_timeout: Duration,
28    max_xml_response_size: usize,
29    max_error_response_size: usize,
30    retry_policy: RetryPolicy,
31    user_agent: String,
32    credentials_provider: Arc<dyn CredentialsProvider>,
33    observer: Option<Arc<dyn RequestObserver>>,
34}
35
36impl S3Config {
37    /// Starts a configuration builder with HTTPS-safe defaults.
38    pub fn builder() -> S3ConfigBuilder {
39        S3ConfigBuilder::default()
40    }
41
42    /// Returns the service endpoint.
43    pub fn endpoint(&self) -> &Endpoint {
44        &self.endpoint
45    }
46
47    /// Returns the signing region.
48    pub fn region(&self) -> &str {
49        &self.region
50    }
51
52    /// Returns the configured bucket.
53    pub fn bucket(&self) -> &str {
54        &self.bucket
55    }
56
57    /// Returns the configured bucket addressing style.
58    pub fn addressing_style(&self) -> AddressingStyle {
59        self.addressing_style
60    }
61
62    /// Returns the connection-establishment timeout.
63    pub fn connect_timeout(&self) -> Duration {
64        self.connect_timeout
65    }
66
67    /// Returns the timeout applied to a single request attempt.
68    pub fn attempt_timeout(&self) -> Duration {
69        self.attempt_timeout
70    }
71
72    /// Returns the overall operation timeout across all attempts.
73    pub fn operation_timeout(&self) -> Duration {
74        self.operation_timeout
75    }
76
77    /// Returns the maximum permitted gap between response body chunks.
78    pub fn idle_body_timeout(&self) -> Duration {
79        self.idle_body_timeout
80    }
81
82    /// Returns the maximum XML response body size in bytes.
83    pub fn max_xml_response_size(&self) -> usize {
84        self.max_xml_response_size
85    }
86
87    /// Returns the maximum error response body size in bytes.
88    pub fn max_error_response_size(&self) -> usize {
89        self.max_error_response_size
90    }
91
92    /// Returns the retry policy.
93    pub fn retry_policy(&self) -> &RetryPolicy {
94        &self.retry_policy
95    }
96
97    /// Returns the HTTP user-agent value.
98    pub fn user_agent(&self) -> &str {
99        &self.user_agent
100    }
101
102    /// Returns the credential provider.
103    pub fn credentials_provider(&self) -> &Arc<dyn CredentialsProvider> {
104        &self.credentials_provider
105    }
106
107    /// Returns the optional sanitized request observer.
108    pub fn observer(&self) -> Option<&Arc<dyn RequestObserver>> {
109        self.observer.as_ref()
110    }
111
112    /// Clones this service configuration for another bucket.
113    ///
114    /// This is used by [`crate::S3Client::for_bucket`] to retain the same
115    /// connection pool and credential provider while validating the new bucket
116    /// for the configured addressing style.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error when the bucket is empty or invalid for the configured
121    /// endpoint and addressing style.
122    pub fn for_bucket(&self, bucket: impl Into<String>) -> Result<Self, S3Error> {
123        let bucket = bucket.into();
124        if bucket.is_empty() {
125            return Err(S3Error::configuration("bucket must not be empty"));
126        }
127        self.endpoint
128            .object_url(&bucket, None, self.addressing_style)?;
129        let mut config = self.clone();
130        config.bucket = bucket;
131        Ok(config)
132    }
133}
134
135impl fmt::Debug for S3Config {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        formatter
138            .debug_struct("S3Config")
139            .field("endpoint", &self.endpoint)
140            .field("region", &self.region)
141            .field("bucket", &self.bucket)
142            .field("addressing_style", &self.addressing_style)
143            .field("connect_timeout", &self.connect_timeout)
144            .field("attempt_timeout", &self.attempt_timeout)
145            .field("operation_timeout", &self.operation_timeout)
146            .field("idle_body_timeout", &self.idle_body_timeout)
147            .field("max_xml_response_size", &self.max_xml_response_size)
148            .field("max_error_response_size", &self.max_error_response_size)
149            .field("retry_policy", &self.retry_policy)
150            .field("user_agent", &self.user_agent)
151            .field("credentials_provider", &"[REDACTED]")
152            .field("observer", &self.observer.as_ref().map(|_| "configured"))
153            .finish()
154    }
155}
156
157/// Builder for [`S3Config`].
158pub struct S3ConfigBuilder {
159    endpoint: Endpoint,
160    region: String,
161    bucket: Option<String>,
162    addressing_style: AddressingStyle,
163    allow_http: bool,
164    connect_timeout: Duration,
165    attempt_timeout: Duration,
166    operation_timeout: Duration,
167    idle_body_timeout: Duration,
168    max_xml_response_size: usize,
169    max_error_response_size: usize,
170    retry_policy: RetryPolicy,
171    user_agent: String,
172    credentials_provider: Arc<dyn CredentialsProvider>,
173    observer: Option<Arc<dyn RequestObserver>>,
174}
175
176impl S3ConfigBuilder {
177    /// Sets a custom endpoint. HTTP endpoints still require explicit opt-in.
178    pub fn endpoint(mut self, endpoint: Endpoint) -> Self {
179        self.endpoint = endpoint;
180        self
181    }
182
183    /// Sets the SigV4 signing region.
184    pub fn region(mut self, region: impl Into<String>) -> Self {
185        self.region = region.into();
186        self
187    }
188
189    /// Sets the target bucket.
190    pub fn bucket(mut self, bucket: impl Into<String>) -> Self {
191        self.bucket = Some(bucket.into());
192        self
193    }
194
195    /// Sets the bucket addressing style.
196    pub fn addressing_style(mut self, style: AddressingStyle) -> Self {
197        self.addressing_style = style;
198        self
199    }
200
201    /// Explicitly permits plain HTTP for local S3-compatible testing.
202    ///
203    /// This does not disable TLS verification for HTTPS endpoints and should not be
204    /// enabled for endpoints reached over an untrusted network.
205    pub fn allow_http_for_local_testing(mut self) -> Self {
206        self.allow_http = true;
207        self
208    }
209
210    /// Sets the connection-establishment timeout.
211    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
212        self.connect_timeout = timeout;
213        self
214    }
215
216    /// Sets the timeout for each individual request attempt.
217    pub fn attempt_timeout(mut self, timeout: Duration) -> Self {
218        self.attempt_timeout = timeout;
219        self
220    }
221
222    /// Sets the overall timeout for one primitive S3 operation across retries.
223    ///
224    /// Managed multipart uploads instead use the transfer and cleanup deadlines
225    /// in [`crate::MultipartOptions`].
226    pub fn operation_timeout(mut self, timeout: Duration) -> Self {
227        self.operation_timeout = timeout;
228        self
229    }
230
231    /// Sets the maximum idle period while streaming a response body.
232    pub fn idle_body_timeout(mut self, timeout: Duration) -> Self {
233        self.idle_body_timeout = timeout;
234        self
235    }
236
237    /// Sets the maximum XML response body size.
238    pub fn max_xml_response_size(mut self, bytes: usize) -> Self {
239        self.max_xml_response_size = bytes;
240        self
241    }
242
243    /// Sets the maximum error response body size.
244    pub fn max_error_response_size(mut self, bytes: usize) -> Self {
245        self.max_error_response_size = bytes;
246        self
247    }
248
249    /// Sets the retry policy.
250    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
251        self.retry_policy = policy;
252        self
253    }
254
255    /// Sets the HTTP user-agent value.
256    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
257        self.user_agent = user_agent.into();
258        self
259    }
260
261    /// Sets the asynchronous credential provider.
262    pub fn credentials_provider(mut self, provider: Arc<dyn CredentialsProvider>) -> Self {
263        self.credentials_provider = provider;
264        self
265    }
266
267    /// Installs a dependency-free observer for sanitized request lifecycle events.
268    pub fn observer(mut self, observer: Arc<dyn RequestObserver>) -> Self {
269        self.observer = Some(observer);
270        self
271    }
272
273    /// Validates and creates the configuration.
274    pub fn build(self) -> Result<S3Config, S3Error> {
275        let bucket = self
276            .bucket
277            .ok_or_else(|| S3Error::configuration("bucket is required"))?;
278        if !self.endpoint.is_https() && !self.allow_http {
279            return Err(S3Error::configuration(
280                "plain HTTP requires allow_http_for_local_testing",
281            ));
282        }
283        validate_nonempty_token("region", &self.region)?;
284        if bucket.is_empty() {
285            return Err(S3Error::configuration("bucket must not be empty"));
286        }
287        for (name, timeout) in [
288            ("connect timeout", self.connect_timeout),
289            ("attempt timeout", self.attempt_timeout),
290            ("operation timeout", self.operation_timeout),
291            ("idle body timeout", self.idle_body_timeout),
292        ] {
293            if timeout.is_zero() {
294                return Err(S3Error::configuration(format!(
295                    "{name} must be greater than zero"
296                )));
297            }
298            if std::time::Instant::now().checked_add(timeout).is_none() {
299                return Err(S3Error::configuration(format!(
300                    "{name} is too large to represent as a deadline"
301                )));
302            }
303        }
304        if self.max_xml_response_size == 0 || self.max_error_response_size == 0 {
305            return Err(S3Error::configuration(
306                "response body limits must be greater than zero",
307            ));
308        }
309        HeaderValue::from_str(&self.user_agent)
310            .map_err(|_| S3Error::configuration("user agent is not a valid HTTP header value"))?;
311        if self.user_agent.is_empty() {
312            return Err(S3Error::configuration("user agent must not be empty"));
313        }
314
315        // Ensure this bucket is valid for the selected style before any signed request.
316        self.endpoint
317            .object_url(&bucket, None, self.addressing_style)?;
318
319        Ok(S3Config {
320            endpoint: self.endpoint,
321            region: self.region,
322            bucket,
323            addressing_style: self.addressing_style,
324            connect_timeout: self.connect_timeout,
325            attempt_timeout: self.attempt_timeout,
326            operation_timeout: self.operation_timeout,
327            idle_body_timeout: self.idle_body_timeout,
328            max_xml_response_size: self.max_xml_response_size,
329            max_error_response_size: self.max_error_response_size,
330            retry_policy: self.retry_policy,
331            user_agent: self.user_agent,
332            credentials_provider: self.credentials_provider,
333            observer: self.observer,
334        })
335    }
336}
337
338impl Default for S3ConfigBuilder {
339    fn default() -> Self {
340        Self {
341            endpoint: Endpoint::default(),
342            region: "us-east-1".to_owned(),
343            bucket: None,
344            addressing_style: AddressingStyle::Path,
345            allow_http: false,
346            connect_timeout: Duration::from_secs(10),
347            attempt_timeout: Duration::from_secs(30),
348            operation_timeout: Duration::from_secs(5 * 60),
349            idle_body_timeout: Duration::from_secs(30),
350            max_xml_response_size: 1024 * 1024,
351            max_error_response_size: 64 * 1024,
352            retry_policy: RetryPolicy::default(),
353            user_agent: format!("s3-wire/{}", env!("CARGO_PKG_VERSION")),
354            credentials_provider: Arc::new(EnvironmentCredentialsProvider::new()),
355            observer: None,
356        }
357    }
358}
359
360fn validate_nonempty_token(name: &str, value: &str) -> Result<(), S3Error> {
361    if value.is_empty()
362        || !value
363            .bytes()
364            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
365    {
366        return Err(S3Error::configuration(format!("{name} is invalid")));
367    }
368    Ok(())
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn https_is_the_default_and_http_requires_opt_in() {
377        let default_config = S3Config::builder().bucket("bucket").build().unwrap();
378        assert!(default_config.endpoint().is_https());
379
380        let endpoint = Endpoint::new("http://127.0.0.1:9000").unwrap();
381        assert!(
382            S3Config::builder()
383                .endpoint(endpoint.clone())
384                .bucket("bucket")
385                .build()
386                .is_err()
387        );
388        assert!(
389            S3Config::builder()
390                .endpoint(endpoint)
391                .allow_http_for_local_testing()
392                .bucket("bucket")
393                .build()
394                .is_ok()
395        );
396    }
397
398    #[test]
399    fn timeouts_must_fit_in_an_instant_deadline() {
400        assert!(
401            S3Config::builder()
402                .bucket("bucket")
403                .operation_timeout(Duration::MAX)
404                .build()
405                .is_err()
406        );
407    }
408}