Skip to main content

minco_plugin_object_storage/
transfers.rs

1//! Streaming reads, resumable multipart uploads, validation state, and cost shape.
2#![forbid(unsafe_code)]
3
4use crate::{ObjectKey, ObjectStore, ObjectStoreError, PresignedObjectRequest, StoredObject};
5use async_trait::async_trait;
6use chrono::{DateTime, TimeDelta, Utc};
7use futures::{Stream, stream};
8use serde::{Deserialize, Serialize};
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    fmt,
12    pin::Pin,
13    sync::Arc,
14};
15use uuid::Uuid;
16
17pub const MIN_MULTIPART_PART_SIZE_BYTES: u64 = 5 * 1024 * 1024;
18pub const MAX_MULTIPART_PART_SIZE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
19pub const MAX_MULTIPART_PARTS: u32 = 10_000;
20pub const MAX_MULTIPART_OBJECT_SIZE_BYTES: u64 =
21    MAX_MULTIPART_PART_SIZE_BYTES * MAX_MULTIPART_PARTS as u64;
22const MAX_CAPABILITY_EXPIRY_SECONDS: i64 = 24 * 60 * 60;
23const DEFAULT_PART_EXPIRY_SECONDS: i64 = 15 * 60;
24const RESERVED_ATTRIBUTE_PREFIX: &str = "minco.";
25const UPLOAD_ID_ATTRIBUTE: &str = "minco.upload_id";
26
27/// One HTTP byte range. Bounded ranges use an exclusive end in Rust and are
28/// rendered with the inclusive end required by HTTP.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum ObjectByteRange {
32    Bounded { start: u64, end_exclusive: u64 },
33    From { start: u64 },
34    Suffix { length: u64 },
35}
36
37impl ObjectByteRange {
38    pub const fn bounded(start: u64, end_exclusive: u64) -> Result<Self, ObjectTransferError> {
39        if start >= end_exclusive {
40            Err(ObjectTransferError::InvalidRange)
41        } else {
42            Ok(Self::Bounded {
43                start,
44                end_exclusive,
45            })
46        }
47    }
48
49    pub const fn from(start: u64) -> Self {
50        Self::From { start }
51    }
52
53    pub const fn suffix(length: u64) -> Result<Self, ObjectTransferError> {
54        if length == 0 {
55            Err(ObjectTransferError::InvalidRange)
56        } else {
57            Ok(Self::Suffix { length })
58        }
59    }
60
61    #[must_use]
62    pub fn to_http_value(self) -> String {
63        match self {
64            Self::Bounded {
65                start,
66                end_exclusive,
67            } => format!("bytes={start}-{}", end_exclusive - 1),
68            Self::From { start } => format!("bytes={start}-"),
69            Self::Suffix { length } => format!("bytes=-{length}"),
70        }
71    }
72
73    pub const fn validate(self) -> Result<(), ObjectTransferError> {
74        match self {
75            Self::Bounded {
76                start,
77                end_exclusive,
78            } if start < end_exclusive => Ok(()),
79            Self::From { .. } => Ok(()),
80            Self::Suffix { length } if length > 0 => Ok(()),
81            _ => Err(ObjectTransferError::InvalidRange),
82        }
83    }
84
85    fn resolve(self, size_bytes: u64) -> Result<(u64, u64), ObjectTransferError> {
86        if size_bytes == 0 {
87            return Err(ObjectTransferError::RangeNotSatisfiable);
88        }
89        match self {
90            Self::Bounded {
91                start,
92                end_exclusive,
93            } if start < end_exclusive && start < size_bytes => {
94                Ok((start, end_exclusive.min(size_bytes)))
95            }
96            Self::From { start } if start < size_bytes => Ok((start, size_bytes)),
97            Self::Suffix { length } if length > 0 => {
98                Ok((size_bytes.saturating_sub(length), size_bytes))
99            }
100            _ => Err(ObjectTransferError::RangeNotSatisfiable),
101        }
102    }
103}
104
105/// Metadata required for conditional and resumable reads.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct ObjectReadHead {
108    pub key: ObjectKey,
109    pub content_type: String,
110    pub size_bytes: u64,
111    pub entity_tag: String,
112    pub version_id: Option<String>,
113    pub last_modified: DateTime<Utc>,
114    #[serde(default)]
115    pub attributes: BTreeMap<String, String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ObjectReadRequest {
120    pub key: ObjectKey,
121    pub range: Option<ObjectByteRange>,
122    pub expected_entity_tag: Option<String>,
123    pub version_id: Option<String>,
124}
125
126pub type ObjectByteStream =
127    Pin<Box<dyn Stream<Item = Result<Vec<u8>, ObjectStoreError>> + Send + 'static>>;
128
129pub struct ObjectReadResponse {
130    pub head: ObjectReadHead,
131    pub content_range: Option<String>,
132    pub stream: ObjectByteStream,
133}
134
135impl fmt::Debug for ObjectReadResponse {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        formatter
138            .debug_struct("ObjectReadResponse")
139            .field("head", &self.head)
140            .field("content_range", &self.content_range)
141            .field("stream", &"[STREAM]")
142            .finish()
143    }
144}
145
146/// Provider-neutral read port. Implementations must produce bounded chunks and
147/// stop provider reads when the returned stream is dropped.
148#[async_trait]
149pub trait ObjectStreamReader: Send + Sync + fmt::Debug {
150    async fn head(&self, key: &ObjectKey) -> Result<Option<ObjectReadHead>, ObjectStoreError>;
151    async fn read(
152        &self,
153        request: ObjectReadRequest,
154    ) -> Result<Option<ObjectReadResponse>, ObjectStoreError>;
155}
156
157#[async_trait]
158impl ObjectStreamReader for crate::MemoryObjectStore {
159    async fn head(&self, key: &ObjectKey) -> Result<Option<ObjectReadHead>, ObjectStoreError> {
160        let object = ObjectStore::get(self, key).await?;
161        Ok(object.map(|object| read_head(&object)))
162    }
163
164    async fn read(
165        &self,
166        request: ObjectReadRequest,
167    ) -> Result<Option<ObjectReadResponse>, ObjectStoreError> {
168        let Some(object) = ObjectStore::get(self, &request.key).await? else {
169            return Ok(None);
170        };
171        let head = read_head(&object);
172        validate_read_preconditions(&head, &request).map_err(|error| transfer_to_store(&error))?;
173        let (bytes, content_range) = match request.range {
174            Some(range) => {
175                let (start, end) = range
176                    .resolve(head.size_bytes)
177                    .map_err(|error| transfer_to_store(&error))?;
178                let start = usize::try_from(start).map_err(|_| ObjectStoreError::ObjectTooLarge)?;
179                let end = usize::try_from(end).map_err(|_| ObjectStoreError::ObjectTooLarge)?;
180                (
181                    object.bytes[start..end].to_vec(),
182                    Some(format!(
183                        "bytes {start}-{}/{size}",
184                        end - 1,
185                        size = head.size_bytes
186                    )),
187                )
188            }
189            None => (object.bytes, None),
190        };
191        Ok(Some(ObjectReadResponse {
192            head,
193            content_range,
194            stream: Box::pin(stream::once(async move { Ok(bytes) })),
195        }))
196    }
197}
198
199fn read_head(object: &StoredObject) -> ObjectReadHead {
200    ObjectReadHead {
201        key: object.key.clone(),
202        content_type: object.metadata.content_type.clone(),
203        size_bytes: object.metadata.size_bytes,
204        entity_tag: format!("\"{}\"", object.metadata.sha256),
205        version_id: None,
206        last_modified: object.metadata.created_at,
207        attributes: object.metadata.attributes.clone(),
208    }
209}
210
211#[derive(Clone)]
212pub struct ObjectReadService(Arc<dyn ObjectStreamReader>);
213
214impl fmt::Debug for ObjectReadService {
215    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216        formatter.debug_tuple("ObjectReadService").finish()
217    }
218}
219
220impl ObjectReadService {
221    pub fn new(reader: Arc<dyn ObjectStreamReader>) -> Self {
222        Self(reader)
223    }
224
225    pub async fn head(&self, key: &ObjectKey) -> Result<Option<ObjectReadHead>, ObjectStoreError> {
226        self.0.head(key).await
227    }
228
229    pub async fn read(
230        &self,
231        request: ObjectReadRequest,
232    ) -> Result<Option<ObjectReadResponse>, ObjectStoreError> {
233        self.0.read(request).await
234    }
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(tag = "kind", rename_all = "snake_case")]
239pub enum DownloadCachePolicy {
240    NoStore,
241    Private {
242        max_age_seconds: u32,
243        immutable: bool,
244    },
245}
246
247impl DownloadCachePolicy {
248    #[must_use]
249    pub fn to_header_value(self) -> String {
250        match self {
251            Self::NoStore => "private, no-store".into(),
252            Self::Private {
253                max_age_seconds,
254                immutable: true,
255            } => format!("private, max-age={max_age_seconds}, immutable"),
256            Self::Private {
257                max_age_seconds,
258                immutable: false,
259            } => format!("private, max-age={max_age_seconds}"),
260        }
261    }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct ObjectDownloadPolicy {
266    expires_in: TimeDelta,
267    cache: DownloadCachePolicy,
268}
269
270impl ObjectDownloadPolicy {
271    pub fn new(
272        expires_in: TimeDelta,
273        cache: DownloadCachePolicy,
274    ) -> Result<Self, ObjectTransferError> {
275        validate_expiry(expires_in)?;
276        Ok(Self { expires_in, cache })
277    }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281pub struct IssueObjectDownload {
282    pub key: ObjectKey,
283    pub range: Option<ObjectByteRange>,
284    pub expected_entity_tag: Option<String>,
285    pub version_id: Option<String>,
286    pub download_file_name: Option<String>,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct SignObjectDownload {
291    pub key: ObjectKey,
292    pub range: Option<ObjectByteRange>,
293    pub if_match: String,
294    pub version_id: Option<String>,
295    pub download_file_name: Option<String>,
296    pub cache_control: String,
297    pub expires_in: TimeDelta,
298}
299
300#[async_trait]
301pub trait ObjectDownloadSigner: Send + Sync + fmt::Debug {
302    async fn sign_download(
303        &self,
304        request: SignObjectDownload,
305    ) -> Result<PresignedObjectRequest, ObjectTransferError>;
306}
307
308/// Client-facing private download capability. Its `Debug` representation uses
309/// the presigned request's redacted formatter.
310#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct ObjectDownloadGrant {
312    pub key: ObjectKey,
313    pub request: PresignedObjectRequest,
314    pub content_type: String,
315    pub size_bytes: u64,
316    pub entity_tag: String,
317    pub version_id: Option<String>,
318    pub last_modified: DateTime<Utc>,
319    pub range: Option<ObjectByteRange>,
320    pub cache_control: String,
321}
322
323impl fmt::Debug for ObjectDownloadGrant {
324    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
325        formatter
326            .debug_struct("ObjectDownloadGrant")
327            .field("key", &self.key)
328            .field("request", &self.request)
329            .field("content_type", &self.content_type)
330            .field("size_bytes", &self.size_bytes)
331            .field("entity_tag", &self.entity_tag)
332            .field("version_id", &self.version_id)
333            .field("last_modified", &self.last_modified)
334            .field("range", &self.range)
335            .field("cache_control", &self.cache_control)
336            .finish()
337    }
338}
339
340#[derive(Clone)]
341pub struct ObjectDownloadService {
342    signer: Arc<dyn ObjectDownloadSigner>,
343    reads: ObjectReadService,
344    policy: ObjectDownloadPolicy,
345}
346
347impl fmt::Debug for ObjectDownloadService {
348    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
349        formatter
350            .debug_struct("ObjectDownloadService")
351            .field("policy", &self.policy)
352            .finish_non_exhaustive()
353    }
354}
355
356impl ObjectDownloadService {
357    pub fn new(
358        signer: Arc<dyn ObjectDownloadSigner>,
359        reads: ObjectReadService,
360        policy: ObjectDownloadPolicy,
361    ) -> Self {
362        Self {
363            signer,
364            reads,
365            policy,
366        }
367    }
368
369    pub async fn issue(
370        &self,
371        request: IssueObjectDownload,
372    ) -> Result<ObjectDownloadGrant, ObjectTransferError> {
373        let head = self
374            .reads
375            .head(&request.key)
376            .await?
377            .ok_or(ObjectTransferError::MissingObject)?;
378        validate_read_preconditions(
379            &head,
380            &ObjectReadRequest {
381                key: request.key.clone(),
382                range: request.range,
383                expected_entity_tag: request.expected_entity_tag,
384                version_id: request.version_id,
385            },
386        )?;
387        if let Some(range) = request.range {
388            range.resolve(head.size_bytes)?;
389        }
390        let download_file_name = request
391            .download_file_name
392            .unwrap_or_else(|| "download".into());
393        validate_download_name(&download_file_name)?;
394        let cache_control = self.policy.cache.to_header_value();
395        let signed = self
396            .signer
397            .sign_download(SignObjectDownload {
398                key: request.key.clone(),
399                range: request.range,
400                if_match: head.entity_tag.clone(),
401                version_id: head.version_id.clone(),
402                download_file_name: Some(download_file_name),
403                cache_control: cache_control.clone(),
404                expires_in: self.policy.expires_in,
405            })
406            .await?;
407        Ok(ObjectDownloadGrant {
408            key: request.key,
409            request: signed,
410            content_type: head.content_type,
411            size_bytes: head.size_bytes,
412            entity_tag: head.entity_tag,
413            version_id: head.version_id,
414            last_modified: head.last_modified,
415            range: request.range,
416            cache_control,
417        })
418    }
419}
420
421fn validate_read_preconditions(
422    head: &ObjectReadHead,
423    request: &ObjectReadRequest,
424) -> Result<(), ObjectTransferError> {
425    if request
426        .expected_entity_tag
427        .as_deref()
428        .is_some_and(|expected| expected != head.entity_tag)
429        || request
430            .version_id
431            .as_deref()
432            .is_some_and(|expected| Some(expected) != head.version_id.as_deref())
433    {
434        return Err(ObjectTransferError::PreconditionFailed);
435    }
436    Ok(())
437}
438
439/// Opaque trusted provider state. It is serializable for application-owned
440/// session persistence but always redacted from `Debug`.
441#[derive(Clone, PartialEq, Eq, Serialize)]
442#[serde(transparent)]
443pub struct ProviderMultipartUploadId(String);
444
445impl ProviderMultipartUploadId {
446    pub fn parse(value: impl Into<String>) -> Result<Self, ObjectTransferError> {
447        let value = value.into();
448        if value.is_empty() || value.len() > 2_048 || value.chars().any(char::is_control) {
449            Err(ObjectTransferError::InvalidProviderUploadId)
450        } else {
451            Ok(Self(value))
452        }
453    }
454
455    /// Expose only at the provider adapter call boundary. Never log this value.
456    pub fn expose_secret(&self) -> &str {
457        &self.0
458    }
459}
460
461impl<'de> Deserialize<'de> for ProviderMultipartUploadId {
462    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
463    where
464        D: serde::Deserializer<'de>,
465    {
466        let value = String::deserialize(deserializer)?;
467        Self::parse(value)
468            .map_err(|_| serde::de::Error::custom("invalid provider multipart upload ID"))
469    }
470}
471
472impl fmt::Debug for ProviderMultipartUploadId {
473    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
474        formatter.write_str("ProviderMultipartUploadId([REDACTED])")
475    }
476}
477
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct MultipartUploadPolicy {
480    key_prefix: ObjectKey,
481    maximum_size_bytes: u64,
482    part_size_bytes: u64,
483    allowed_content_types: BTreeSet<String>,
484    part_expires_in: TimeDelta,
485}
486
487impl MultipartUploadPolicy {
488    pub fn new<I, S>(
489        key_prefix: ObjectKey,
490        maximum_size_bytes: u64,
491        part_size_bytes: u64,
492        allowed_content_types: I,
493    ) -> Result<Self, ObjectTransferError>
494    where
495        I: IntoIterator<Item = S>,
496        S: AsRef<str>,
497    {
498        if maximum_size_bytes == 0 || maximum_size_bytes > MAX_MULTIPART_OBJECT_SIZE_BYTES {
499            return Err(ObjectTransferError::InvalidMaximumSize);
500        }
501        if !(MIN_MULTIPART_PART_SIZE_BYTES..=MAX_MULTIPART_PART_SIZE_BYTES)
502            .contains(&part_size_bytes)
503        {
504            return Err(ObjectTransferError::InvalidPartSize);
505        }
506        let allowed_content_types = allowed_content_types
507            .into_iter()
508            .map(|value| normalize_content_type(value.as_ref()))
509            .collect::<Result<BTreeSet<_>, _>>()?;
510        if allowed_content_types.is_empty() {
511            return Err(ObjectTransferError::EmptyContentTypeAllowlist);
512        }
513        let policy = Self {
514            key_prefix,
515            maximum_size_bytes,
516            part_size_bytes,
517            allowed_content_types,
518            part_expires_in: TimeDelta::seconds(DEFAULT_PART_EXPIRY_SECONDS),
519        };
520        policy.plan(maximum_size_bytes)?;
521        Ok(policy)
522    }
523
524    pub fn with_part_expiry(mut self, expires_in: TimeDelta) -> Result<Self, ObjectTransferError> {
525        validate_expiry(expires_in)?;
526        self.part_expires_in = expires_in;
527        Ok(self)
528    }
529
530    pub fn plan(&self, size_bytes: u64) -> Result<MultipartUploadPlan, ObjectTransferError> {
531        if size_bytes == 0 {
532            return Err(ObjectTransferError::EmptyObject);
533        }
534        if size_bytes > self.maximum_size_bytes {
535            return Err(ObjectTransferError::ObjectTooLarge {
536                actual: size_bytes,
537                maximum: self.maximum_size_bytes,
538            });
539        }
540        let count = size_bytes.div_ceil(self.part_size_bytes);
541        let part_count = u32::try_from(count).map_err(|_| ObjectTransferError::TooManyParts)?;
542        if part_count == 0 || part_count > MAX_MULTIPART_PARTS {
543            return Err(ObjectTransferError::TooManyParts);
544        }
545        Ok(MultipartUploadPlan {
546            size_bytes,
547            part_size_bytes: self.part_size_bytes,
548            part_count,
549        })
550    }
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
554pub struct MultipartUploadPlan {
555    pub size_bytes: u64,
556    pub part_size_bytes: u64,
557    pub part_count: u32,
558}
559
560impl MultipartUploadPlan {
561    pub fn expected_part_size(&self, part_number: u32) -> Result<u64, ObjectTransferError> {
562        if part_number == 0 || part_number > self.part_count {
563            return Err(ObjectTransferError::InvalidPartNumber);
564        }
565        if part_number < self.part_count {
566            Ok(self.part_size_bytes)
567        } else {
568            Ok(self.size_bytes - self.part_size_bytes * u64::from(self.part_count - 1))
569        }
570    }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574pub struct IssueMultipartObjectUpload {
575    pub content_type: String,
576    pub size_bytes: u64,
577    #[serde(default)]
578    pub attributes: BTreeMap<String, String>,
579}
580
581#[derive(Debug, Clone, PartialEq, Eq)]
582pub struct SignMultipartObject {
583    pub key: ObjectKey,
584    pub content_type: String,
585    pub size_bytes: u64,
586    pub attributes: BTreeMap<String, String>,
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
590pub struct SignMultipartPart {
591    pub key: ObjectKey,
592    pub upload_id: ProviderMultipartUploadId,
593    pub part_number: u32,
594    pub size_bytes: u64,
595    /// Hexadecimal SHA-256 for this part. Providers may transport it as base64.
596    pub sha256: String,
597    pub expires_in: TimeDelta,
598}
599
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601#[serde(deny_unknown_fields)]
602pub struct MultipartPartReceipt {
603    pub part_number: u32,
604    pub entity_tag: String,
605    /// Hexadecimal SHA-256 echoed by the provider for this exact part.
606    pub sha256: String,
607}
608
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
610pub struct TrustedMultipartPart {
611    pub part_number: u32,
612    pub size_bytes: u64,
613    pub entity_tag: String,
614    pub sha256: String,
615}
616
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct CompleteMultipartObject {
619    pub key: ObjectKey,
620    pub upload_id: ProviderMultipartUploadId,
621    pub content_type: String,
622    pub size_bytes: u64,
623    pub attributes: BTreeMap<String, String>,
624    pub parts: Vec<TrustedMultipartPart>,
625}
626
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628pub struct CompletedMultipartObject {
629    pub key: ObjectKey,
630    pub content_type: String,
631    pub size_bytes: u64,
632    pub entity_tag: Option<String>,
633    pub version_id: Option<String>,
634    #[serde(default)]
635    pub attributes: BTreeMap<String, String>,
636}
637
638#[async_trait]
639pub trait MultipartObjectSigner: Send + Sync + fmt::Debug {
640    async fn initiate_multipart(
641        &self,
642        request: SignMultipartObject,
643    ) -> Result<ProviderMultipartUploadId, ObjectTransferError>;
644    async fn sign_multipart_part(
645        &self,
646        request: SignMultipartPart,
647    ) -> Result<PresignedObjectRequest, ObjectTransferError>;
648    async fn complete_multipart(
649        &self,
650        request: CompleteMultipartObject,
651    ) -> Result<CompletedMultipartObject, ObjectTransferError>;
652    async fn abort_multipart(
653        &self,
654        key: &ObjectKey,
655        upload_id: &ProviderMultipartUploadId,
656    ) -> Result<(), ObjectTransferError>;
657}
658
659#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
660pub struct MultipartUploadGrant {
661    pub upload_id: Uuid,
662    pub key: ObjectKey,
663    pub size_bytes: u64,
664    pub part_size_bytes: u64,
665    pub part_count: u32,
666}
667
668impl MultipartUploadGrant {
669    pub fn expected_part_size(&self, part_number: u32) -> Result<u64, ObjectTransferError> {
670        MultipartUploadPlan {
671            size_bytes: self.size_bytes,
672            part_size_bytes: self.part_size_bytes,
673            part_count: self.part_count,
674        }
675        .expected_part_size(part_number)
676    }
677}
678
679#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
680pub struct PendingMultipartUpload {
681    pub upload_id: Uuid,
682    pub key: ObjectKey,
683    pub provider_upload_id: ProviderMultipartUploadId,
684    pub expected_content_type: String,
685    pub expected_size_bytes: u64,
686    pub expected_attributes: BTreeMap<String, String>,
687    pub part_size_bytes: u64,
688    pub part_count: u32,
689}
690
691impl fmt::Debug for PendingMultipartUpload {
692    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
693        formatter
694            .debug_struct("PendingMultipartUpload")
695            .field("upload_id", &self.upload_id)
696            .field("key", &self.key)
697            .field("provider_upload_id", &"[REDACTED]")
698            .field("expected_content_type", &self.expected_content_type)
699            .field("expected_size_bytes", &self.expected_size_bytes)
700            .field("expected_attribute_names", &self.expected_attributes.keys())
701            .field("part_size_bytes", &self.part_size_bytes)
702            .field("part_count", &self.part_count)
703            .finish()
704    }
705}
706
707#[derive(Debug, Clone, PartialEq, Eq)]
708pub struct IssuedMultipartUpload {
709    pub grant: MultipartUploadGrant,
710    pub pending: PendingMultipartUpload,
711}
712
713#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
714pub struct MultipartPartGrant {
715    pub upload_id: Uuid,
716    pub part_number: u32,
717    pub size_bytes: u64,
718    pub request: PresignedObjectRequest,
719}
720
721impl fmt::Debug for MultipartPartGrant {
722    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
723        formatter
724            .debug_struct("MultipartPartGrant")
725            .field("upload_id", &self.upload_id)
726            .field("part_number", &self.part_number)
727            .field("size_bytes", &self.size_bytes)
728            .field("request", &self.request)
729            .finish()
730    }
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734pub struct ExpectedMultipartPart {
735    pub upload_id: Uuid,
736    pub part_number: u32,
737    pub size_bytes: u64,
738    pub sha256: String,
739}
740
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub struct IssuedMultipartPart {
743    pub grant: MultipartPartGrant,
744    pub expected: ExpectedMultipartPart,
745}
746
747#[derive(Clone)]
748pub struct MultipartObjectService {
749    signer: Arc<dyn MultipartObjectSigner>,
750    policy: MultipartUploadPolicy,
751}
752
753impl fmt::Debug for MultipartObjectService {
754    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
755        formatter
756            .debug_struct("MultipartObjectService")
757            .field("policy", &self.policy)
758            .finish_non_exhaustive()
759    }
760}
761
762impl MultipartObjectService {
763    pub fn new(signer: Arc<dyn MultipartObjectSigner>, policy: MultipartUploadPolicy) -> Self {
764        Self { signer, policy }
765    }
766
767    pub async fn issue(
768        &self,
769        request: IssueMultipartObjectUpload,
770    ) -> Result<IssuedMultipartUpload, ObjectTransferError> {
771        let content_type = normalize_content_type(&request.content_type)?;
772        if !self.policy.allowed_content_types.contains(&content_type) {
773            return Err(ObjectTransferError::UnsupportedContentType(content_type));
774        }
775        let plan = self.policy.plan(request.size_bytes)?;
776        let upload_id = Uuid::now_v7();
777        let key = ObjectKey::parse(format!("{}/{upload_id}", self.policy.key_prefix.as_str()))?;
778        let mut attributes = validate_attributes(request.attributes)?;
779        attributes.insert(UPLOAD_ID_ATTRIBUTE.into(), upload_id.to_string());
780        let provider_upload_id = self
781            .signer
782            .initiate_multipart(SignMultipartObject {
783                key: key.clone(),
784                content_type: content_type.clone(),
785                size_bytes: request.size_bytes,
786                attributes: attributes.clone(),
787            })
788            .await?;
789        Ok(IssuedMultipartUpload {
790            grant: MultipartUploadGrant {
791                upload_id,
792                key: key.clone(),
793                size_bytes: plan.size_bytes,
794                part_size_bytes: plan.part_size_bytes,
795                part_count: plan.part_count,
796            },
797            pending: PendingMultipartUpload {
798                upload_id,
799                key,
800                provider_upload_id,
801                expected_content_type: content_type,
802                expected_size_bytes: plan.size_bytes,
803                expected_attributes: attributes,
804                part_size_bytes: plan.part_size_bytes,
805                part_count: plan.part_count,
806            },
807        })
808    }
809
810    pub async fn issue_part(
811        &self,
812        pending: &PendingMultipartUpload,
813        part_number: u32,
814        sha256: String,
815    ) -> Result<IssuedMultipartPart, ObjectTransferError> {
816        self.validate_pending(pending)?;
817        let plan = pending.plan();
818        let size_bytes = plan.expected_part_size(part_number)?;
819        let sha256 = normalize_sha256(&sha256)?;
820        let signed = self
821            .signer
822            .sign_multipart_part(SignMultipartPart {
823                key: pending.key.clone(),
824                upload_id: pending.provider_upload_id.clone(),
825                part_number,
826                size_bytes,
827                sha256: sha256.clone(),
828                expires_in: self.policy.part_expires_in,
829            })
830            .await?;
831        Ok(IssuedMultipartPart {
832            grant: MultipartPartGrant {
833                upload_id: pending.upload_id,
834                part_number,
835                size_bytes,
836                request: signed,
837            },
838            expected: ExpectedMultipartPart {
839                upload_id: pending.upload_id,
840                part_number,
841                size_bytes,
842                sha256,
843            },
844        })
845    }
846
847    pub fn accept_part(
848        &self,
849        pending: &PendingMultipartUpload,
850        expected: &ExpectedMultipartPart,
851        receipt: MultipartPartReceipt,
852    ) -> Result<TrustedMultipartPart, ObjectTransferError> {
853        self.validate_pending(pending)?;
854        if expected.upload_id != pending.upload_id
855            || receipt.part_number != expected.part_number
856            || normalize_sha256(&receipt.sha256)? != expected.sha256
857            || pending.plan().expected_part_size(receipt.part_number)? != expected.size_bytes
858        {
859            return Err(ObjectTransferError::PartReceiptMismatch);
860        }
861        validate_entity_tag(&receipt.entity_tag)?;
862        Ok(TrustedMultipartPart {
863            part_number: receipt.part_number,
864            size_bytes: expected.size_bytes,
865            entity_tag: receipt.entity_tag,
866            sha256: expected.sha256.clone(),
867        })
868    }
869
870    pub async fn complete(
871        &self,
872        pending: &PendingMultipartUpload,
873        parts: &[TrustedMultipartPart],
874    ) -> Result<CompletedMultipartObject, ObjectTransferError> {
875        self.validate_pending(pending)?;
876        validate_complete_parts(pending, parts)?;
877        let completed = self
878            .signer
879            .complete_multipart(CompleteMultipartObject {
880                key: pending.key.clone(),
881                upload_id: pending.provider_upload_id.clone(),
882                content_type: pending.expected_content_type.clone(),
883                size_bytes: pending.expected_size_bytes,
884                attributes: pending.expected_attributes.clone(),
885                parts: parts.to_vec(),
886            })
887            .await?;
888        if completed.key != pending.key {
889            return Err(ObjectTransferError::CompletedObjectMismatch);
890        }
891        if completed.content_type != pending.expected_content_type
892            || completed.size_bytes != pending.expected_size_bytes
893            || completed.attributes != pending.expected_attributes
894        {
895            return Err(ObjectTransferError::CompletedObjectMismatch);
896        }
897        Ok(completed)
898    }
899
900    pub async fn abort(&self, pending: &PendingMultipartUpload) -> Result<(), ObjectTransferError> {
901        self.validate_pending_identity(pending)?;
902        self.signer
903            .abort_multipart(&pending.key, &pending.provider_upload_id)
904            .await
905    }
906
907    fn validate_pending(
908        &self,
909        pending: &PendingMultipartUpload,
910    ) -> Result<(), ObjectTransferError> {
911        self.validate_pending_identity(pending)?;
912        let content_type = normalize_content_type(&pending.expected_content_type)?;
913        let plan = self.policy.plan(pending.expected_size_bytes)?;
914        let mut attributes = pending.expected_attributes.clone();
915        let upload_id = attributes.remove(UPLOAD_ID_ATTRIBUTE);
916        let expected_upload_id = pending.upload_id.to_string();
917        validate_attributes(attributes)?;
918        if content_type != pending.expected_content_type
919            || !self.policy.allowed_content_types.contains(&content_type)
920            || pending.part_size_bytes != plan.part_size_bytes
921            || pending.part_count != plan.part_count
922            || upload_id.as_deref() != Some(expected_upload_id.as_str())
923        {
924            return Err(ObjectTransferError::InvalidPendingUpload);
925        }
926        Ok(())
927    }
928
929    fn validate_pending_identity(
930        &self,
931        pending: &PendingMultipartUpload,
932    ) -> Result<(), ObjectTransferError> {
933        let expected_key = ObjectKey::parse(format!(
934            "{}/{}",
935            self.policy.key_prefix.as_str(),
936            pending.upload_id
937        ))?;
938        if pending.key != expected_key {
939            return Err(ObjectTransferError::InvalidPendingUpload);
940        }
941        Ok(())
942    }
943}
944
945impl PendingMultipartUpload {
946    const fn plan(&self) -> MultipartUploadPlan {
947        MultipartUploadPlan {
948            size_bytes: self.expected_size_bytes,
949            part_size_bytes: self.part_size_bytes,
950            part_count: self.part_count,
951        }
952    }
953}
954
955fn validate_complete_parts(
956    pending: &PendingMultipartUpload,
957    parts: &[TrustedMultipartPart],
958) -> Result<(), ObjectTransferError> {
959    if parts.len() != usize::try_from(pending.part_count).unwrap_or(usize::MAX) {
960        return Err(ObjectTransferError::IncompletePartManifest {
961            expected: pending.part_count,
962            actual: u32::try_from(parts.len()).unwrap_or(u32::MAX),
963        });
964    }
965    for (index, part) in parts.iter().enumerate() {
966        let part_number =
967            u32::try_from(index + 1).map_err(|_| ObjectTransferError::TooManyParts)?;
968        if part.part_number != part_number
969            || part.size_bytes != pending.plan().expected_part_size(part_number)?
970        {
971            return Err(ObjectTransferError::InvalidPartManifest);
972        }
973        normalize_sha256(&part.sha256)?;
974        validate_entity_tag(&part.entity_tag)?;
975    }
976    Ok(())
977}
978
979/// Application-visible content state. Provider integrity never skips
980/// quarantine; only an application inspection policy records `Accepted`.
981#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
982#[serde(tag = "state", rename_all = "snake_case")]
983pub enum ObjectValidationState {
984    Quarantined,
985    Accepted {
986        inspector: String,
987        inspected_at: DateTime<Utc>,
988    },
989    Rejected {
990        inspector: String,
991        code: String,
992        inspected_at: DateTime<Utc>,
993    },
994}
995
996#[derive(Debug, Clone, PartialEq, Eq)]
997pub enum ObjectInspectionVerdict {
998    Accept,
999    Reject { code: String },
1000}
1001
1002#[async_trait]
1003pub trait ObjectContentInspector: Send + Sync + fmt::Debug {
1004    fn id(&self) -> &str;
1005    async fn inspect(
1006        &self,
1007        object: &ObjectReadHead,
1008    ) -> Result<ObjectInspectionVerdict, ObjectTransferError>;
1009}
1010
1011pub async fn inspect_quarantined_object(
1012    inspector: &dyn ObjectContentInspector,
1013    object: &ObjectReadHead,
1014    now: DateTime<Utc>,
1015) -> Result<ObjectValidationState, ObjectTransferError> {
1016    let id = validate_inspector_id(inspector.id())?;
1017    match inspector.inspect(object).await? {
1018        ObjectInspectionVerdict::Accept => Ok(ObjectValidationState::Accepted {
1019            inspector: id,
1020            inspected_at: now,
1021        }),
1022        ObjectInspectionVerdict::Reject { code } => {
1023            let code = validate_inspection_code(&code)?;
1024            Ok(ObjectValidationState::Rejected {
1025                inspector: id,
1026                code,
1027                inspected_at: now,
1028            })
1029        }
1030    }
1031}
1032
1033/// Usage inputs for structural cost review. Monetary rates are deliberately
1034/// absent because Region, account, storage class and destination are external.
1035#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1036pub struct ObjectTransferCostUsage {
1037    pub retained_bytes: u64,
1038    pub incomplete_multipart_bytes: u64,
1039    pub single_upload_requests: u64,
1040    pub multipart_initiations: u64,
1041    pub multipart_part_attempts: u64,
1042    pub multipart_completions: u64,
1043    pub multipart_aborts: u64,
1044    pub metadata_requests: u64,
1045    pub download_requests: u64,
1046    pub downloaded_bytes: u64,
1047    pub accelerated_bytes: u64,
1048    pub edge_requests: u64,
1049    pub edge_egress_bytes: u64,
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1053pub struct ObjectTransferCostProjection {
1054    pub complete: bool,
1055    pub fixed_compute: bool,
1056    pub api_relay_bytes: u64,
1057    pub usage: ObjectTransferCostUsage,
1058    pub missing_rates: Vec<String>,
1059    pub notes: Vec<String>,
1060}
1061
1062#[must_use]
1063pub fn estimate_object_transfer_cost(
1064    usage: ObjectTransferCostUsage,
1065) -> ObjectTransferCostProjection {
1066    let mut missing_rates = Vec::new();
1067    if usage.retained_bytes > 0 || usage.incomplete_multipart_bytes > 0 {
1068        missing_rates.push("storage_byte_month".into());
1069    }
1070    if usage.single_upload_requests
1071        + usage.multipart_initiations
1072        + usage.multipart_part_attempts
1073        + usage.multipart_completions
1074        + usage.multipart_aborts
1075        + usage.metadata_requests
1076        + usage.download_requests
1077        > 0
1078    {
1079        missing_rates.push("provider_request".into());
1080    }
1081    if usage.downloaded_bytes > 0 {
1082        missing_rates.push("provider_egress_byte".into());
1083    }
1084    if usage.accelerated_bytes > 0 {
1085        missing_rates.push("acceleration_byte".into());
1086    }
1087    if usage.edge_requests > 0 {
1088        missing_rates.push("edge_request".into());
1089    }
1090    if usage.edge_egress_bytes > 0 {
1091        missing_rates.push("edge_egress_byte".into());
1092    }
1093    ObjectTransferCostProjection {
1094        complete: missing_rates.is_empty(),
1095        fixed_compute: false,
1096        api_relay_bytes: 0,
1097        usage,
1098        missing_rates,
1099        notes: vec![
1100            "Direct transfer excludes Lambda and API Gateway file-body relay.".into(),
1101            "Incomplete multipart bytes accrue storage cost until completion, abort, or lifecycle cleanup.".into(),
1102            "A provider bill additionally requires account, Region, storage class, destination, retention, and current rates.".into(),
1103        ],
1104    }
1105}
1106
1107#[non_exhaustive]
1108#[derive(Debug, thiserror::Error)]
1109pub enum ObjectTransferError {
1110    #[error("object byte range is invalid")]
1111    InvalidRange,
1112    #[error("object byte range cannot be satisfied")]
1113    RangeNotSatisfiable,
1114    #[error("the requested object does not exist")]
1115    MissingObject,
1116    #[error("the object changed after the client observed it")]
1117    PreconditionFailed,
1118    #[error("download filename is invalid")]
1119    InvalidDownloadName,
1120    #[error("capability expiry must be greater than zero and no more than 24 hours")]
1121    InvalidExpiry,
1122    #[error("multipart maximum size is invalid")]
1123    InvalidMaximumSize,
1124    #[error("multipart part size is invalid")]
1125    InvalidPartSize,
1126    #[error("multipart upload would exceed the provider part limit")]
1127    TooManyParts,
1128    #[error("multipart part number is invalid")]
1129    InvalidPartNumber,
1130    #[error("upload content type is invalid")]
1131    InvalidContentType,
1132    #[error("upload policy must allow at least one content type")]
1133    EmptyContentTypeAllowlist,
1134    #[error("upload content type is not allowed: {0}")]
1135    UnsupportedContentType(String),
1136    #[error("upload body must not be empty")]
1137    EmptyObject,
1138    #[error("the requested upload is {actual} bytes; the maximum is {maximum} bytes")]
1139    ObjectTooLarge { actual: u64, maximum: u64 },
1140    #[error("multipart part SHA-256 must be exactly 64 hexadecimal characters")]
1141    InvalidSha256,
1142    #[error("multipart upload attributes are invalid or reserved")]
1143    InvalidAttributes,
1144    #[error("provider multipart upload ID is invalid")]
1145    InvalidProviderUploadId,
1146    #[error("persisted multipart upload state does not match its configured policy")]
1147    InvalidPendingUpload,
1148    #[error("multipart part entity tag is invalid")]
1149    InvalidEntityTag,
1150    #[error("multipart part receipt does not match the issued part")]
1151    PartReceiptMismatch,
1152    #[error("multipart manifest is incomplete: expected {expected} parts, received {actual}")]
1153    IncompletePartManifest { expected: u32, actual: u32 },
1154    #[error("multipart manifest is not consecutive and exact")]
1155    InvalidPartManifest,
1156    #[error("provider completed object metadata does not match the trusted session")]
1157    CompletedObjectMismatch,
1158    #[error("content inspector identity or rejection code is invalid")]
1159    InvalidInspectionResult,
1160    #[error(transparent)]
1161    ObjectStore(#[from] ObjectStoreError),
1162    #[error("object transfer provider failed: {0}")]
1163    Provider(String),
1164}
1165
1166fn validate_expiry(value: TimeDelta) -> Result<(), ObjectTransferError> {
1167    if value <= TimeDelta::zero() || value > TimeDelta::seconds(MAX_CAPABILITY_EXPIRY_SECONDS) {
1168        Err(ObjectTransferError::InvalidExpiry)
1169    } else {
1170        Ok(())
1171    }
1172}
1173
1174fn validate_download_name(value: &str) -> Result<(), ObjectTransferError> {
1175    if value.is_empty()
1176        || value.len() > 255
1177        || !value
1178            .bytes()
1179            .all(|byte| byte == b' ' || byte.is_ascii_graphic())
1180        || value
1181            .chars()
1182            .any(|character| matches!(character, '"' | '\\' | '/' | ';'))
1183    {
1184        Err(ObjectTransferError::InvalidDownloadName)
1185    } else {
1186        Ok(())
1187    }
1188}
1189
1190fn normalize_content_type(value: &str) -> Result<String, ObjectTransferError> {
1191    let value = value.trim().to_ascii_lowercase();
1192    let Some((top, subtype)) = value.split_once('/') else {
1193        return Err(ObjectTransferError::InvalidContentType);
1194    };
1195    if value.len() > 255
1196        || subtype.contains('/')
1197        || !valid_media_token(top)
1198        || !valid_media_token(subtype)
1199    {
1200        Err(ObjectTransferError::InvalidContentType)
1201    } else {
1202        Ok(value)
1203    }
1204}
1205
1206fn valid_media_token(value: &str) -> bool {
1207    !value.is_empty()
1208        && value.bytes().all(|byte| {
1209            byte.is_ascii_alphanumeric()
1210                || matches!(
1211                    byte,
1212                    b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-'
1213                )
1214        })
1215}
1216
1217fn normalize_sha256(value: &str) -> Result<String, ObjectTransferError> {
1218    let value = value.trim();
1219    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1220        Err(ObjectTransferError::InvalidSha256)
1221    } else {
1222        Ok(value.to_ascii_lowercase())
1223    }
1224}
1225
1226fn validate_attributes(
1227    attributes: BTreeMap<String, String>,
1228) -> Result<BTreeMap<String, String>, ObjectTransferError> {
1229    if attributes.len() > 31
1230        || attributes.iter().any(|(key, value)| {
1231            key.trim().is_empty()
1232                || key.starts_with(RESERVED_ATTRIBUTE_PREFIX)
1233                || key.len() > 128
1234                || value.len() > 1_024
1235                || key.chars().any(char::is_control)
1236                || value.chars().any(char::is_control)
1237        })
1238    {
1239        Err(ObjectTransferError::InvalidAttributes)
1240    } else {
1241        Ok(attributes)
1242    }
1243}
1244
1245fn validate_entity_tag(value: &str) -> Result<(), ObjectTransferError> {
1246    if value.is_empty()
1247        || value.len() > crate::MAX_MULTIPART_ENTITY_TAG_BYTES
1248        || value.starts_with("W/")
1249        || value.chars().any(char::is_control)
1250    {
1251        Err(ObjectTransferError::InvalidEntityTag)
1252    } else {
1253        Ok(())
1254    }
1255}
1256
1257fn validate_inspector_id(value: &str) -> Result<String, ObjectTransferError> {
1258    if valid_identifier(value) {
1259        Ok(value.into())
1260    } else {
1261        Err(ObjectTransferError::InvalidInspectionResult)
1262    }
1263}
1264
1265fn validate_inspection_code(value: &str) -> Result<String, ObjectTransferError> {
1266    if valid_identifier(value) {
1267        Ok(value.into())
1268    } else {
1269        Err(ObjectTransferError::InvalidInspectionResult)
1270    }
1271}
1272
1273fn valid_identifier(value: &str) -> bool {
1274    !value.is_empty()
1275        && value.len() <= 128
1276        && value.bytes().all(|byte| {
1277            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
1278        })
1279}
1280
1281fn transfer_to_store(error: &ObjectTransferError) -> ObjectStoreError {
1282    ObjectStoreError::Store(error.to_string())
1283}