Skip to main content

s3_wire/operation/
types.rs

1use std::{fmt, num::NonZeroU64, str::FromStr};
2
3use secrecy::{ExposeSecret, SecretString};
4
5/// Maximum encoded byte length accepted by S3 for an object key.
6pub const MAX_OBJECT_KEY_BYTES: usize = 1_024;
7
8/// A validated UTF-8 S3 object key.
9///
10/// This type deliberately has no filesystem-path or URL semantics. A slash is
11/// just another key byte and is preserved exactly.
12#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
13pub struct ObjectKey(String);
14
15impl ObjectKey {
16    /// Validates and constructs an object key.
17    pub fn new(value: impl Into<String>) -> Result<Self, ObjectKeyError> {
18        let value = value.into();
19        if value.is_empty() {
20            return Err(ObjectKeyError::Empty);
21        }
22        if value.len() > MAX_OBJECT_KEY_BYTES {
23            return Err(ObjectKeyError::TooLong {
24                actual: value.len(),
25                maximum: MAX_OBJECT_KEY_BYTES,
26            });
27        }
28        Ok(Self(value))
29    }
30
31    /// Returns the key without interpreting or decoding it.
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35
36    /// Consumes the key and returns its UTF-8 representation.
37    pub fn into_string(self) -> String {
38        self.0
39    }
40}
41
42impl fmt::Debug for ObjectKey {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        formatter.debug_tuple("ObjectKey").field(&self.0).finish()
45    }
46}
47
48impl fmt::Display for ObjectKey {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        formatter.write_str(&self.0)
51    }
52}
53
54impl AsRef<str> for ObjectKey {
55    fn as_ref(&self) -> &str {
56        self.as_str()
57    }
58}
59
60impl TryFrom<String> for ObjectKey {
61    type Error = ObjectKeyError;
62
63    fn try_from(value: String) -> Result<Self, Self::Error> {
64        Self::new(value)
65    }
66}
67
68impl TryFrom<&str> for ObjectKey {
69    type Error = ObjectKeyError;
70
71    fn try_from(value: &str) -> Result<Self, Self::Error> {
72        Self::new(value)
73    }
74}
75
76impl FromStr for ObjectKey {
77    type Err = ObjectKeyError;
78
79    fn from_str(value: &str) -> Result<Self, Self::Err> {
80        Self::new(value)
81    }
82}
83
84/// Validation failure for an [`ObjectKey`].
85#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
86pub enum ObjectKeyError {
87    /// S3 object keys cannot be empty.
88    #[error("an S3 object key cannot be empty")]
89    Empty,
90    /// The key exceeds S3's 1,024-byte limit.
91    #[error("S3 object key is {actual} bytes; the maximum is {maximum}")]
92    TooLong {
93        /// Actual UTF-8 byte length.
94        actual: usize,
95        /// Maximum accepted UTF-8 byte length.
96        maximum: usize,
97    },
98}
99
100/// A byte range for a GET request.
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub enum ByteRange {
103    /// Bytes from `start` through `end`, inclusive.
104    Inclusive {
105        /// First byte offset.
106        start: u64,
107        /// Last byte offset.
108        end: u64,
109    },
110    /// All bytes starting at the given offset.
111    From(u64),
112    /// The last non-zero number of bytes.
113    Suffix(NonZeroU64),
114}
115
116impl ByteRange {
117    /// Constructs an inclusive range, rejecting an end before its start.
118    pub fn inclusive(start: u64, end: u64) -> Result<Self, RangeError> {
119        if end < start {
120            return Err(RangeError { start, end });
121        }
122        Ok(Self::Inclusive { start, end })
123    }
124
125    /// Constructs a range extending from `start` to the end of the object.
126    pub const fn from(start: u64) -> Self {
127        Self::From(start)
128    }
129
130    /// Constructs a suffix range. Zero is rejected.
131    pub fn suffix(length: u64) -> Option<Self> {
132        NonZeroU64::new(length).map(Self::Suffix)
133    }
134
135    /// Returns the HTTP `Range` header value.
136    pub fn to_header_value(self) -> String {
137        match self {
138            Self::Inclusive { start, end } => format!("bytes={start}-{end}"),
139            Self::From(start) => format!("bytes={start}-"),
140            Self::Suffix(length) => format!("bytes=-{length}"),
141        }
142    }
143}
144
145/// Invalid inclusive byte range.
146#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
147#[error("range end {end} precedes start {start}")]
148pub struct RangeError {
149    /// First requested byte.
150    pub start: u64,
151    /// Last requested byte.
152    pub end: u64,
153}
154
155/// Conditional headers shared by object operations.
156#[derive(Clone, Debug, Default, Eq, PartialEq)]
157pub struct Conditions {
158    /// Match this entity tag before performing the operation.
159    pub if_match: Option<String>,
160    /// Perform the operation only when this entity tag does not match.
161    pub if_none_match: Option<String>,
162    /// Perform the operation only if the object changed after this instant.
163    pub if_modified_since: Option<time::OffsetDateTime>,
164    /// Perform the operation only if the object did not change after this instant.
165    pub if_unmodified_since: Option<time::OffsetDateTime>,
166}
167
168/// Checksum algorithm understood by S3 checksum headers.
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170#[non_exhaustive]
171pub enum ChecksumAlgorithm {
172    /// CRC-32.
173    Crc32,
174    /// CRC-32C.
175    Crc32c,
176    /// CRC-64/NVME.
177    Crc64Nvme,
178    /// SHA-1.
179    Sha1,
180    /// SHA-256.
181    Sha256,
182}
183
184/// How S3 derives an object checksum from multipart data.
185#[derive(Clone, Debug, Eq, PartialEq)]
186#[non_exhaustive]
187pub enum ChecksumType {
188    /// S3 combines independently calculated part checksums.
189    Composite,
190    /// The checksum covers the complete object byte sequence.
191    FullObject,
192    /// A newer aggregation mode returned by the service.
193    Unknown(String),
194}
195
196impl ChecksumType {
197    /// Returns the value used by S3 headers and XML responses.
198    pub fn as_str(&self) -> &str {
199        match self {
200            Self::Composite => "COMPOSITE",
201            Self::FullObject => "FULL_OBJECT",
202            Self::Unknown(value) => value,
203        }
204    }
205
206    pub(crate) fn parse(value: String) -> Self {
207        match value.as_str() {
208            "COMPOSITE" => Self::Composite,
209            "FULL_OBJECT" => Self::FullObject,
210            _ => Self::Unknown(value),
211        }
212    }
213}
214
215/// Base64-encoded checksums returned by S3.
216#[derive(Clone, Debug, Default, Eq, PartialEq)]
217pub struct Checksum {
218    /// Base64-encoded CRC-32 value.
219    pub crc32: Option<String>,
220    /// Base64-encoded CRC-32C value.
221    pub crc32c: Option<String>,
222    /// Base64-encoded CRC-64/NVME value.
223    pub crc64_nvme: Option<String>,
224    /// Base64-encoded SHA-1 value.
225    pub sha1: Option<String>,
226    /// Base64-encoded SHA-256 value.
227    pub sha256: Option<String>,
228}
229
230/// Identifiers supplied by an S3-compatible service for diagnostics.
231#[derive(Clone, Debug, Default, Eq, PartialEq)]
232pub struct RequestIds {
233    /// `x-amz-request-id`, when supplied.
234    pub request_id: Option<String>,
235    /// `x-amz-id-2`, when supplied.
236    pub host_id: Option<String>,
237}
238
239/// A presigned URL whose standard formatting is always redacted.
240///
241/// Callers must explicitly opt in to exposing the URL because its query string
242/// contains signing material.
243#[derive(Clone)]
244pub struct PresignedUrl(SecretString);
245
246impl PresignedUrl {
247    /// Wraps a generated presigned URL.
248    pub(crate) fn new(url: impl Into<String>) -> Self {
249        Self(url.into().into())
250    }
251
252    /// Explicitly exposes the full signed URL.
253    pub fn expose(&self) -> &str {
254        self.0.expose_secret()
255    }
256
257    /// Consumes this wrapper and explicitly exposes the full signed URL.
258    pub fn into_exposed(self) -> String {
259        self.0.expose_secret().to_owned()
260    }
261}
262
263impl fmt::Debug for PresignedUrl {
264    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
265        formatter.write_str("PresignedUrl([REDACTED])")
266    }
267}
268
269impl fmt::Display for PresignedUrl {
270    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
271        formatter.write_str("[REDACTED PRESIGNED URL]")
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use proptest::prelude::*;
279
280    #[test]
281    fn byte_ranges_render_without_off_by_one_changes() {
282        assert_eq!(
283            ByteRange::inclusive(2, 9).unwrap().to_header_value(),
284            "bytes=2-9"
285        );
286        assert_eq!(ByteRange::from(2).to_header_value(), "bytes=2-");
287        assert_eq!(ByteRange::suffix(2).unwrap().to_header_value(), "bytes=-2");
288        assert!(ByteRange::inclusive(9, 2).is_err());
289        assert!(ByteRange::suffix(0).is_none());
290    }
291
292    #[test]
293    fn presigned_url_formatting_is_redacted() {
294        let signed = PresignedUrl::new("https://example.test/key?X-Amz-Signature=secret");
295        assert!(!format!("{signed:?}").contains("secret"));
296        assert!(!signed.to_string().contains("secret"));
297        assert!(signed.expose().contains("secret"));
298    }
299
300    proptest! {
301        #[test]
302        fn valid_object_keys_round_trip(value in ".{1,300}") {
303            prop_assume!(value.len() <= MAX_OBJECT_KEY_BYTES);
304            let key = ObjectKey::new(value.clone()).unwrap();
305            prop_assert_eq!(key.into_string(), value);
306        }
307    }
308}