@@ -403,6 +403,113 @@ impl StreamingUploader for S3StreamingUploader {
403403 }
404404}
405405
406+ /// Validates an S3 bucket name against the AWS bucket naming rules
407+ /// (<https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html>),
408+ /// so that a misconfigured bucket name is rejected with a clear error message
409+ /// instead of surfacing as an opaque request error at first access.
410+ pub fn validate_s3_bucket_name ( bucket : & str ) -> Result < ( ) , String > {
411+ let len = bucket. len ( ) ;
412+ if !( 3 ..=63 ) . contains ( & len) {
413+ return Err ( format ! (
414+ "invalid S3 bucket name {bucket:?}: length must be between 3 and 63 characters, got {len}"
415+ ) ) ;
416+ }
417+ if bucket
418+ . parse :: < std:: net:: Ipv4Addr > ( )
419+ . is_ok_and ( |_| bucket. split ( '.' ) . count ( ) == 4 )
420+ {
421+ return Err ( format ! (
422+ "invalid S3 bucket name {bucket:?}: must not be formatted as an IP address"
423+ ) ) ;
424+ }
425+ let is_valid_char =
426+ |c : char | c. is_ascii_lowercase ( ) || c. is_ascii_digit ( ) || c == '-' || c == '.' ;
427+ if !bucket. chars ( ) . all ( is_valid_char) {
428+ return Err ( format ! (
429+ "invalid S3 bucket name {bucket:?}: only lowercase letters, numbers, dots and hyphens are allowed"
430+ ) ) ;
431+ }
432+ let starts_ends_alnum = bucket
433+ . chars ( )
434+ . next ( )
435+ . is_some_and ( |c| c. is_ascii_lowercase ( ) || c. is_ascii_digit ( ) )
436+ && bucket
437+ . chars ( )
438+ . next_back ( )
439+ . is_some_and ( |c| c. is_ascii_lowercase ( ) || c. is_ascii_digit ( ) ) ;
440+ if !starts_ends_alnum {
441+ return Err ( format ! (
442+ "invalid S3 bucket name {bucket:?}: must start and end with a lowercase letter or number"
443+ ) ) ;
444+ }
445+ if bucket. contains ( ".." ) {
446+ return Err ( format ! (
447+ "invalid S3 bucket name {bucket:?}: must not contain two adjacent periods"
448+ ) ) ;
449+ }
450+ if bucket. starts_with ( "xn--" ) || bucket. ends_with ( "-s3alias" ) || bucket. ends_with ( "--ol-s3" ) {
451+ return Err ( format ! (
452+ "invalid S3 bucket name {bucket:?}: must not start with the prefix \" xn--\" or end with the suffix \" -s3alias\" or \" --ol-s3\" "
453+ ) ) ;
454+ }
455+ Ok ( ( ) )
456+ }
457+
458+ #[ cfg( test) ]
459+ mod validate_s3_bucket_name_tests {
460+ use super :: validate_s3_bucket_name;
461+
462+ #[ test]
463+ fn test_valid_bucket_names ( ) {
464+ assert ! ( validate_s3_bucket_name( "rw-data" ) . is_ok( ) ) ;
465+ assert ! ( validate_s3_bucket_name( "my.bucket.123" ) . is_ok( ) ) ;
466+ assert ! ( validate_s3_bucket_name( "abc" ) . is_ok( ) ) ;
467+ assert ! ( validate_s3_bucket_name( & "a" . repeat( 63 ) ) . is_ok( ) ) ;
468+ }
469+
470+ #[ test]
471+ fn test_rejects_underscore ( ) {
472+ // e.g. issue #20263: `rw_data` is not a valid bucket name.
473+ let err = validate_s3_bucket_name ( "rw_data" ) . unwrap_err ( ) ;
474+ assert ! ( err. contains( "lowercase letters, numbers, dots and hyphens" ) ) ;
475+ }
476+
477+ #[ test]
478+ fn test_rejects_bad_length ( ) {
479+ assert ! ( validate_s3_bucket_name( "ab" ) . is_err( ) ) ;
480+ assert ! ( validate_s3_bucket_name( & "a" . repeat( 64 ) ) . is_err( ) ) ;
481+ }
482+
483+ #[ test]
484+ fn test_rejects_uppercase ( ) {
485+ assert ! ( validate_s3_bucket_name( "MyBucket" ) . is_err( ) ) ;
486+ }
487+
488+ #[ test]
489+ fn test_rejects_ip_address ( ) {
490+ assert ! ( validate_s3_bucket_name( "192.168.1.1" ) . is_err( ) ) ;
491+ }
492+
493+ #[ test]
494+ fn test_rejects_bad_start_end ( ) {
495+ assert ! ( validate_s3_bucket_name( "-mybucket" ) . is_err( ) ) ;
496+ assert ! ( validate_s3_bucket_name( "mybucket-" ) . is_err( ) ) ;
497+ assert ! ( validate_s3_bucket_name( ".mybucket" ) . is_err( ) ) ;
498+ }
499+
500+ #[ test]
501+ fn test_rejects_adjacent_periods ( ) {
502+ assert ! ( validate_s3_bucket_name( "my..bucket" ) . is_err( ) ) ;
503+ }
504+
505+ #[ test]
506+ fn test_rejects_reserved_prefix_suffix ( ) {
507+ assert ! ( validate_s3_bucket_name( "xn--bucket" ) . is_err( ) ) ;
508+ assert ! ( validate_s3_bucket_name( "mybucket-s3alias" ) . is_err( ) ) ;
509+ assert ! ( validate_s3_bucket_name( "mybucket--ol-s3" ) . is_err( ) ) ;
510+ }
511+ }
512+
406513fn get_upload_body ( data : Vec < Bytes > ) -> ByteStream {
407514 // `ByteStream` is retryable when created from in-memory data.
408515 // This code path is used for non-multipart uploads, so a copy is acceptable.
0 commit comments