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