1use std::collections::HashMap;
4use std::fmt;
5
6use base64::Engine;
7use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
8use http::header::{HeaderName, HeaderValue};
9use jiff::Timestamp;
10use zeroize::Zeroizing;
11
12use crate::encryption::ObjectEncryptionRequest;
13use crate::object_lock::{LegalHoldStatus, ObjectRetention};
14use crate::traits::{CopyObjectOptions, ObjectReadOptions};
15use crate::{Error, Result};
16
17const S3_MULTIPART_CHECKSUM_MAX_PARTS: u32 = 10_000;
18
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct ObjectAttributes {
22 pub content_type: Option<String>,
24 pub cache_control: Option<String>,
26 pub content_disposition: Option<String>,
28 pub content_encoding: Option<String>,
30 pub content_language: Option<String>,
32 pub expires: Option<Timestamp>,
34 pub user_metadata: HashMap<String, String>,
36}
37
38impl ObjectAttributes {
39 pub fn validate(&self) -> Result<()> {
41 for (name, value) in [
42 ("Content-Type", self.content_type.as_deref()),
43 ("Cache-Control", self.cache_control.as_deref()),
44 ("Content-Disposition", self.content_disposition.as_deref()),
45 ("Content-Encoding", self.content_encoding.as_deref()),
46 ("Content-Language", self.content_language.as_deref()),
47 ] {
48 if let Some(value) = value {
49 if value.trim().is_empty() {
50 return Err(Error::InvalidPath(format!("{name} cannot be empty")));
51 }
52 HeaderValue::from_str(value).map_err(|_| {
53 Error::InvalidPath(format!("{name} contains invalid HTTP header characters"))
54 })?;
55 }
56 }
57 for (key, value) in &self.user_metadata {
58 if key.trim().is_empty() {
59 return Err(Error::InvalidPath(
60 "User metadata keys cannot be empty".to_string(),
61 ));
62 }
63 let header_name = format!("x-amz-meta-{key}");
64 HeaderName::from_bytes(header_name.as_bytes()).map_err(|_| {
65 Error::InvalidPath(format!(
66 "User metadata key '{key}' cannot be represented as an HTTP header"
67 ))
68 })?;
69 HeaderValue::from_str(value).map_err(|_| {
70 Error::InvalidPath(format!(
71 "User metadata value for '{key}' contains invalid HTTP header characters"
72 ))
73 })?;
74 }
75 Ok(())
76 }
77
78 fn contains_only_content_type(&self) -> bool {
79 self.cache_control.is_none()
80 && self.content_disposition.is_none()
81 && self.content_encoding.is_none()
82 && self.content_language.is_none()
83 && self.expires.is_none()
84 && self.user_metadata.is_empty()
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum MetadataDirective {
91 Copy,
93 Replace,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum TaggingDirective {
100 Copy,
102 Replace,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum ChecksumAlgorithm {
109 Crc32,
111 Crc32c,
113 Crc64Nvme,
115 Sha1,
117 Sha256,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct ObjectChecksum {
124 pub algorithm: ChecksumAlgorithm,
126 pub value: String,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum ChecksumRequest {
133 Calculate(ChecksumAlgorithm),
135 Precomputed(ObjectChecksum),
137}
138
139impl ChecksumRequest {
140 fn validate(&self) -> Result<()> {
141 match self {
142 Self::Calculate(_) => Ok(()),
143 Self::Precomputed(checksum) => checksum.validate(),
144 }
145 }
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq)]
150pub struct ObjectTransferMetadata {
151 pub attributes: ObjectAttributes,
153 pub storage_class: Option<String>,
155 pub checksums: Vec<ObjectChecksum>,
157}
158
159impl ObjectChecksum {
160 pub fn new(algorithm: ChecksumAlgorithm, value: impl Into<String>) -> Result<Self> {
162 let checksum = Self {
163 algorithm,
164 value: value.into(),
165 };
166 checksum.validate()?;
167 Ok(checksum)
168 }
169
170 pub fn new_persisted(algorithm: ChecksumAlgorithm, value: impl Into<String>) -> Result<Self> {
176 let checksum = Self {
177 algorithm,
178 value: value.into(),
179 };
180 if checksum.validate().is_ok() {
181 return Ok(checksum);
182 }
183
184 let (digest, part_count) = checksum.value.rsplit_once('-').ok_or_else(|| {
185 Error::InvalidPath("Persisted object checksum is not valid Base64".to_string())
186 })?;
187 let part_count = part_count.parse::<u32>().map_err(|_| {
188 Error::InvalidPath(
189 "Composite checksum part count must be a positive integer".to_string(),
190 )
191 })?;
192 if part_count == 0
193 || part_count > S3_MULTIPART_CHECKSUM_MAX_PARTS
194 || checksum.algorithm == ChecksumAlgorithm::Crc64Nvme
195 {
196 return Err(Error::InvalidPath(
197 "Persisted composite checksum is not valid for this algorithm".to_string(),
198 ));
199 }
200 let decoded = BASE64_STANDARD.decode(digest).map_err(|_| {
201 Error::InvalidPath("Composite checksum digest must be valid Base64".to_string())
202 })?;
203 let expected_length = match checksum.algorithm {
204 ChecksumAlgorithm::Crc32 | ChecksumAlgorithm::Crc32c => 4,
205 ChecksumAlgorithm::Sha1 => 20,
206 ChecksumAlgorithm::Sha256 => 32,
207 ChecksumAlgorithm::Crc64Nvme => 8,
208 };
209 if decoded.len() != expected_length {
210 return Err(Error::InvalidPath(format!(
211 "Composite checksum for {:?} must decode to {expected_length} bytes",
212 checksum.algorithm
213 )));
214 }
215 Ok(checksum)
216 }
217
218 pub fn validate(&self) -> Result<()> {
220 if self.value.trim().is_empty() {
221 return Err(Error::InvalidPath(
222 "Object checksum value cannot be empty".to_string(),
223 ));
224 }
225 let decoded = BASE64_STANDARD
226 .decode(&self.value)
227 .map_err(|_| Error::InvalidPath("Object checksum must be valid Base64".to_string()))?;
228 let expected_length = match self.algorithm {
229 ChecksumAlgorithm::Crc32 | ChecksumAlgorithm::Crc32c => 4,
230 ChecksumAlgorithm::Crc64Nvme => 8,
231 ChecksumAlgorithm::Sha1 => 20,
232 ChecksumAlgorithm::Sha256 => 32,
233 };
234 if decoded.len() != expected_length {
235 return Err(Error::InvalidPath(format!(
236 "Object checksum for {:?} must decode to {expected_length} bytes",
237 self.algorithm
238 )));
239 }
240 Ok(())
241 }
242}
243
244#[derive(Clone, PartialEq, Eq)]
246pub struct SseCustomerKey(Zeroizing<Vec<u8>>);
247
248impl SseCustomerKey {
249 pub fn new(key: Vec<u8>) -> Result<Self> {
251 let key = Zeroizing::new(key);
252 if key.len() != 32 {
253 return Err(Error::InvalidPath(
254 "SSE-C keys must contain exactly 32 bytes".to_string(),
255 ));
256 }
257 Ok(Self(key))
258 }
259
260 pub fn expose_secret(&self) -> &[u8] {
264 self.0.as_slice()
265 }
266}
267
268impl fmt::Debug for SseCustomerKey {
269 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
270 formatter.write_str("SseCustomerKey([REDACTED])")
271 }
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum ObjectWriteEncryption {
277 Managed(ObjectEncryptionRequest),
279 SseCustomer {
281 key: SseCustomerKey,
283 },
284}
285
286impl From<ObjectEncryptionRequest> for ObjectWriteEncryption {
287 fn from(value: ObjectEncryptionRequest) -> Self {
288 Self::Managed(value)
289 }
290}
291
292#[derive(Debug, Clone, Default, PartialEq, Eq)]
294pub struct ObjectWriteOptions {
295 pub attributes: Option<ObjectAttributes>,
297 pub tags: Option<HashMap<String, String>>,
299 pub storage_class: Option<String>,
301 pub checksum: Option<ChecksumRequest>,
303 pub encryption: Option<ObjectWriteEncryption>,
305 pub retention: Option<ObjectRetention>,
307 pub legal_hold: Option<LegalHoldStatus>,
309}
310
311impl ObjectWriteOptions {
312 pub fn validate(&self) -> Result<()> {
314 if let Some(attributes) = &self.attributes {
315 attributes.validate()?;
316 }
317 if let Some(tags) = &self.tags {
318 if tags.len() > 10 {
319 return Err(Error::InvalidPath(
320 "Objects can have at most 10 tags".to_string(),
321 ));
322 }
323 for (key, value) in tags {
324 let key_length = key.chars().count();
325 if !(1..=128).contains(&key_length) {
326 return Err(Error::InvalidPath(
327 "Object tag keys must contain between 1 and 128 characters".to_string(),
328 ));
329 }
330 if value.chars().count() > 256 {
331 return Err(Error::InvalidPath(
332 "Object tag values cannot exceed 256 characters".to_string(),
333 ));
334 }
335 }
336 }
337 if self
338 .storage_class
339 .as_deref()
340 .is_some_and(|value| value.trim().is_empty())
341 {
342 return Err(Error::InvalidPath(
343 "Storage class cannot be empty".to_string(),
344 ));
345 }
346 if let Some(checksum) = &self.checksum {
347 checksum.validate()?;
348 }
349 if self
350 .retention
351 .as_ref()
352 .is_some_and(|retention| retention.retain_until <= jiff::Timestamp::now())
353 {
354 return Err(Error::InvalidPath(
355 "Object retention date must be in the future".to_string(),
356 ));
357 }
358 if matches!(
359 self.encryption.as_ref(),
360 Some(ObjectWriteEncryption::Managed(
361 ObjectEncryptionRequest::SseKms { key_id }
362 )) if key_id.trim().is_empty()
363 ) {
364 return Err(Error::InvalidPath("KMS key ID cannot be empty".to_string()));
365 }
366 Ok(())
367 }
368
369 pub fn legacy_put_arguments(&self) -> Result<(Option<&str>, Option<&ObjectEncryptionRequest>)> {
373 self.validate()?;
374 let content_type = match &self.attributes {
375 Some(attributes) if attributes.contains_only_content_type() => {
376 attributes.content_type.as_deref()
377 }
378 Some(_) => {
379 return Err(Error::UnsupportedFeature(
380 "Object attributes other than Content-Type require advanced write support"
381 .to_string(),
382 ));
383 }
384 None => None,
385 };
386 if self.tags.is_some()
387 || self.storage_class.is_some()
388 || self.checksum.is_some()
389 || self.retention.is_some()
390 || self.legal_hold.is_some()
391 {
392 return Err(Error::UnsupportedFeature(
393 "Transfer fidelity options are not implemented by this object store".to_string(),
394 ));
395 }
396 let encryption = match self.encryption.as_ref() {
397 Some(ObjectWriteEncryption::Managed(request)) => Some(request),
398 Some(ObjectWriteEncryption::SseCustomer { .. }) => {
399 return Err(Error::UnsupportedFeature(
400 "SSE-C writes are not implemented by this object store".to_string(),
401 ));
402 }
403 None => None,
404 };
405 Ok((content_type, encryption))
406 }
407
408 fn legacy_copy_encryption(&self) -> Result<Option<&ObjectEncryptionRequest>> {
409 self.validate()?;
410 if self.attributes.is_some()
411 || self.tags.is_some()
412 || self.storage_class.is_some()
413 || self.checksum.is_some()
414 || self.retention.is_some()
415 || self.legal_hold.is_some()
416 {
417 return Err(Error::UnsupportedFeature(
418 "Advanced copy destination options are not implemented by this object store"
419 .to_string(),
420 ));
421 }
422 match self.encryption.as_ref() {
423 Some(ObjectWriteEncryption::Managed(request)) => Ok(Some(request)),
424 Some(ObjectWriteEncryption::SseCustomer { .. }) => Err(Error::UnsupportedFeature(
425 "Destination SSE-C is not implemented by this object store".to_string(),
426 )),
427 None => Ok(None),
428 }
429 }
430}
431
432#[derive(Debug, Clone, Default, PartialEq, Eq)]
434pub struct TransferReadOptions {
435 pub version_id: Option<String>,
437 pub checksum_mode: bool,
439 pub customer_key: Option<SseCustomerKey>,
441}
442
443impl TransferReadOptions {
444 pub fn validate(&self) -> Result<()> {
446 if self.version_id.as_deref().is_some_and(str::is_empty) {
447 return Err(Error::InvalidPath("Version ID cannot be empty".to_string()));
448 }
449 Ok(())
450 }
451
452 pub(crate) fn legacy_read_options(&self) -> Result<ObjectReadOptions> {
453 self.validate()?;
454 if self.checksum_mode || self.customer_key.is_some() {
455 return Err(Error::UnsupportedFeature(
456 "Checksum-mode or SSE-C reads are not implemented by this object store".to_string(),
457 ));
458 }
459 ObjectReadOptions::for_version(self.version_id.clone())
460 }
461}
462
463impl From<ObjectReadOptions> for TransferReadOptions {
464 fn from(value: ObjectReadOptions) -> Self {
465 Self {
466 version_id: value.version_id,
467 ..Self::default()
468 }
469 }
470}
471
472#[derive(Debug, Clone, Default, PartialEq, Eq)]
474pub struct TransferCopyOptions {
475 pub source: TransferReadOptions,
477 pub metadata_directive: Option<MetadataDirective>,
479 pub tagging_directive: Option<TaggingDirective>,
481 pub destination: ObjectWriteOptions,
483}
484
485impl TransferCopyOptions {
486 pub fn validate(&self) -> Result<()> {
488 self.source.validate()?;
489 self.destination.validate()?;
490 match self.metadata_directive {
491 None | Some(MetadataDirective::Copy) if self.destination.attributes.is_some() => {
492 return Err(Error::InvalidPath(
493 "Destination attributes require an explicit metadata REPLACE directive"
494 .to_string(),
495 ));
496 }
497 Some(MetadataDirective::Replace) if self.destination.attributes.is_none() => {
498 return Err(Error::InvalidPath(
499 "Metadata REPLACE requires an explicit attributes value".to_string(),
500 ));
501 }
502 _ => {}
503 }
504 match self.tagging_directive {
505 None | Some(TaggingDirective::Copy) if self.destination.tags.is_some() => {
506 return Err(Error::InvalidPath(
507 "Destination tags require an explicit tagging REPLACE directive".to_string(),
508 ));
509 }
510 Some(TaggingDirective::Replace) if self.destination.tags.is_none() => {
511 return Err(Error::InvalidPath(
512 "Tagging REPLACE requires an explicit tags value".to_string(),
513 ));
514 }
515 _ => {}
516 }
517 Ok(())
518 }
519
520 pub(crate) fn legacy_copy_arguments(
521 &self,
522 ) -> Result<(CopyObjectOptions, Option<&ObjectEncryptionRequest>)> {
523 self.validate()?;
524 if self.metadata_directive.is_some() || self.tagging_directive.is_some() {
525 return Err(Error::UnsupportedFeature(
526 "Metadata or tagging directives are not implemented by this object store"
527 .to_string(),
528 ));
529 }
530 let source = self.source.legacy_read_options()?;
531 let encryption = self.destination.legacy_copy_encryption()?;
532 let copy = CopyObjectOptions::for_source_version(source.version_id)?;
533 Ok((copy, encryption))
534 }
535
536 pub(crate) fn validate_multipart_source_version(
537 &self,
538 multipart_source_version_id: Option<&str>,
539 ) -> Result<()> {
540 if self.source.version_id.is_some()
541 && self.source.version_id.as_deref() != multipart_source_version_id
542 {
543 return Err(Error::InvalidPath(
544 "Transfer and multipart source version IDs must match".to_string(),
545 ));
546 }
547 Ok(())
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn persisted_checksums_accept_valid_composite_values_only() {
557 let digest = BASE64_STANDARD.encode([7_u8; 32]);
558 let checksum =
559 ObjectChecksum::new_persisted(ChecksumAlgorithm::Sha256, format!("{digest}-3"))
560 .expect("valid composite checksum");
561 assert_eq!(checksum.value, format!("{digest}-3"));
562
563 for value in [
564 format!("{digest}-0"),
565 format!("{digest}-10001"),
566 format!("{digest}-not-a-number"),
567 ] {
568 assert!(matches!(
569 ObjectChecksum::new_persisted(ChecksumAlgorithm::Sha256, value),
570 Err(Error::InvalidPath(_))
571 ));
572 }
573 assert!(matches!(
574 ObjectChecksum::new_persisted(
575 ChecksumAlgorithm::Crc64Nvme,
576 format!("{}-2", BASE64_STANDARD.encode([3_u8; 8]))
577 ),
578 Err(Error::InvalidPath(_))
579 ));
580 }
581
582 #[test]
583 fn advanced_reads_cannot_fall_through_to_legacy_backends() {
584 let checksum_read = TransferReadOptions {
585 checksum_mode: true,
586 ..TransferReadOptions::default()
587 };
588 assert!(matches!(
589 checksum_read.legacy_read_options(),
590 Err(Error::UnsupportedFeature(_))
591 ));
592
593 let customer_key = SseCustomerKey::new(vec![3; 32]).expect("valid customer key");
594 let encrypted_read = TransferReadOptions {
595 customer_key: Some(customer_key),
596 ..TransferReadOptions::default()
597 };
598 assert!(matches!(
599 encrypted_read.legacy_read_options(),
600 Err(Error::UnsupportedFeature(_))
601 ));
602 }
603
604 #[test]
605 fn explicit_copy_directives_cannot_fall_through_to_legacy_backends() {
606 for options in [
607 TransferCopyOptions {
608 metadata_directive: Some(MetadataDirective::Copy),
609 ..TransferCopyOptions::default()
610 },
611 TransferCopyOptions {
612 tagging_directive: Some(TaggingDirective::Copy),
613 ..TransferCopyOptions::default()
614 },
615 ] {
616 assert!(matches!(
617 options.legacy_copy_arguments(),
618 Err(Error::UnsupportedFeature(_))
619 ));
620 }
621 }
622
623 #[test]
624 fn multipart_source_versions_must_match_when_transfer_selects_one() {
625 let options = TransferCopyOptions {
626 source: TransferReadOptions {
627 version_id: Some("source-v1".to_string()),
628 ..TransferReadOptions::default()
629 },
630 ..TransferCopyOptions::default()
631 };
632
633 options
634 .validate_multipart_source_version(Some("source-v1"))
635 .expect("matching source versions should be accepted");
636 assert!(matches!(
637 options.validate_multipart_source_version(Some("source-v2")),
638 Err(Error::InvalidPath(_))
639 ));
640 assert!(matches!(
641 options.validate_multipart_source_version(None),
642 Err(Error::InvalidPath(_))
643 ));
644 }
645}