Skip to main content

rustack_s3_core/
validation.rs

1//! Validation for S3 requests.
2//!
3//! Provides validation functions for bucket names, object keys, tags, and
4//! user-defined metadata following the rules defined in the
5//! [Amazon S3 documentation](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html).
6
7use std::{collections::HashMap, hash::BuildHasher, net::Ipv4Addr};
8
9use base64::Engine;
10use md5::{Digest, Md5};
11
12use crate::error::S3ServiceError;
13
14/// Maximum number of tags allowed on a single S3 object or bucket.
15const MAX_TAGS: usize = 10;
16
17/// Maximum length of a tag key in characters.
18const MAX_TAG_KEY_LEN: usize = 128;
19
20/// Maximum length of a tag value in characters.
21const MAX_TAG_VALUE_LEN: usize = 256;
22
23/// Maximum total size (in bytes) of all user-defined metadata keys and values.
24const MAX_METADATA_SIZE: usize = 2048;
25
26/// Maximum object key length in bytes.
27const MAX_KEY_BYTES: usize = 1024;
28
29/// Minimum bucket name length.
30const MIN_BUCKET_NAME_LEN: usize = 3;
31
32/// Maximum bucket name length.
33const MAX_BUCKET_NAME_LEN: usize = 63;
34
35/// Validate an S3 bucket name.
36///
37/// Rules (per AWS documentation):
38/// - 3-63 characters long
39/// - Only lowercase letters, numbers, hyphens, and dots
40/// - Must start and end with a letter or number
41/// - No consecutive dots (`..`)
42/// - Not formatted as an IPv4 address (e.g. `192.168.0.1`)
43/// - Must not start with `xn--`
44/// - Must not end with `-s3alias`
45/// - Must not start with `sthree-`
46///
47/// # Errors
48///
49/// Returns [`S3ServiceError::InvalidBucketName`] if any rule is violated.
50///
51/// # Examples
52///
53/// ```
54/// use rustack_s3_core::validation::validate_bucket_name;
55///
56/// assert!(validate_bucket_name("my-valid-bucket").is_ok());
57/// assert!(validate_bucket_name("AB").is_err());
58/// ```
59pub fn validate_bucket_name(name: &str) -> Result<(), S3ServiceError> {
60    let len = name.len();
61
62    if !(MIN_BUCKET_NAME_LEN..=MAX_BUCKET_NAME_LEN).contains(&len) {
63        return Err(S3ServiceError::InvalidBucketName {
64            name: name.to_owned(),
65            reason: format!(
66                "Bucket name must be between {MIN_BUCKET_NAME_LEN} and {MAX_BUCKET_NAME_LEN} \
67                 characters long"
68            ),
69        });
70    }
71
72    if !name
73        .bytes()
74        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'.')
75    {
76        return Err(S3ServiceError::InvalidBucketName {
77            name: name.to_owned(),
78            reason: "Bucket name must only contain lowercase letters, numbers, hyphens, and dots"
79                .to_owned(),
80        });
81    }
82
83    let first = name.as_bytes()[0];
84    let last = name.as_bytes()[len - 1];
85    if !(first.is_ascii_lowercase() || first.is_ascii_digit())
86        || !(last.is_ascii_lowercase() || last.is_ascii_digit())
87    {
88        return Err(S3ServiceError::InvalidBucketName {
89            name: name.to_owned(),
90            reason: "Bucket name must start and end with a letter or number".to_owned(),
91        });
92    }
93
94    if name.contains("..") {
95        return Err(S3ServiceError::InvalidBucketName {
96            name: name.to_owned(),
97            reason: "Bucket name must not contain consecutive dots".to_owned(),
98        });
99    }
100
101    if name.parse::<Ipv4Addr>().is_ok() {
102        return Err(S3ServiceError::InvalidBucketName {
103            name: name.to_owned(),
104            reason: "Bucket name must not be formatted as an IP address".to_owned(),
105        });
106    }
107
108    if name.starts_with("xn--") {
109        return Err(S3ServiceError::InvalidBucketName {
110            name: name.to_owned(),
111            reason: "Bucket name must not start with 'xn--'".to_owned(),
112        });
113    }
114
115    if name.ends_with("-s3alias") {
116        return Err(S3ServiceError::InvalidBucketName {
117            name: name.to_owned(),
118            reason: "Bucket name must not end with '-s3alias'".to_owned(),
119        });
120    }
121
122    if name.starts_with("sthree-") {
123        return Err(S3ServiceError::InvalidBucketName {
124            name: name.to_owned(),
125            reason: "Bucket name must not start with 'sthree-'".to_owned(),
126        });
127    }
128
129    Ok(())
130}
131
132/// Validate an S3 object key.
133///
134/// Rules:
135/// - 1-1024 bytes in length
136/// - Must be valid UTF-8 (enforced by the `&str` type)
137///
138/// # Errors
139///
140/// Returns [`S3ServiceError::InvalidArgument`] if the key is empty or exceeds
141/// 1024 bytes, or [`S3ServiceError::KeyTooLong`] if the key is too long.
142///
143/// # Examples
144///
145/// ```
146/// use rustack_s3_core::validation::validate_object_key;
147///
148/// assert!(validate_object_key("photos/2024/image.jpg").is_ok());
149/// assert!(validate_object_key("").is_err());
150/// ```
151pub fn validate_object_key(key: &str) -> Result<(), S3ServiceError> {
152    if key.is_empty() {
153        return Err(S3ServiceError::InvalidArgument {
154            message: "Object key must not be empty".to_owned(),
155        });
156    }
157
158    if key.len() > MAX_KEY_BYTES {
159        return Err(S3ServiceError::KeyTooLong);
160    }
161
162    Ok(())
163}
164
165/// Validate a tag key.
166///
167/// Rules:
168/// - 1-128 characters in length
169///
170/// # Errors
171///
172/// Returns [`S3ServiceError::InvalidTag`] if the key is empty or too long.
173///
174/// # Examples
175///
176/// ```
177/// use rustack_s3_core::validation::validate_tag_key;
178///
179/// assert!(validate_tag_key("environment").is_ok());
180/// assert!(validate_tag_key("").is_err());
181/// ```
182pub fn validate_tag_key(key: &str) -> Result<(), S3ServiceError> {
183    if key.is_empty() {
184        return Err(S3ServiceError::InvalidTag {
185            message: "Tag key must not be empty".to_owned(),
186        });
187    }
188    if key.chars().count() > MAX_TAG_KEY_LEN {
189        return Err(S3ServiceError::InvalidTag {
190            message: format!(
191                "Tag key must not exceed {MAX_TAG_KEY_LEN} characters, got {}",
192                key.chars().count()
193            ),
194        });
195    }
196    Ok(())
197}
198
199/// Validate a tag value.
200///
201/// Rules:
202/// - 0-256 characters in length (empty values are allowed)
203///
204/// # Errors
205///
206/// Returns [`S3ServiceError::InvalidTag`] if the value exceeds 256 characters.
207///
208/// # Examples
209///
210/// ```
211/// use rustack_s3_core::validation::validate_tag_value;
212///
213/// assert!(validate_tag_value("production").is_ok());
214/// assert!(validate_tag_value("").is_ok());
215/// ```
216pub fn validate_tag_value(value: &str) -> Result<(), S3ServiceError> {
217    if value.chars().count() > MAX_TAG_VALUE_LEN {
218        return Err(S3ServiceError::InvalidTag {
219            message: format!(
220                "Tag value must not exceed {MAX_TAG_VALUE_LEN} characters, got {}",
221                value.chars().count()
222            ),
223        });
224    }
225    Ok(())
226}
227
228/// Validate a set of tags.
229///
230/// Rules:
231/// - Maximum of 10 tags
232/// - Each key must be 1-128 characters
233/// - Each value must be 0-256 characters
234///
235/// # Errors
236///
237/// Returns [`S3ServiceError::InvalidTag`] if any rule is violated.
238///
239/// # Examples
240///
241/// ```
242/// use rustack_s3_core::validation::validate_tags;
243///
244/// let tags = vec![
245///     ("env".to_owned(), "prod".to_owned()),
246///     ("team".to_owned(), "backend".to_owned()),
247/// ];
248/// assert!(validate_tags(&tags).is_ok());
249/// ```
250pub fn validate_tags(tags: &[(String, String)]) -> Result<(), S3ServiceError> {
251    if tags.len() > MAX_TAGS {
252        return Err(S3ServiceError::InvalidTag {
253            message: format!(
254                "Object tags cannot be greater than {MAX_TAGS}, got {}",
255                tags.len()
256            ),
257        });
258    }
259
260    for (key, value) in tags {
261        validate_tag_key(key)?;
262        validate_tag_value(value)?;
263    }
264
265    Ok(())
266}
267
268/// Validate user-defined metadata.
269///
270/// Rules:
271/// - Total size of all keys plus all values must not exceed 2 KB (2048 bytes)
272///
273/// # Errors
274///
275/// Returns [`S3ServiceError::InvalidArgument`] if the total metadata size
276/// exceeds the limit.
277///
278/// # Examples
279///
280/// ```
281/// use std::collections::HashMap;
282/// use rustack_s3_core::validation::validate_metadata;
283///
284/// let mut meta = HashMap::new();
285/// meta.insert("color".to_owned(), "blue".to_owned());
286/// assert!(validate_metadata(&meta).is_ok());
287/// ```
288pub fn validate_metadata<S: BuildHasher>(
289    metadata: &HashMap<String, String, S>,
290) -> Result<(), S3ServiceError> {
291    let total_size: usize = metadata.iter().map(|(k, v)| k.len() + v.len()).sum();
292
293    if total_size > MAX_METADATA_SIZE {
294        return Err(S3ServiceError::InvalidArgument {
295            message: format!(
296                "User-defined metadata must not exceed {MAX_METADATA_SIZE} bytes, got {total_size}"
297            ),
298        });
299    }
300
301    Ok(())
302}
303
304/// Validate the `Content-MD5` header against the request body.
305///
306/// If the header is present, its value must be a valid Base64-encoded MD5
307/// digest that matches the body. If the header is absent, validation
308/// succeeds (the header is optional).
309///
310/// # Errors
311///
312/// Returns [`S3ServiceError::InvalidDigest`] if the header value is not
313/// valid Base64, or [`S3ServiceError::BadDigest`] if the decoded digest
314/// does not match the body.
315///
316/// # Examples
317///
318/// ```
319/// use rustack_s3_core::validation::validate_content_md5;
320///
321/// // No header → always OK
322/// assert!(validate_content_md5(None, b"hello").is_ok());
323/// ```
324pub fn validate_content_md5(content_md5: Option<&str>, body: &[u8]) -> Result<(), S3ServiceError> {
325    let Some(expected_b64) = content_md5 else {
326        return Ok(());
327    };
328
329    let expected_bytes = base64::engine::general_purpose::STANDARD
330        .decode(expected_b64)
331        .map_err(|_| S3ServiceError::InvalidDigest)?;
332
333    let actual = Md5::digest(body);
334    if actual.as_slice() != expected_bytes {
335        return Err(S3ServiceError::BadDigest);
336    }
337
338    Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    // -----------------------------------------------------------------------
346    // Bucket name validation
347    // -----------------------------------------------------------------------
348
349    #[test]
350    fn test_should_accept_valid_bucket_names() {
351        let long_name = "a".repeat(63);
352        let valid = [
353            "my-bucket",
354            "abc",
355            "a-b-c",
356            "bucket.with.dots",
357            "123bucket",
358            "bucket123",
359            long_name.as_str(),
360        ];
361        for name in valid {
362            assert!(validate_bucket_name(name).is_ok(), "expected valid: {name}");
363        }
364    }
365
366    #[test]
367    fn test_should_reject_short_bucket_name() {
368        assert!(validate_bucket_name("ab").is_err());
369        assert!(validate_bucket_name("a").is_err());
370        assert!(validate_bucket_name("").is_err());
371    }
372
373    #[test]
374    fn test_should_reject_long_bucket_name() {
375        let name = "a".repeat(64);
376        assert!(validate_bucket_name(&name).is_err());
377    }
378
379    #[test]
380    fn test_should_reject_uppercase_bucket_name() {
381        assert!(validate_bucket_name("MyBucket").is_err());
382    }
383
384    #[test]
385    fn test_should_reject_bucket_starting_with_hyphen() {
386        assert!(validate_bucket_name("-bucket").is_err());
387    }
388
389    #[test]
390    fn test_should_reject_bucket_ending_with_hyphen() {
391        assert!(validate_bucket_name("bucket-").is_err());
392    }
393
394    #[test]
395    fn test_should_reject_consecutive_dots_in_bucket_name() {
396        assert!(validate_bucket_name("my..bucket").is_err());
397    }
398
399    #[test]
400    fn test_should_reject_ip_address_bucket_name() {
401        assert!(validate_bucket_name("192.168.1.1").is_err());
402    }
403
404    #[test]
405    fn test_should_reject_xn_prefix_bucket_name() {
406        assert!(validate_bucket_name("xn--example").is_err());
407    }
408
409    #[test]
410    fn test_should_reject_s3alias_suffix_bucket_name() {
411        assert!(validate_bucket_name("mybucket-s3alias").is_err());
412    }
413
414    #[test]
415    fn test_should_reject_sthree_prefix_bucket_name() {
416        assert!(validate_bucket_name("sthree-bucket").is_err());
417    }
418
419    // -----------------------------------------------------------------------
420    // Object key validation
421    // -----------------------------------------------------------------------
422
423    #[test]
424    fn test_should_accept_valid_object_keys() {
425        assert!(validate_object_key("a").is_ok());
426        assert!(validate_object_key("photos/2024/image.jpg").is_ok());
427        assert!(validate_object_key(&"k".repeat(1024)).is_ok());
428    }
429
430    #[test]
431    fn test_should_reject_empty_object_key() {
432        assert!(validate_object_key("").is_err());
433    }
434
435    #[test]
436    fn test_should_reject_too_long_object_key() {
437        let key = "k".repeat(1025);
438        assert!(validate_object_key(&key).is_err());
439    }
440
441    // -----------------------------------------------------------------------
442    // Tag validation
443    // -----------------------------------------------------------------------
444
445    #[test]
446    fn test_should_accept_valid_tag_key() {
447        assert!(validate_tag_key("environment").is_ok());
448        assert!(validate_tag_key(&"k".repeat(128)).is_ok());
449    }
450
451    #[test]
452    fn test_should_reject_empty_tag_key() {
453        assert!(validate_tag_key("").is_err());
454    }
455
456    #[test]
457    fn test_should_reject_too_long_tag_key() {
458        assert!(validate_tag_key(&"k".repeat(129)).is_err());
459    }
460
461    #[test]
462    fn test_should_accept_valid_tag_value() {
463        assert!(validate_tag_value("").is_ok());
464        assert!(validate_tag_value("production").is_ok());
465        assert!(validate_tag_value(&"v".repeat(256)).is_ok());
466    }
467
468    #[test]
469    fn test_should_reject_too_long_tag_value() {
470        assert!(validate_tag_value(&"v".repeat(257)).is_err());
471    }
472
473    #[test]
474    fn test_should_accept_valid_tag_set() {
475        let tags: Vec<(String, String)> = (0..10)
476            .map(|i| (format!("key{i}"), format!("val{i}")))
477            .collect();
478        assert!(validate_tags(&tags).is_ok());
479    }
480
481    #[test]
482    fn test_should_reject_too_many_tags() {
483        let tags: Vec<(String, String)> = (0..11)
484            .map(|i| (format!("key{i}"), format!("val{i}")))
485            .collect();
486        assert!(validate_tags(&tags).is_err());
487    }
488
489    #[test]
490    fn test_should_reject_tags_with_invalid_key() {
491        let tags = vec![(String::new(), "value".to_owned())];
492        assert!(validate_tags(&tags).is_err());
493    }
494
495    #[test]
496    fn test_should_reject_tags_with_invalid_value() {
497        let tags = vec![("key".to_owned(), "v".repeat(257))];
498        assert!(validate_tags(&tags).is_err());
499    }
500
501    // -----------------------------------------------------------------------
502    // Metadata validation
503    // -----------------------------------------------------------------------
504
505    #[test]
506    fn test_should_accept_valid_metadata() {
507        let mut meta = HashMap::new();
508        meta.insert("color".to_owned(), "blue".to_owned());
509        assert!(validate_metadata(&meta).is_ok());
510    }
511
512    #[test]
513    fn test_should_accept_empty_metadata() {
514        let meta = HashMap::new();
515        assert!(validate_metadata(&meta).is_ok());
516    }
517
518    #[test]
519    fn test_should_reject_oversized_metadata() {
520        let mut meta = HashMap::new();
521        // Single entry that exceeds 2 KB
522        meta.insert("key".to_owned(), "v".repeat(2048));
523        assert!(validate_metadata(&meta).is_err());
524    }
525
526    #[test]
527    fn test_should_accept_metadata_at_limit() {
528        let mut meta = HashMap::new();
529        // key (3 bytes) + value (2045 bytes) = 2048
530        meta.insert("key".to_owned(), "v".repeat(2045));
531        assert!(validate_metadata(&meta).is_ok());
532    }
533
534    // -----------------------------------------------------------------------
535    // Content-MD5 validation
536    // -----------------------------------------------------------------------
537
538    #[test]
539    fn test_should_accept_absent_content_md5() {
540        assert!(validate_content_md5(None, b"any body").is_ok());
541    }
542
543    #[test]
544    fn test_should_accept_correct_content_md5() {
545        let body = b"hello world";
546        let digest = base64::engine::general_purpose::STANDARD.encode(Md5::digest(body));
547        assert!(validate_content_md5(Some(&digest), body).is_ok());
548    }
549
550    #[test]
551    fn test_should_reject_wrong_content_md5() {
552        let body = b"hello world";
553        let wrong = base64::engine::general_purpose::STANDARD.encode(Md5::digest(b"wrong"));
554        assert!(matches!(
555            validate_content_md5(Some(&wrong), body),
556            Err(S3ServiceError::BadDigest)
557        ));
558    }
559
560    #[test]
561    fn test_should_reject_invalid_base64_content_md5() {
562        assert!(matches!(
563            validate_content_md5(Some("not-valid-base64!!!"), b"body"),
564            Err(S3ServiceError::InvalidDigest)
565        ));
566    }
567}