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(
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}