Skip to main content

modo/storage/
config.rs

1use serde::Deserialize;
2
3use crate::error::{Error, Result};
4
5/// Configuration for a single S3-compatible storage bucket.
6#[non_exhaustive]
7#[derive(Debug, Clone, Deserialize)]
8#[serde(default)]
9pub struct BucketConfig {
10    /// Name used as the lookup key in [`Buckets`](super::Buckets). Ignored by [`Storage::new()`](super::Storage::new).
11    pub name: String,
12    /// S3 bucket name.
13    pub bucket: String,
14    /// AWS region (e.g. `us-east-1`). `None` uses `us-east-1` by default.
15    pub region: Option<String>,
16    /// S3-compatible endpoint URL.
17    pub endpoint: String,
18    /// Access key ID.
19    pub access_key: String,
20    /// Secret access key.
21    pub secret_key: String,
22    /// Base URL for public (non-signed) file URLs. `None` means [`Storage::url()`](super::Storage::url) will error.
23    pub public_url: Option<String>,
24    /// Maximum file size in human-readable format (e.g. `"10mb"`). `None` disables the limit.
25    pub max_file_size: Option<String>,
26    /// Use path-style URLs (e.g. `https://endpoint/bucket/key`). Defaults to `true`.
27    /// Set to `false` for virtual-hosted-style (e.g. `https://bucket.endpoint/key`).
28    pub path_style: bool,
29}
30
31impl Default for BucketConfig {
32    fn default() -> Self {
33        Self {
34            name: String::new(),
35            bucket: String::new(),
36            region: None,
37            endpoint: String::new(),
38            access_key: String::new(),
39            secret_key: String::new(),
40            public_url: None,
41            max_file_size: None,
42            path_style: true,
43        }
44    }
45}
46
47impl BucketConfig {
48    /// Validate configuration. Returns an error if required fields are missing
49    /// or `max_file_size` is invalid. Called by `Storage::new()`.
50    pub(crate) fn validate(&self) -> Result<()> {
51        if self.bucket.is_empty() {
52            return Err(Error::internal("bucket name is required"));
53        }
54        if self.endpoint.is_empty() {
55            return Err(Error::internal("endpoint is required"));
56        }
57        if let Some(ref size_str) = self.max_file_size {
58            parse_size(size_str)?; // validates format and > 0
59        }
60        Ok(())
61    }
62
63    /// Normalize the config: trim `public_url`, convert empty to `None`.
64    pub(crate) fn normalized_public_url(&self) -> Option<String> {
65        self.public_url
66            .as_deref()
67            .map(str::trim)
68            .filter(|s| !s.is_empty())
69            .map(|s| s.trim_end_matches('/').to_string())
70    }
71
72    /// Parse `max_file_size` to bytes. Returns `None` if not set.
73    pub(crate) fn max_file_size_bytes(&self) -> Result<Option<usize>> {
74        match &self.max_file_size {
75            Some(s) => Ok(Some(parse_size(s)?)),
76            None => Ok(None),
77        }
78    }
79}
80
81/// Parse a human-readable size string into bytes.
82///
83/// Format: `<number><unit>` where unit is `b`, `kb`, `mb`, `gb` (case-insensitive).
84/// Bare numbers (e.g. `"1024"`) are treated as bytes.
85pub(crate) fn parse_size(s: &str) -> Result<usize> {
86    let s = s.trim().to_ascii_lowercase();
87    if s.is_empty() {
88        return Err(Error::internal("empty size string"));
89    }
90
91    let (num_str, multiplier) = if let Some(n) = s.strip_suffix("gb") {
92        (n, 1024 * 1024 * 1024)
93    } else if let Some(n) = s.strip_suffix("mb") {
94        (n, 1024 * 1024)
95    } else if let Some(n) = s.strip_suffix("kb") {
96        (n, 1024)
97    } else if let Some(n) = s.strip_suffix('b') {
98        (n, 1)
99    } else {
100        (s.as_str(), 1)
101    };
102
103    let num: usize = num_str
104        .trim()
105        .parse()
106        .map_err(|_| Error::internal(format!("invalid size string: \"{s}\"")))?;
107
108    let result = num
109        .checked_mul(multiplier)
110        .ok_or_else(|| Error::internal(format!("size value overflows: \"{s}\"")))?;
111    if result == 0 {
112        return Err(Error::internal(format!(
113            "size must be greater than 0: \"{s}\""
114        )));
115    }
116
117    Ok(result)
118}
119
120/// Convert kilobytes to bytes.
121pub fn kb(n: usize) -> usize {
122    n * 1024
123}
124
125/// Convert megabytes to bytes.
126pub fn mb(n: usize) -> usize {
127    n * 1024 * 1024
128}
129
130/// Convert gigabytes to bytes.
131pub fn gb(n: usize) -> usize {
132    n * 1024 * 1024 * 1024
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    // -- parse_size --
140
141    #[test]
142    fn parse_size_mb() {
143        assert_eq!(parse_size("10mb").unwrap(), 10 * 1024 * 1024);
144    }
145
146    #[test]
147    fn parse_size_kb() {
148        assert_eq!(parse_size("500kb").unwrap(), 500 * 1024);
149    }
150
151    #[test]
152    fn parse_size_gb() {
153        assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
154    }
155
156    #[test]
157    fn parse_size_bytes_with_suffix() {
158        assert_eq!(parse_size("1024b").unwrap(), 1024);
159    }
160
161    #[test]
162    fn parse_size_bare_number() {
163        assert_eq!(parse_size("1024").unwrap(), 1024);
164    }
165
166    #[test]
167    fn parse_size_case_insensitive() {
168        assert_eq!(parse_size("10MB").unwrap(), 10 * 1024 * 1024);
169        assert_eq!(parse_size("5Kb").unwrap(), 5 * 1024);
170    }
171
172    #[test]
173    fn parse_size_with_whitespace() {
174        assert_eq!(parse_size("  10mb  ").unwrap(), 10 * 1024 * 1024);
175    }
176
177    #[test]
178    fn parse_size_empty_string() {
179        assert!(parse_size("").is_err());
180    }
181
182    #[test]
183    fn parse_size_invalid() {
184        assert!(parse_size("abc").is_err());
185        assert!(parse_size("mb").is_err());
186    }
187
188    #[test]
189    fn parse_size_zero_rejected() {
190        assert!(parse_size("0mb").is_err());
191        assert!(parse_size("0").is_err());
192    }
193
194    #[test]
195    fn parse_size_overflow() {
196        assert!(parse_size("999999999999gb").is_err());
197        assert!(parse_size("99999999999999999999").is_err());
198    }
199
200    #[test]
201    fn parse_size_negative_rejected() {
202        assert!(parse_size("-1mb").is_err());
203    }
204
205    #[test]
206    fn parse_size_single_byte() {
207        assert_eq!(parse_size("1b").unwrap(), 1);
208    }
209
210    // -- size helpers --
211
212    #[test]
213    fn size_helpers() {
214        assert_eq!(kb(1), 1024);
215        assert_eq!(mb(1), 1024 * 1024);
216        assert_eq!(gb(1), 1024 * 1024 * 1024);
217        assert_eq!(mb(5), 5 * 1024 * 1024);
218    }
219
220    // -- BucketConfig validation --
221
222    #[test]
223    fn valid_config() {
224        let config = BucketConfig {
225            bucket: "test".into(),
226            endpoint: "https://s3.example.com".into(),
227            ..Default::default()
228        };
229        config.validate().unwrap();
230    }
231
232    #[test]
233    fn rejects_empty_bucket() {
234        let config = BucketConfig {
235            endpoint: "https://s3.example.com".into(),
236            ..Default::default()
237        };
238        assert!(config.validate().is_err());
239    }
240
241    #[test]
242    fn rejects_empty_endpoint() {
243        let config = BucketConfig {
244            bucket: "test".into(),
245            ..Default::default()
246        };
247        assert!(config.validate().is_err());
248    }
249
250    #[test]
251    fn rejects_invalid_max_file_size() {
252        let config = BucketConfig {
253            bucket: "test".into(),
254            endpoint: "https://s3.example.com".into(),
255            max_file_size: Some("not-a-size".into()),
256            ..Default::default()
257        };
258        assert!(config.validate().is_err());
259    }
260
261    #[test]
262    fn rejects_zero_max_file_size() {
263        let config = BucketConfig {
264            bucket: "test".into(),
265            endpoint: "https://s3.example.com".into(),
266            max_file_size: Some("0mb".into()),
267            ..Default::default()
268        };
269        assert!(config.validate().is_err());
270    }
271
272    #[test]
273    fn none_max_file_size_is_valid() {
274        let config = BucketConfig {
275            bucket: "test".into(),
276            endpoint: "https://s3.example.com".into(),
277            max_file_size: None,
278            ..Default::default()
279        };
280        config.validate().unwrap();
281    }
282
283    #[test]
284    fn normalized_public_url_strips_trailing_slash() {
285        let config = BucketConfig {
286            public_url: Some("https://cdn.example.com/".into()),
287            ..Default::default()
288        };
289        assert_eq!(
290            config.normalized_public_url(),
291            Some("https://cdn.example.com".into())
292        );
293    }
294
295    #[test]
296    fn normalized_public_url_empty_becomes_none() {
297        let config = BucketConfig {
298            public_url: Some("".into()),
299            ..Default::default()
300        };
301        assert_eq!(config.normalized_public_url(), None);
302    }
303
304    #[test]
305    fn normalized_public_url_whitespace_becomes_none() {
306        let config = BucketConfig {
307            public_url: Some("   ".into()),
308            ..Default::default()
309        };
310        assert_eq!(config.normalized_public_url(), None);
311    }
312
313    #[test]
314    fn normalized_public_url_none_stays_none() {
315        let config = BucketConfig::default();
316        assert_eq!(config.normalized_public_url(), None);
317    }
318
319    #[test]
320    fn default_path_style_is_true() {
321        let config = BucketConfig::default();
322        assert!(config.path_style);
323    }
324
325    #[test]
326    fn default_region_is_none() {
327        let config = BucketConfig::default();
328        assert!(config.region.is_none());
329    }
330}