1use serde::{Deserialize, Serialize};
2
3use crate::error::{Error, Result};
4
5#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
10pub struct S3Object {
11 pub bucket: String,
13 pub key: String,
15}
16
17impl S3Object {
18 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 pub fn uri(&self) -> String {
34 format!("s3://{}/{}", self.bucket, self.key)
35 }
36}
37
38#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
42pub struct S3Prefix {
43 pub bucket: String,
45 pub prefix: String,
47}
48
49impl S3Prefix {
50 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 pub fn join_key(&self, zip_path: &str) -> String {
60 join_destination_key(&self.prefix, zip_path)
61 }
62
63 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}