Skip to main content

s3_unspool/
s3_uri.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{Error, Result};
4
5/// Parsed S3 object URI.
6///
7/// Use [`S3Object::parse`] to construct an object from an `s3://bucket/key`
8/// string.
9#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
10pub struct S3Object {
11    /// S3 bucket name.
12    pub bucket: String,
13    /// S3 object key.
14    pub key: String,
15}
16
17impl S3Object {
18    /// Parses an object URI such as `s3://my-bucket/path/archive.zip`.
19    pub fn parse(uri: impl AsRef<str>) -> Result<Self> {
20        let uri = uri.as_ref();
21        let (bucket, key) = parse_s3_parts(uri, true)?;
22        let key = key.trim_start_matches('/').to_string();
23        if key.is_empty() {
24            return Err(Error::InvalidS3Uri {
25                uri: uri.to_string(),
26                reason: "missing object key".to_string(),
27            });
28        }
29        Ok(Self { bucket, key })
30    }
31
32    /// Formats this object as an `s3://bucket/key` URI.
33    pub fn uri(&self) -> String {
34        format!("s3://{}/{}", self.bucket, self.key)
35    }
36}
37
38/// Parsed S3 destination prefix URI.
39///
40/// Prefixes may be empty, for example `s3://my-bucket`.
41#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
42pub struct S3Prefix {
43    /// S3 bucket name.
44    pub bucket: String,
45    /// Destination key prefix, without a leading slash.
46    pub prefix: String,
47}
48
49impl S3Prefix {
50    /// Parses a prefix URI such as `s3://my-bucket/site/`.
51    pub fn parse(uri: impl AsRef<str>) -> Result<Self> {
52        let uri = uri.as_ref();
53        let (bucket, prefix) = parse_s3_parts(uri, false)?;
54        let prefix = prefix.trim_start_matches('/').to_string();
55        Ok(Self { bucket, prefix })
56    }
57
58    /// Joins a normalized ZIP path onto this prefix.
59    pub fn join_key(&self, zip_path: &str) -> String {
60        join_destination_key(&self.prefix, zip_path)
61    }
62
63    /// Formats this prefix as an `s3://bucket/prefix` URI.
64    pub fn uri(&self) -> String {
65        if self.prefix.is_empty() {
66            format!("s3://{}", self.bucket)
67        } else {
68            format!("s3://{}/{}", self.bucket, self.prefix)
69        }
70    }
71}
72
73fn parse_s3_parts(uri: &str, require_key: bool) -> Result<(String, String)> {
74    let without_scheme = uri
75        .strip_prefix("s3://")
76        .ok_or_else(|| Error::InvalidS3Uri {
77            uri: uri.to_string(),
78            reason: "missing s3:// scheme".to_string(),
79        })?;
80
81    let (bucket, key) = match without_scheme.split_once('/') {
82        Some((bucket, key)) => (bucket, key),
83        None if require_key => {
84            return Err(Error::InvalidS3Uri {
85                uri: uri.to_string(),
86                reason: "missing object key".to_string(),
87            });
88        }
89        None => (without_scheme, ""),
90    };
91
92    if bucket.is_empty() {
93        return Err(Error::InvalidS3Uri {
94            uri: uri.to_string(),
95            reason: "missing bucket".to_string(),
96        });
97    }
98
99    if require_key && key.is_empty() {
100        return Err(Error::InvalidS3Uri {
101            uri: uri.to_string(),
102            reason: "missing object key".to_string(),
103        });
104    }
105
106    Ok((bucket.to_string(), key.to_string()))
107}
108
109pub(crate) fn join_destination_key(prefix: &str, zip_path: &str) -> String {
110    if prefix.is_empty() {
111        zip_path.to_string()
112    } else if prefix.ends_with('/') {
113        format!("{prefix}{zip_path}")
114    } else {
115        format!("{prefix}/{zip_path}")
116    }
117}
118
119pub(crate) fn normalize_etag(etag: &str) -> Option<String> {
120    let trimmed = etag.trim().trim_matches('"').to_ascii_lowercase();
121    if trimmed.len() == 32 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
122        Some(trimmed)
123    } else {
124        None
125    }
126}