aster_forge_storage_core/
s3_config.rs

1//! S3-compatible endpoint and bucket normalization helpers.
2//!
3//! S3-compatible providers often encode the bucket either in configuration or in the endpoint URL.
4//! This module extracts a consistent bucket and endpoint pair while rejecting ambiguous or malformed
5//! values before a storage driver attempts to connect.
6
7use http::Uri;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10/// Normalized S3-compatible endpoint and bucket values.
11pub struct NormalizedS3Config {
12    /// Endpoint URL, or an empty string when the provider default endpoint should be used.
13    pub endpoint: String,
14    /// Bucket name with surrounding whitespace removed.
15    pub bucket: String,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19/// Errors returned while normalizing S3-compatible configuration.
20pub enum S3ConfigError {
21    /// The bucket field is required but was empty.
22    MissingBucket,
23    /// The endpoint URL was malformed or used an unsupported scheme.
24    InvalidEndpoint(String),
25}
26
27/// Normalizes and validates an S3-compatible endpoint and bucket pair.
28///
29/// # Errors
30///
31/// Returns [`S3ConfigError::MissingBucket`] when the trimmed bucket is empty, or
32/// [`S3ConfigError::InvalidEndpoint`] when a non-empty endpoint is malformed, lacks an HTTP(S)
33/// scheme or hostname, or contains a query string or fragment.
34pub fn normalize_s3_endpoint_and_bucket(
35    endpoint: &str,
36    bucket: &str,
37) -> std::result::Result<NormalizedS3Config, S3ConfigError> {
38    let endpoint = endpoint.trim();
39    let bucket = bucket.trim().to_string();
40
41    if endpoint.is_empty() {
42        if bucket.is_empty() {
43            return Err(S3ConfigError::MissingBucket);
44        }
45
46        return Ok(NormalizedS3Config {
47            endpoint: String::new(),
48            bucket,
49        });
50    }
51
52    if endpoint.contains('#') {
53        return Err(S3ConfigError::InvalidEndpoint(format!(
54            "S3 endpoint must not include a fragment: '{endpoint}'"
55        )));
56    }
57
58    let uri: Uri = endpoint.parse().map_err(|_| {
59        S3ConfigError::InvalidEndpoint(format!("invalid S3 endpoint URL: '{endpoint}'"))
60    })?;
61
62    let scheme = uri.scheme_str().ok_or_else(|| {
63        S3ConfigError::InvalidEndpoint(format!(
64            "S3 endpoint must include http:// or https://: '{endpoint}'"
65        ))
66    })?;
67    if scheme != "http" && scheme != "https" {
68        return Err(S3ConfigError::InvalidEndpoint(format!(
69            "S3 endpoint must use http:// or https://: '{endpoint}'"
70        )));
71    }
72
73    uri.authority().ok_or_else(|| {
74        S3ConfigError::InvalidEndpoint(format!("S3 endpoint must include a hostname: '{endpoint}'"))
75    })?;
76
77    if uri.query().is_some() {
78        return Err(S3ConfigError::InvalidEndpoint(format!(
79            "S3 endpoint must not include a query string: '{endpoint}'"
80        )));
81    }
82
83    if bucket.is_empty() {
84        return Err(S3ConfigError::MissingBucket);
85    }
86
87    Ok(NormalizedS3Config {
88        endpoint: endpoint.trim_end_matches('/').to_string(),
89        bucket,
90    })
91}
92
93#[cfg(test)]
94mod tests {
95    use super::{S3ConfigError, normalize_s3_endpoint_and_bucket};
96
97    #[test]
98    fn allows_standard_s3_endpoint_without_rewriting() {
99        let normalized =
100            normalize_s3_endpoint_and_bucket("https://s3.example.com/custom/path", "archive")
101                .expect("normalized S3 config");
102
103        assert_eq!(normalized.endpoint, "https://s3.example.com/custom/path");
104        assert_eq!(normalized.bucket, "archive");
105    }
106
107    #[test]
108    fn trims_trailing_endpoint_slashes() {
109        let normalized =
110            normalize_s3_endpoint_and_bucket("https://s3.example.com/custom/path/", "archive")
111                .expect("normalized S3 config");
112
113        assert_eq!(normalized.endpoint, "https://s3.example.com/custom/path");
114    }
115
116    #[test]
117    fn rejects_missing_bucket_for_any_s3_compatible_endpoint() {
118        assert_eq!(
119            normalize_s3_endpoint_and_bucket("https://s3.example.com", "")
120                .expect_err("missing bucket should fail"),
121            S3ConfigError::MissingBucket
122        );
123    }
124
125    #[test]
126    fn allows_empty_endpoint_when_bucket_is_present() {
127        let normalized =
128            normalize_s3_endpoint_and_bucket("   ", " archive ").expect("bucket-only config");
129
130        assert_eq!(normalized.endpoint, "");
131        assert_eq!(normalized.bucket, "archive");
132    }
133
134    #[test]
135    fn rejects_endpoint_without_http_scheme_or_host() {
136        assert!(matches!(
137            normalize_s3_endpoint_and_bucket("s3.example.com", "archive"),
138            Err(S3ConfigError::InvalidEndpoint(_))
139        ));
140        assert!(matches!(
141            normalize_s3_endpoint_and_bucket("ftp://s3.example.com", "archive"),
142            Err(S3ConfigError::InvalidEndpoint(_))
143        ));
144        assert!(matches!(
145            normalize_s3_endpoint_and_bucket("https:///missing-host", "archive"),
146            Err(S3ConfigError::InvalidEndpoint(_))
147        ));
148        assert!(matches!(
149            normalize_s3_endpoint_and_bucket("https://s3.example.com?x=1", "archive"),
150            Err(S3ConfigError::InvalidEndpoint(_))
151        ));
152        assert!(matches!(
153            normalize_s3_endpoint_and_bucket("https://s3.example.com/root#fragment", "archive"),
154            Err(S3ConfigError::InvalidEndpoint(_))
155        ));
156    }
157}