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/// Base64-encoded checksums returned by S3.
185#[derive(Clone, Debug, Default, Eq, PartialEq)]
186pub struct Checksum {
187    /// Base64-encoded CRC-32 value.
188    pub crc32: Option<String>,
189    /// Base64-encoded CRC-32C value.
190    pub crc32c: Option<String>,
191    /// Base64-encoded CRC-64/NVME value.
192    pub crc64_nvme: Option<String>,
193    /// Base64-encoded SHA-1 value.
194    pub sha1: Option<String>,
195    /// Base64-encoded SHA-256 value.
196    pub sha256: Option<String>,
197}
198
199/// Identifiers supplied by an S3-compatible service for diagnostics.
200#[derive(Clone, Debug, Default, Eq, PartialEq)]
201pub struct RequestIds {
202    /// `x-amz-request-id`, when supplied.
203    pub request_id: Option<String>,
204    /// `x-amz-id-2`, when supplied.
205    pub host_id: Option<String>,
206}
207
208/// A presigned URL whose standard formatting is always redacted.
209///
210/// Callers must explicitly opt in to exposing the URL because its query string
211/// contains signing material.
212#[derive(Clone)]
213pub struct PresignedUrl(SecretString);
214
215impl PresignedUrl {
216    /// Wraps a generated presigned URL.
217    pub(crate) fn new(url: impl Into<String>) -> Self {
218        Self(url.into().into())
219    }
220
221    /// Explicitly exposes the full signed URL.
222    pub fn expose(&self) -> &str {
223        self.0.expose_secret()
224    }
225
226    /// Consumes this wrapper and explicitly exposes the full signed URL.
227    pub fn into_exposed(self) -> String {
228        self.0.expose_secret().to_owned()
229    }
230}
231
232impl fmt::Debug for PresignedUrl {
233    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
234        formatter.write_str("PresignedUrl([REDACTED])")
235    }
236}
237
238impl fmt::Display for PresignedUrl {
239    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
240        formatter.write_str("[REDACTED PRESIGNED URL]")
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use proptest::prelude::*;
248
249    #[test]
250    fn byte_ranges_render_without_off_by_one_changes() {
251        assert_eq!(
252            ByteRange::inclusive(2, 9).unwrap().to_header_value(),
253            "bytes=2-9"
254        );
255        assert_eq!(ByteRange::from(2).to_header_value(), "bytes=2-");
256        assert_eq!(ByteRange::suffix(2).unwrap().to_header_value(), "bytes=-2");
257        assert!(ByteRange::inclusive(9, 2).is_err());
258        assert!(ByteRange::suffix(0).is_none());
259    }
260
261    #[test]
262    fn presigned_url_formatting_is_redacted() {
263        let signed = PresignedUrl::new("https://example.test/key?X-Amz-Signature=secret");
264        assert!(!format!("{signed:?}").contains("secret"));
265        assert!(!signed.to_string().contains("secret"));
266        assert!(signed.expose().contains("secret"));
267    }
268
269    proptest! {
270        #[test]
271        fn valid_object_keys_round_trip(value in ".{1,300}") {
272            prop_assume!(value.len() <= MAX_OBJECT_KEY_BYTES);
273            let key = ObjectKey::new(value.clone()).unwrap();
274            prop_assert_eq!(key.into_string(), value);
275        }
276    }
277}