Skip to main content

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.
28pub fn normalize_s3_endpoint_and_bucket(
29    endpoint: &str,
30    bucket: &str,
31) -> std::result::Result<NormalizedS3Config, S3ConfigError> {
32    let endpoint = endpoint.trim();
33    let bucket = bucket.trim().to_string();
34
35    if endpoint.is_empty() {
36        if bucket.is_empty() {
37            return Err(S3ConfigError::MissingBucket);
38        }
39
40        return Ok(NormalizedS3Config {
41            endpoint: String::new(),
42            bucket,
43        });
44    }
45
46    if endpoint.contains('#') {
47        return Err(S3ConfigError::InvalidEndpoint(format!(
48            "S3 endpoint must not include a fragment: '{endpoint}'"
49        )));
50    }
51
52    let uri: Uri = endpoint.parse().map_err(|_| {
53        S3ConfigError::InvalidEndpoint(format!("invalid S3 endpoint URL: '{endpoint}'"))
54    })?;
55
56    let scheme = uri.scheme_str().ok_or_else(|| {
57        S3ConfigError::InvalidEndpoint(format!(
58            "S3 endpoint must include http:// or https://: '{endpoint}'"
59        ))
60    })?;
61    if scheme != "http" && scheme != "https" {
62        return Err(S3ConfigError::InvalidEndpoint(format!(
63            "S3 endpoint must use http:// or https://: '{endpoint}'"
64        )));
65    }
66
67    uri.authority().ok_or_else(|| {
68        S3ConfigError::InvalidEndpoint(format!("S3 endpoint must include a hostname: '{endpoint}'"))
69    })?;
70
71    if uri.query().is_some() {
72        return Err(S3ConfigError::InvalidEndpoint(format!(
73            "S3 endpoint must not include a query string: '{endpoint}'"
74        )));
75    }
76
77    if bucket.is_empty() {
78        return Err(S3ConfigError::MissingBucket);
79    }
80
81    Ok(NormalizedS3Config {
82        endpoint: endpoint.trim_end_matches('/').to_string(),
83        bucket,
84    })
85}
86
87#[cfg(test)]
88mod tests {
89    use super::{S3ConfigError, normalize_s3_endpoint_and_bucket};
90
91    #[test]
92    fn allows_standard_s3_endpoint_without_rewriting() {
93        let normalized =
94            normalize_s3_endpoint_and_bucket("https://s3.example.com/custom/path", "archive")
95                .expect("normalized S3 config");
96
97        assert_eq!(normalized.endpoint, "https://s3.example.com/custom/path");
98        assert_eq!(normalized.bucket, "archive");
99    }
100
101    #[test]
102    fn trims_trailing_endpoint_slashes() {
103        let normalized =
104            normalize_s3_endpoint_and_bucket("https://s3.example.com/custom/path/", "archive")
105                .expect("normalized S3 config");
106
107        assert_eq!(normalized.endpoint, "https://s3.example.com/custom/path");
108    }
109
110    #[test]
111    fn rejects_missing_bucket_for_any_s3_compatible_endpoint() {
112        assert_eq!(
113            normalize_s3_endpoint_and_bucket("https://s3.example.com", "")
114                .expect_err("missing bucket should fail"),
115            S3ConfigError::MissingBucket
116        );
117    }
118
119    #[test]
120    fn allows_empty_endpoint_when_bucket_is_present() {
121        let normalized =
122            normalize_s3_endpoint_and_bucket("   ", " archive ").expect("bucket-only config");
123
124        assert_eq!(normalized.endpoint, "");
125        assert_eq!(normalized.bucket, "archive");
126    }
127
128    #[test]
129    fn rejects_endpoint_without_http_scheme_or_host() {
130        assert!(matches!(
131            normalize_s3_endpoint_and_bucket("s3.example.com", "archive"),
132            Err(S3ConfigError::InvalidEndpoint(_))
133        ));
134        assert!(matches!(
135            normalize_s3_endpoint_and_bucket("ftp://s3.example.com", "archive"),
136            Err(S3ConfigError::InvalidEndpoint(_))
137        ));
138        assert!(matches!(
139            normalize_s3_endpoint_and_bucket("https:///missing-host", "archive"),
140            Err(S3ConfigError::InvalidEndpoint(_))
141        ));
142        assert!(matches!(
143            normalize_s3_endpoint_and_bucket("https://s3.example.com?x=1", "archive"),
144            Err(S3ConfigError::InvalidEndpoint(_))
145        ));
146        assert!(matches!(
147            normalize_s3_endpoint_and_bucket("https://s3.example.com/root#fragment", "archive"),
148            Err(S3ConfigError::InvalidEndpoint(_))
149        ));
150    }
151}