rustack_s3_core/
validation.rs1use std::{collections::HashMap, hash::BuildHasher, net::Ipv4Addr};
8
9use base64::Engine;
10use md5::{Digest, Md5};
11
12use crate::error::S3ServiceError;
13
14const MAX_TAGS: usize = 10;
16
17const MAX_TAG_KEY_LEN: usize = 128;
19
20const MAX_TAG_VALUE_LEN: usize = 256;
22
23const MAX_METADATA_SIZE: usize = 2048;
25
26const MAX_KEY_BYTES: usize = 1024;
28
29const MIN_BUCKET_NAME_LEN: usize = 3;
31
32const MAX_BUCKET_NAME_LEN: usize = 63;
34
35pub 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
132pub 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
165pub 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
199pub 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
228pub 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
268pub 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
304pub 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 #[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 #[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 #[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 #[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 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 meta.insert("key".to_owned(), "v".repeat(2045));
531 assert!(validate_metadata(&meta).is_ok());
532 }
533
534 #[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}