mountpoint_s3_fs/s3/
path.rs1use std::{fmt::Display, ops::Deref};
2
3use regex::Regex;
4use thiserror::Error;
5
6use super::{Prefix, PrefixError};
7
8#[derive(Error, Debug, PartialEq)]
9pub enum S3PathError {
10 #[error("expected an S3 URI")]
11 ExpectedS3URI,
12 #[error("the bucket must have a valid name (only letters, numbers, . and -) or a valid ARN")]
13 InvalidBucketName,
14 #[error("the bucket must have a valid name (only letters, numbers, . and -). ARNs are not supported in s3:// URIs")]
15 InvalidBucketNameS3URI,
16 #[error("bucket names must be 3-255 characters long")]
17 InvalidBucketLength,
18 #[error("invalid bucket prefix: {0:}")]
19 PrefixError(#[from] PrefixError),
20}
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct Bucket(String);
24
25impl Bucket {
26 pub fn new(bucket_name: impl Into<String>) -> Result<Self, S3PathError> {
27 let bucket_name = bucket_name.into();
28 validate_bucket_length(&bucket_name)?;
29 if matches_bucket_regex(&bucket_name) || bucket_name.starts_with("arn:") {
30 Ok(Self(bucket_name))
31 } else {
32 Err(S3PathError::InvalidBucketName)
33 }
34 }
35
36 fn is_arn(&self) -> bool {
37 self.0.starts_with("arn:")
38 }
39
40 pub fn as_str(&self) -> &str {
41 &self.0
42 }
43}
44
45impl Deref for Bucket {
46 type Target = str;
47
48 fn deref(&self) -> &Self::Target {
49 &self.0
50 }
51}
52
53impl AsRef<str> for Bucket {
54 fn as_ref(&self) -> &str {
55 self
56 }
57}
58
59impl Display for Bucket {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 write!(f, "{}", self.0)
62 }
63}
64
65impl From<Bucket> for String {
66 fn from(bucket_name: Bucket) -> Self {
67 bucket_name.0
68 }
69}
70
71impl TryFrom<String> for Bucket {
72 type Error = S3PathError;
73
74 fn try_from(value: String) -> Result<Self, Self::Error> {
75 Self::new(value)
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct S3Path {
82 pub bucket: Bucket,
84
85 pub prefix: Prefix,
87}
88
89impl S3Path {
90 pub fn new(bucket: Bucket, prefix: Prefix) -> Self {
91 Self { bucket, prefix }
92 }
93
94 pub fn parse_s3_uri(s3_uri: &str) -> Result<S3Path, S3PathError> {
95 let bucket_prefix = s3_uri.strip_prefix("s3://").ok_or(S3PathError::ExpectedS3URI)?;
96 let (bucket, prefix) = {
97 if let Some((bucket, prefix_str)) = bucket_prefix.split_once("/") {
98 (bucket, prefix_str)
99 } else {
100 (bucket_prefix, "")
101 }
102 };
103 let bucket = Bucket::new(bucket.to_owned())?;
104 if bucket.is_arn() {
105 return Err(S3PathError::InvalidBucketNameS3URI);
106 }
107 Ok(S3Path {
108 bucket,
109 prefix: Prefix::new(prefix)?,
110 })
111 }
112
113 pub fn bucket_description(&self) -> String {
114 if self.prefix.as_str().is_empty() {
115 format!("bucket {}", self.bucket)
116 } else {
117 format!("prefix {} of bucket {}", self.prefix, self.bucket)
118 }
119 }
120}
121
122fn validate_bucket_length(bucket_name: &str) -> Result<(), S3PathError> {
123 if bucket_name.len() < 3 || bucket_name.len() > 255 {
124 Err(S3PathError::InvalidBucketLength)
125 } else {
126 Ok(())
127 }
128}
129
130fn matches_bucket_regex(bucket_name: &str) -> bool {
131 let bucket_regex = Regex::new(r"^[0-9a-zA-Z\-\._]+$").unwrap();
134 bucket_regex.is_match(bucket_name)
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use test_case::test_case;
141
142 const VALID: bool = true;
143 const INVALID: bool = false;
144
145 #[test_case("test-bucket", VALID; "simple bucket")]
146 #[test_case("test-123.buc_ket", VALID; "bucket name with .")]
147 #[test_case("my-access-point-hrzrlukc5m36ft7okagglf3gmwluquse1b-s3alias", VALID; "access point alias")]
148 #[test_case("my-object-lambda-acc-1a4n8yjrb3kda96f67zwrwiiuse1a--ol-s3", VALID; "object lambda access point alias")]
149 #[test_case("s3://test-bucket", INVALID; "s3 uris not allowed for validate_bucket_name")]
150 #[test_case("~/mnt", INVALID; "directory name in place of bucket")]
151 #[test_case("arn:aws:s3::00000000:accesspoint/s3-bucket-test.mrap", VALID; "multiregion accesspoint ARN")]
152 #[test_case("arn:aws:s3:::amzn-s3-demo-bucket", VALID; "bucket ARN(maybe rejected by endpoint resolver with error message)")]
153 #[test_case("arn:aws-cn:s3:cn-north-2:555555555555:accesspoint/china-region-ap", VALID; "standard accesspoint ARN in China")]
154 #[test_case("arn:aws-us-gov:s3-object-lambda:us-gov-west-1:555555555555:accesspoint/example-olap", VALID; "S3 object lambda accesspoint in US Gov")]
155 #[test_case("arn:aws:s3-outposts:us-east-1:555555555555:outpost/outpost-id/accesspoint/accesspoint-name", VALID; "S3 outpost accesspoint ARN")]
156 fn validate_from_bucket(bucket_name: &str, valid: bool) {
157 let parsed = Bucket::new(bucket_name.to_owned()).map(|bucket_name| S3Path::new(bucket_name, Prefix::empty()));
158 if valid {
159 let s3_path = parsed.expect("valid bucket name");
160 assert_eq!(s3_path.bucket.as_str(), bucket_name);
161 assert_eq!(s3_path.prefix.as_str(), "");
162 } else {
163 parsed.expect_err("invalid bucket name");
164 }
165 }
166
167 #[test_case("s3://test-bucket", "", VALID; "s3 uris allowed")]
168 #[test_case("s3://test-bucket/", "", VALID; "s3 uris allowed with trailing /")]
169 #[test_case("s3://test-bucket/prefix/", "prefix/", VALID; "s3 uris allowed with prefixes ending in /")]
170 #[test_case("s3://a", "", INVALID; "too short")]
171 #[test_case("s3://[][][][]", "", INVALID; "invalid bucket name")]
172 #[test_case("test-bucket", "", INVALID; "only s3 uris allowed")]
173 #[test_case("s3://test-bucket/foo", "", INVALID; "prefixes must end in /")]
174 #[test_case("s3://arn:aws:s3:::amzn-s3-demo-bucket", "", INVALID; "ARNs not allowed in S3 URIs")]
175 fn validate_from_s3_uri(bucket_name: &str, prefix: &str, valid: bool) {
176 let parsed = S3Path::parse_s3_uri(bucket_name);
177 if valid {
178 let expected = bucket_name.strip_prefix("s3://").unwrap();
179 let expected_bucket = expected
180 .split_once("/")
181 .map(|(bucket, _prefix)| bucket)
182 .unwrap_or(expected);
183 let s3_uri = parsed.expect("valid bucket name");
184 assert_eq!(s3_uri.bucket.as_str(), expected_bucket);
185 assert_eq!(s3_uri.prefix.as_str(), prefix)
186 } else {
187 parsed.expect_err("invalid bucket name");
188 }
189 }
190}