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