aster_forge_storage_core/
s3_config.rs1use http::Uri;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct NormalizedS3Config {
12 pub endpoint: String,
14 pub bucket: String,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum S3ConfigError {
21 MissingBucket,
23 InvalidEndpoint(String),
25}
26
27pub 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}