Skip to main content

mountpoint_s3_fs/s3/
path.rs

1use 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/// A bucket & prefix combination.
80#[derive(Debug, Clone)]
81pub struct S3Path {
82    /// Name of bucket
83    pub bucket: Bucket,
84
85    /// Prefix inside the bucket
86    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    // Actual bucket names must start/end with a letter, but bucket aliases can end with numbers
132    // (-s3), so let's just naively check for invalid characters.
133    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}