use std::{fmt, num::NonZeroU64, str::FromStr};
use secrecy::{ExposeSecret, SecretString};
pub const MAX_OBJECT_KEY_BYTES: usize = 1_024;
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ObjectKey(String);
impl ObjectKey {
pub fn new(value: impl Into<String>) -> Result<Self, ObjectKeyError> {
let value = value.into();
if value.is_empty() {
return Err(ObjectKeyError::Empty);
}
if value.len() > MAX_OBJECT_KEY_BYTES {
return Err(ObjectKeyError::TooLong {
actual: value.len(),
maximum: MAX_OBJECT_KEY_BYTES,
});
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl fmt::Debug for ObjectKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_tuple("ObjectKey").field(&self.0).finish()
}
}
impl fmt::Display for ObjectKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl AsRef<str> for ObjectKey {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl TryFrom<String> for ObjectKey {
type Error = ObjectKeyError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for ObjectKey {
type Error = ObjectKeyError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl FromStr for ObjectKey {
type Err = ObjectKeyError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum ObjectKeyError {
#[error("an S3 object key cannot be empty")]
Empty,
#[error("S3 object key is {actual} bytes; the maximum is {maximum}")]
TooLong {
actual: usize,
maximum: usize,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ByteRange {
Inclusive {
start: u64,
end: u64,
},
From(u64),
Suffix(NonZeroU64),
}
impl ByteRange {
pub fn inclusive(start: u64, end: u64) -> Result<Self, RangeError> {
if end < start {
return Err(RangeError { start, end });
}
Ok(Self::Inclusive { start, end })
}
pub const fn from(start: u64) -> Self {
Self::From(start)
}
pub fn suffix(length: u64) -> Option<Self> {
NonZeroU64::new(length).map(Self::Suffix)
}
pub fn to_header_value(self) -> String {
match self {
Self::Inclusive { start, end } => format!("bytes={start}-{end}"),
Self::From(start) => format!("bytes={start}-"),
Self::Suffix(length) => format!("bytes=-{length}"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
#[error("range end {end} precedes start {start}")]
pub struct RangeError {
pub start: u64,
pub end: u64,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Conditions {
pub if_match: Option<String>,
pub if_none_match: Option<String>,
pub if_modified_since: Option<time::OffsetDateTime>,
pub if_unmodified_since: Option<time::OffsetDateTime>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ChecksumAlgorithm {
Crc32,
Crc32c,
Crc64Nvme,
Sha1,
Sha256,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Checksum {
pub crc32: Option<String>,
pub crc32c: Option<String>,
pub crc64_nvme: Option<String>,
pub sha1: Option<String>,
pub sha256: Option<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RequestIds {
pub request_id: Option<String>,
pub host_id: Option<String>,
}
#[derive(Clone)]
pub struct PresignedUrl(SecretString);
impl PresignedUrl {
pub(crate) fn new(url: impl Into<String>) -> Self {
Self(url.into().into())
}
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
pub fn into_exposed(self) -> String {
self.0.expose_secret().to_owned()
}
}
impl fmt::Debug for PresignedUrl {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("PresignedUrl([REDACTED])")
}
}
impl fmt::Display for PresignedUrl {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("[REDACTED PRESIGNED URL]")
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn byte_ranges_render_without_off_by_one_changes() {
assert_eq!(
ByteRange::inclusive(2, 9).unwrap().to_header_value(),
"bytes=2-9"
);
assert_eq!(ByteRange::from(2).to_header_value(), "bytes=2-");
assert_eq!(ByteRange::suffix(2).unwrap().to_header_value(), "bytes=-2");
assert!(ByteRange::inclusive(9, 2).is_err());
assert!(ByteRange::suffix(0).is_none());
}
#[test]
fn presigned_url_formatting_is_redacted() {
let signed = PresignedUrl::new("https://example.test/key?X-Amz-Signature=secret");
assert!(!format!("{signed:?}").contains("secret"));
assert!(!signed.to_string().contains("secret"));
assert!(signed.expose().contains("secret"));
}
proptest! {
#[test]
fn valid_object_keys_round_trip(value in ".{1,300}") {
prop_assume!(value.len() <= MAX_OBJECT_KEY_BYTES);
let key = ObjectKey::new(value.clone()).unwrap();
prop_assert_eq!(key.into_string(), value);
}
}
}