Skip to main content

minco_plugin_object_storage/
uploads.rs

1use crate::{
2    MemoryObjectStore, MultipartObjectService, ObjectAccessSigner, ObjectDownloadService,
3    ObjectKey, ObjectMetadata, ObjectReadService, ObjectStoragePlugin, ObjectStore,
4    ObjectStoreError, PresignedObjectRequest,
5};
6use async_trait::async_trait;
7use chrono::{DateTime, TimeDelta, Utc};
8use minco_core::{CapabilityProvision, Plugin, PluginContext, PluginDescriptor, PluginError};
9use semver::Version;
10use serde::{Deserialize, Serialize};
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    sync::Arc,
14};
15use uuid::Uuid;
16
17#[cfg(feature = "http")]
18use crate::{ObjectTransferHttpService, object_transfer_http_module, object_transfer_operations};
19
20const MAX_UPLOAD_EXPIRY_SECONDS: i64 = 24 * 60 * 60;
21const DEFAULT_UPLOAD_EXPIRY_SECONDS: i64 = 15 * 60;
22const UPLOAD_ID_ATTRIBUTE: &str = "minco.upload_id";
23const RESERVED_ATTRIBUTE_PREFIX: &str = "minco.";
24
25/// Metadata returned without loading an object's bytes into application memory.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ObjectHead {
28    pub key: ObjectKey,
29    pub content_type: String,
30    pub size_bytes: u64,
31    /// SHA-256 is optional because a provider can report metadata for legacy
32    /// objects that were not uploaded with a provider checksum.
33    pub sha256: Option<String>,
34    pub created_at: DateTime<Utc>,
35    #[serde(default)]
36    pub attributes: BTreeMap<String, String>,
37}
38
39impl ObjectHead {
40    fn from_metadata(key: ObjectKey, metadata: ObjectMetadata) -> Self {
41        Self {
42            key,
43            content_type: metadata.content_type,
44            size_bytes: metadata.size_bytes,
45            sha256: Some(metadata.sha256),
46            created_at: metadata.created_at,
47            attributes: metadata.attributes,
48        }
49    }
50}
51
52/// Provider port for an inexpensive metadata lookup such as S3 `HeadObject`.
53#[async_trait]
54pub trait ObjectMetadataReader: Send + Sync + std::fmt::Debug {
55    async fn head(&self, key: &ObjectKey) -> Result<Option<ObjectHead>, ObjectStoreError>;
56}
57
58#[async_trait]
59impl ObjectMetadataReader for MemoryObjectStore {
60    async fn head(&self, key: &ObjectKey) -> Result<Option<ObjectHead>, ObjectStoreError> {
61        ObjectStore::get(self, key).await.map(|object| {
62            object.map(|stored| ObjectHead::from_metadata(stored.key, stored.metadata))
63        })
64    }
65}
66
67/// Typed metadata service injected by a managed object-storage plugin.
68#[derive(Clone)]
69pub struct ObjectMetadataService(Arc<dyn ObjectMetadataReader>);
70
71impl std::fmt::Debug for ObjectMetadataService {
72    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        formatter.debug_tuple("ObjectMetadataService").finish()
74    }
75}
76
77impl ObjectMetadataService {
78    pub fn new(reader: Arc<dyn ObjectMetadataReader>) -> Self {
79        Self(reader)
80    }
81
82    pub async fn head(&self, key: &ObjectKey) -> Result<Option<ObjectHead>, ObjectStoreError> {
83        self.0.head(key).await
84    }
85}
86
87/// Exact provider request for one checksummed direct upload.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct SignObjectUpload {
90    pub key: ObjectKey,
91    pub content_type: String,
92    pub size_bytes: u64,
93    /// Lowercase hexadecimal SHA-256 of the complete object body.
94    pub sha256: String,
95    pub expires_in: TimeDelta,
96    pub attributes: BTreeMap<String, String>,
97}
98
99/// Provider adapter that can bind an upload capability to exact bytes.
100///
101/// This is separate from [`ObjectAccessSigner`] so the compatibility-preserving
102/// `PresignPutObject` contract can remain available while managed uploads require
103/// an exact size and SHA-256 checksum.
104#[async_trait]
105pub trait ObjectUploadSigner: Send + Sync + std::fmt::Debug {
106    async fn sign_upload(
107        &self,
108        request: SignObjectUpload,
109    ) -> Result<PresignedObjectRequest, ObjectUploadError>;
110}
111
112/// Closed upload policy owned by the application composition root.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ObjectUploadPolicy {
115    key_prefix: ObjectKey,
116    allowed_content_types: BTreeSet<String>,
117    maximum_size_bytes: u64,
118    expires_in: TimeDelta,
119}
120
121impl ObjectUploadPolicy {
122    pub fn new<I, S>(
123        key_prefix: ObjectKey,
124        maximum_size_bytes: u64,
125        allowed_content_types: I,
126    ) -> Result<Self, ObjectUploadError>
127    where
128        I: IntoIterator<Item = S>,
129        S: AsRef<str>,
130    {
131        if maximum_size_bytes == 0 {
132            return Err(ObjectUploadError::InvalidMaximumSize);
133        }
134        let allowed_content_types = allowed_content_types
135            .into_iter()
136            .map(|value| normalize_content_type(value.as_ref()))
137            .collect::<Result<BTreeSet<_>, _>>()?;
138        if allowed_content_types.is_empty() {
139            return Err(ObjectUploadError::EmptyContentTypeAllowlist);
140        }
141        Ok(Self {
142            key_prefix,
143            allowed_content_types,
144            maximum_size_bytes,
145            expires_in: TimeDelta::seconds(DEFAULT_UPLOAD_EXPIRY_SECONDS),
146        })
147    }
148
149    pub fn with_expiry(mut self, expires_in: TimeDelta) -> Result<Self, ObjectUploadError> {
150        validate_expiry(expires_in)?;
151        self.expires_in = expires_in;
152        Ok(self)
153    }
154
155    pub const fn key_prefix(&self) -> &ObjectKey {
156        &self.key_prefix
157    }
158
159    pub const fn maximum_size_bytes(&self) -> u64 {
160        self.maximum_size_bytes
161    }
162
163    pub const fn expires_in(&self) -> TimeDelta {
164        self.expires_in
165    }
166
167    pub fn allows_content_type(&self, content_type: &str) -> bool {
168        normalize_content_type(content_type)
169            .is_ok_and(|value| self.allowed_content_types.contains(&value))
170    }
171}
172
173/// Application-authorized request for one direct object upload.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct IssueObjectUpload {
176    pub content_type: String,
177    pub size_bytes: u64,
178    /// Lowercase or uppercase hexadecimal SHA-256 of the complete file.
179    pub sha256: String,
180    #[serde(default)]
181    pub attributes: BTreeMap<String, String>,
182}
183
184/// Client-facing bearer capability. Send this value to the authorized client,
185/// but never persist or log it as the trusted upload record.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct ObjectUploadGrant {
188    pub key: ObjectKey,
189    pub request: PresignedObjectRequest,
190}
191
192/// Trusted server-side record for one issued upload capability.
193///
194/// Persist this value in application-owned state. Do not accept a replacement
195/// from an untrusted client as authorization.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct PendingObjectUpload {
198    pub key: ObjectKey,
199    pub expected_content_type: String,
200    pub expected_size_bytes: u64,
201    pub expected_sha256: String,
202    pub expected_attributes: BTreeMap<String, String>,
203    /// Expiry of the bearer upload capability, not of the pending record.
204    ///
205    /// Verification may happen after this timestamp when the provider accepted
206    /// the upload before the capability expired. Pending-record retention and
207    /// cleanup remain application-owned.
208    pub capability_expires_at: DateTime<Utc>,
209}
210
211/// Split result that prevents the bearer request from becoming trusted state by
212/// accident. Return only [`Self::grant`] to the client and retain
213/// [`Self::pending`] on the server.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct IssuedObjectUpload {
216    pub grant: ObjectUploadGrant,
217    pub pending: PendingObjectUpload,
218}
219
220/// Metadata accepted after the provider confirms the issued upload contract.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct VerifiedObjectUpload {
223    pub key: ObjectKey,
224    pub metadata: ObjectHead,
225}
226
227/// Issues unique direct-upload capabilities and verifies their provider metadata.
228#[derive(Clone)]
229pub struct ObjectUploadService {
230    signer: Arc<dyn ObjectUploadSigner>,
231    metadata: ObjectMetadataService,
232    policy: ObjectUploadPolicy,
233}
234
235impl std::fmt::Debug for ObjectUploadService {
236    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        formatter
238            .debug_struct("ObjectUploadService")
239            .field("policy", &self.policy)
240            .finish_non_exhaustive()
241    }
242}
243
244impl ObjectUploadService {
245    pub const fn new(
246        signer: Arc<dyn ObjectUploadSigner>,
247        metadata: ObjectMetadataService,
248        policy: ObjectUploadPolicy,
249    ) -> Self {
250        Self {
251            signer,
252            metadata,
253            policy,
254        }
255    }
256
257    pub async fn issue(
258        &self,
259        request: IssueObjectUpload,
260    ) -> Result<IssuedObjectUpload, ObjectUploadError> {
261        let IssueObjectUpload {
262            content_type,
263            size_bytes,
264            sha256,
265            attributes,
266        } = request;
267        let content_type = normalize_content_type(&content_type)?;
268        if !self.policy.allowed_content_types.contains(&content_type) {
269            return Err(ObjectUploadError::UnsupportedContentType(content_type));
270        }
271        if size_bytes == 0 {
272            return Err(ObjectUploadError::EmptyObject);
273        }
274        if size_bytes > self.policy.maximum_size_bytes {
275            return Err(ObjectUploadError::ObjectTooLarge {
276                actual: size_bytes,
277                maximum: self.policy.maximum_size_bytes,
278            });
279        }
280        let sha256 = normalize_sha256(&sha256)?;
281        let mut attributes = validate_attributes(attributes)?;
282        let upload_id = Uuid::now_v7().to_string();
283        attributes.insert(UPLOAD_ID_ATTRIBUTE.to_owned(), upload_id.clone());
284        let key = generated_key(&self.policy.key_prefix, &upload_id)?;
285        let signed = self
286            .signer
287            .sign_upload(SignObjectUpload {
288                key: key.clone(),
289                content_type: content_type.clone(),
290                size_bytes,
291                sha256: sha256.clone(),
292                expires_in: self.policy.expires_in,
293                attributes: attributes.clone(),
294            })
295            .await?;
296        let capability_expires_at = signed.expires_at;
297        Ok(IssuedObjectUpload {
298            grant: ObjectUploadGrant {
299                key: key.clone(),
300                request: signed,
301            },
302            pending: PendingObjectUpload {
303                key,
304                expected_content_type: content_type,
305                expected_size_bytes: size_bytes,
306                expected_sha256: sha256,
307                expected_attributes: attributes,
308                capability_expires_at,
309            },
310        })
311    }
312
313    pub async fn verify(
314        &self,
315        pending: &PendingObjectUpload,
316    ) -> Result<VerifiedObjectUpload, ObjectUploadError> {
317        self.validate_pending(pending)?;
318        let Some(metadata) = self.metadata.head(&pending.key).await? else {
319            return Err(ObjectUploadError::MissingObject);
320        };
321        if metadata.key != pending.key {
322            return Err(ObjectUploadError::ObjectKeyMismatch);
323        }
324        let actual_content_type = normalize_content_type(&metadata.content_type)
325            .map_err(|_| ObjectUploadError::ContentTypeMismatch)?;
326        if actual_content_type != pending.expected_content_type {
327            return Err(ObjectUploadError::ContentTypeMismatch);
328        }
329        if metadata.size_bytes != pending.expected_size_bytes {
330            return Err(ObjectUploadError::ObjectSizeMismatch {
331                actual: metadata.size_bytes,
332                expected: pending.expected_size_bytes,
333            });
334        }
335        let actual_sha256 = metadata
336            .sha256
337            .as_deref()
338            .map(normalize_sha256)
339            .transpose()
340            .map_err(|_| ObjectUploadError::ChecksumMismatch)?;
341        if actual_sha256.as_deref() != Some(pending.expected_sha256.as_str()) {
342            return Err(ObjectUploadError::ChecksumMismatch);
343        }
344        if metadata.attributes != pending.expected_attributes {
345            return Err(ObjectUploadError::AttributeMismatch);
346        }
347        Ok(VerifiedObjectUpload {
348            key: pending.key.clone(),
349            metadata,
350        })
351    }
352
353    fn validate_pending(&self, pending: &PendingObjectUpload) -> Result<(), ObjectUploadError> {
354        let content_type = normalize_content_type(&pending.expected_content_type)
355            .map_err(|_| ObjectUploadError::InvalidPendingUpload)?;
356        let sha256 = normalize_sha256(&pending.expected_sha256)
357            .map_err(|_| ObjectUploadError::InvalidPendingUpload)?;
358        let mut attributes = pending.expected_attributes.clone();
359        let upload_id = attributes
360            .remove(UPLOAD_ID_ATTRIBUTE)
361            .ok_or(ObjectUploadError::InvalidPendingUpload)?;
362        let parsed_upload_id =
363            Uuid::parse_str(&upload_id).map_err(|_| ObjectUploadError::InvalidPendingUpload)?;
364        validate_attributes(attributes).map_err(|_| ObjectUploadError::InvalidPendingUpload)?;
365        let expected_key = generated_key(&self.policy.key_prefix, &upload_id)
366            .map_err(|_| ObjectUploadError::InvalidPendingUpload)?;
367        if content_type != pending.expected_content_type
368            || !self.policy.allowed_content_types.contains(&content_type)
369            || pending.expected_size_bytes == 0
370            || pending.expected_size_bytes > self.policy.maximum_size_bytes
371            || sha256 != pending.expected_sha256
372            || parsed_upload_id.to_string() != upload_id
373            || parsed_upload_id.get_version_num() != 7
374            || pending.key != expected_key
375        {
376            return Err(ObjectUploadError::InvalidPendingUpload);
377        }
378        Ok(())
379    }
380}
381
382/// Object-storage plugin with the direct-upload and metadata lifecycle installed.
383#[derive(Debug, Clone)]
384pub struct ManagedObjectStoragePlugin {
385    storage: ObjectStoragePlugin,
386    metadata: ObjectMetadataService,
387    uploads: ObjectUploadService,
388    transfers: Option<ManagedObjectTransferServices>,
389    #[cfg(feature = "http")]
390    http: Option<ObjectTransferHttpService>,
391}
392
393/// Statically selected large-transfer services installed as one coherent
394/// provider profile. The application still owns authorization and session
395/// persistence.
396#[derive(Debug, Clone)]
397pub struct ManagedObjectTransferServices {
398    pub reads: ObjectReadService,
399    pub downloads: ObjectDownloadService,
400    pub multipart: MultipartObjectService,
401}
402
403impl ManagedObjectTransferServices {
404    pub const fn new(
405        reads: ObjectReadService,
406        downloads: ObjectDownloadService,
407        multipart: MultipartObjectService,
408    ) -> Self {
409        Self {
410            reads,
411            downloads,
412            multipart,
413        }
414    }
415}
416
417impl ManagedObjectStoragePlugin {
418    pub fn new<S>(
419        store: Arc<dyn ObjectStore>,
420        signer: Arc<S>,
421        metadata_reader: Arc<dyn ObjectMetadataReader>,
422        policy: ObjectUploadPolicy,
423    ) -> Self
424    where
425        S: ObjectAccessSigner + ObjectUploadSigner + 'static,
426    {
427        let access_signer: Arc<dyn ObjectAccessSigner> = signer.clone();
428        let upload_signer: Arc<dyn ObjectUploadSigner> = signer;
429        Self::new_with_signers(store, access_signer, upload_signer, metadata_reader, policy)
430    }
431
432    /// Construct managed storage with independent private-download and upload
433    /// signers while retaining static, typed composition.
434    pub fn new_with_signers(
435        store: Arc<dyn ObjectStore>,
436        access_signer: Arc<dyn ObjectAccessSigner>,
437        upload_signer: Arc<dyn ObjectUploadSigner>,
438        metadata_reader: Arc<dyn ObjectMetadataReader>,
439        policy: ObjectUploadPolicy,
440    ) -> Self {
441        let metadata = ObjectMetadataService::new(metadata_reader);
442        let uploads = ObjectUploadService::new(upload_signer, metadata.clone(), policy);
443        Self {
444            storage: ObjectStoragePlugin::new(store).with_access_signer(access_signer),
445            metadata,
446            uploads,
447            transfers: None,
448            #[cfg(feature = "http")]
449            http: None,
450        }
451    }
452
453    /// Install range streaming, private download grants, and multipart upload
454    /// through the same explicit provider composition.
455    #[must_use]
456    pub fn with_transfer_services(mut self, transfers: ManagedObjectTransferServices) -> Self {
457        self.transfers = Some(transfers);
458        self
459    }
460
461    /// Contribute the authenticated HTTP control plane. The injected use-case
462    /// service owns authorization and durable upload/object state.
463    #[cfg(feature = "http")]
464    #[must_use]
465    pub fn with_http_api(mut self, http: ObjectTransferHttpService) -> Self {
466        self.http = Some(http);
467        self
468    }
469}
470
471impl Plugin for ManagedObjectStoragePlugin {
472    fn descriptor(&self) -> PluginDescriptor {
473        let mut descriptor = self.storage.descriptor();
474        descriptor.provides.extend([
475            CapabilityProvision {
476                name: "storage.object.metadata".into(),
477                version: Version::new(1, 0, 0),
478            },
479            CapabilityProvision {
480                name: "storage.object.upload".into(),
481                version: Version::new(1, 0, 0),
482            },
483        ]);
484        if self.transfers.is_some() {
485            descriptor.provides.extend([
486                CapabilityProvision {
487                    name: "storage.object.stream".into(),
488                    version: Version::new(1, 0, 0),
489                },
490                CapabilityProvision {
491                    name: "storage.object.download".into(),
492                    version: Version::new(1, 0, 0),
493                },
494                CapabilityProvision {
495                    name: "storage.object.multipart".into(),
496                    version: Version::new(1, 0, 0),
497                },
498            ]);
499        }
500        #[cfg(feature = "http")]
501        if self.http.is_some() {
502            descriptor.provides.push(CapabilityProvision {
503                name: "storage.object.http".into(),
504                version: Version::new(1, 0, 0),
505            });
506            descriptor.operations.extend(object_transfer_operations());
507        }
508        descriptor
509    }
510
511    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
512        self.storage.install(context)?;
513        context.services().insert(Arc::new(self.metadata.clone()))?;
514        context.services().insert(Arc::new(self.uploads.clone()))?;
515        if let Some(transfers) = &self.transfers {
516            context
517                .services()
518                .insert(Arc::new(transfers.reads.clone()))?;
519            context
520                .services()
521                .insert(Arc::new(transfers.downloads.clone()))?;
522            context
523                .services()
524                .insert(Arc::new(transfers.multipart.clone()))?;
525        }
526        #[cfg(feature = "http")]
527        if let Some(http) = &self.http {
528            object_transfer_http_module(context.plugin_id().clone(), http.clone())
529                .contribute(context);
530        }
531        Ok(())
532    }
533}
534
535#[non_exhaustive]
536#[derive(Debug, thiserror::Error)]
537pub enum ObjectUploadError {
538    #[error("upload policy maximum size must be greater than zero")]
539    InvalidMaximumSize,
540    #[error("upload policy must allow at least one exact content type")]
541    EmptyContentTypeAllowlist,
542    #[error("upload content type is invalid")]
543    InvalidContentType,
544    #[error("upload content type is not allowed: {0}")]
545    UnsupportedContentType(String),
546    #[error("upload body must not be empty")]
547    EmptyObject,
548    #[error("upload SHA-256 must be exactly 64 hexadecimal characters")]
549    InvalidSha256,
550    #[error("upload attributes are invalid or use a reserved Minco key")]
551    InvalidAttributes,
552    #[error("upload expiry must be greater than zero and no more than 24 hours")]
553    InvalidExpiry,
554    #[error("persisted upload state does not match its configured policy")]
555    InvalidPendingUpload,
556    #[error("the uploaded object does not exist")]
557    MissingObject,
558    #[error("the provider reported metadata for a different object key")]
559    ObjectKeyMismatch,
560    #[error("the uploaded object's content type does not match the issued capability")]
561    ContentTypeMismatch,
562    #[error("the uploaded object's signed attributes do not match the issued capability")]
563    AttributeMismatch,
564    #[error("the uploaded object's SHA-256 does not match the issued capability")]
565    ChecksumMismatch,
566    #[error("the requested upload is {actual} bytes; the policy maximum is {maximum} bytes")]
567    ObjectTooLarge { actual: u64, maximum: u64 },
568    #[error("the uploaded object is {actual} bytes; the issued size is {expected} bytes")]
569    ObjectSizeMismatch { actual: u64, expected: u64 },
570    #[error("the provider endpoint for the upload capability could not be resolved")]
571    EndpointResolution,
572    #[error("the signing credentials have an invalid expiration time")]
573    InvalidCredentialExpiry,
574    #[error("the signing credentials expire too soon to issue an upload capability")]
575    CredentialLifetimeTooShort,
576    #[error(transparent)]
577    ObjectStore(#[from] ObjectStoreError),
578}
579
580fn validate_expiry(expires_in: TimeDelta) -> Result<(), ObjectUploadError> {
581    if expires_in <= TimeDelta::zero() || expires_in > TimeDelta::seconds(MAX_UPLOAD_EXPIRY_SECONDS)
582    {
583        Err(ObjectUploadError::InvalidExpiry)
584    } else {
585        Ok(())
586    }
587}
588
589fn normalize_content_type(value: &str) -> Result<String, ObjectUploadError> {
590    let value = value.trim().to_ascii_lowercase();
591    let Some((top_level, subtype)) = value.split_once('/') else {
592        return Err(ObjectUploadError::InvalidContentType);
593    };
594    if value.len() > 255
595        || subtype.contains('/')
596        || !valid_media_token(top_level)
597        || !valid_media_token(subtype)
598    {
599        return Err(ObjectUploadError::InvalidContentType);
600    }
601    Ok(value)
602}
603
604fn valid_media_token(value: &str) -> bool {
605    !value.is_empty()
606        && value.bytes().all(|byte| {
607            byte.is_ascii_alphanumeric()
608                || matches!(
609                    byte,
610                    b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-'
611                )
612        })
613}
614
615fn normalize_sha256(value: &str) -> Result<String, ObjectUploadError> {
616    let value = value.trim();
617    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
618        return Err(ObjectUploadError::InvalidSha256);
619    }
620    Ok(value.to_ascii_lowercase())
621}
622
623fn validate_attributes(
624    attributes: BTreeMap<String, String>,
625) -> Result<BTreeMap<String, String>, ObjectUploadError> {
626    if attributes.len() > 31
627        || attributes.iter().any(|(key, value)| {
628            key.trim().is_empty()
629                || key.starts_with(RESERVED_ATTRIBUTE_PREFIX)
630                || key.len() > 128
631                || value.len() > 1024
632                || key.chars().any(char::is_control)
633                || value.chars().any(char::is_control)
634        })
635    {
636        return Err(ObjectUploadError::InvalidAttributes);
637    }
638    Ok(attributes)
639}
640
641fn generated_key(prefix: &ObjectKey, upload_id: &str) -> Result<ObjectKey, ObjectUploadError> {
642    ObjectKey::parse(format!("{}/{upload_id}", prefix.as_str())).map_err(ObjectUploadError::from)
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use crate::{PresignGetObject, PresignPutObject, PresignedMethod, PutObject};
649    use minco_core::{PluginId, PluginManager, PluginSelection};
650    use sha2::{Digest, Sha256};
651    use std::sync::atomic::{AtomicUsize, Ordering};
652
653    #[derive(Debug)]
654    struct TestSigner;
655
656    #[async_trait]
657    impl ObjectAccessSigner for TestSigner {
658        async fn sign_put(
659            &self,
660            request: PresignPutObject,
661        ) -> Result<PresignedObjectRequest, ObjectStoreError> {
662            Ok(PresignedObjectRequest {
663                method: PresignedMethod::Post,
664                url: "https://objects.example/upload".into(),
665                headers: BTreeMap::new(),
666                form_fields: BTreeMap::from([
667                    ("key".into(), request.key.as_str().to_owned()),
668                    ("content-type".into(), request.content_type),
669                    (
670                        "maximum-size".into(),
671                        request.maximum_size_bytes.to_string(),
672                    ),
673                ]),
674                expires_at: Utc::now() + request.expires_in,
675            })
676        }
677
678        async fn sign_get(
679            &self,
680            request: PresignGetObject,
681        ) -> Result<PresignedObjectRequest, ObjectStoreError> {
682            Ok(PresignedObjectRequest {
683                method: PresignedMethod::Get,
684                url: format!("https://objects.example/{}", request.key.as_str()),
685                headers: BTreeMap::new(),
686                form_fields: BTreeMap::new(),
687                expires_at: Utc::now() + request.expires_in,
688            })
689        }
690    }
691
692    #[async_trait]
693    impl ObjectUploadSigner for TestSigner {
694        async fn sign_upload(
695            &self,
696            request: SignObjectUpload,
697        ) -> Result<PresignedObjectRequest, ObjectUploadError> {
698            Ok(PresignedObjectRequest {
699                method: PresignedMethod::Post,
700                url: "https://objects.example/upload".into(),
701                headers: BTreeMap::new(),
702                form_fields: BTreeMap::from([
703                    ("key".into(), request.key.as_str().to_owned()),
704                    ("content-type".into(), request.content_type),
705                    ("size-bytes".into(), request.size_bytes.to_string()),
706                    ("sha256".into(), request.sha256),
707                ]),
708                expires_at: Utc::now() + request.expires_in,
709            })
710        }
711    }
712
713    fn sha256(bytes: &[u8]) -> String {
714        hex::encode(Sha256::digest(bytes))
715    }
716
717    fn policy() -> ObjectUploadPolicy {
718        ObjectUploadPolicy::new(
719            ObjectKey::parse("uploads/images").unwrap(),
720            1_024,
721            ["image/png", "image/jpeg"],
722        )
723        .unwrap()
724    }
725
726    fn service(store: Arc<MemoryObjectStore>) -> ObjectUploadService {
727        let metadata: Arc<dyn ObjectMetadataReader> = store;
728        ObjectUploadService::new(
729            Arc::new(TestSigner),
730            ObjectMetadataService::new(metadata),
731            policy(),
732        )
733    }
734
735    fn request() -> IssueObjectUpload {
736        IssueObjectUpload {
737            content_type: "IMAGE/PNG".into(),
738            size_bytes: 3,
739            sha256: sha256(b"png"),
740            attributes: BTreeMap::from([("tenant".into(), "acme".into())]),
741        }
742    }
743
744    #[test]
745    fn policy_is_closed_and_bounded() {
746        assert!(
747            ObjectUploadPolicy::new(ObjectKey::parse("uploads").unwrap(), 0, ["image/png"])
748                .is_err()
749        );
750        assert!(
751            ObjectUploadPolicy::new(
752                ObjectKey::parse("uploads").unwrap(),
753                1,
754                std::iter::empty::<&str>()
755            )
756            .is_err()
757        );
758        assert!(policy().with_expiry(TimeDelta::hours(25)).is_err());
759        assert!(policy().allows_content_type("IMAGE/PNG"));
760        assert!(!policy().allows_content_type("text/html"));
761    }
762
763    #[tokio::test]
764    async fn issuing_an_upload_separates_the_bearer_grant_from_trusted_state() {
765        let service = service(Arc::new(MemoryObjectStore::default()));
766        let first = service.issue(request()).await.unwrap();
767        let second = service.issue(request()).await.unwrap();
768        assert_ne!(first.grant.key, second.grant.key);
769        assert_eq!(first.grant.key, first.pending.key);
770        assert!(first.grant.key.as_str().starts_with("uploads/images/"));
771        assert!(
772            std::path::Path::new(first.grant.key.as_str())
773                .extension()
774                .is_none()
775        );
776        assert_eq!(first.pending.expected_content_type, "image/png");
777        assert_eq!(first.pending.expected_size_bytes, 3);
778        assert_eq!(first.pending.expected_sha256, sha256(b"png"));
779        assert!(
780            first
781                .pending
782                .expected_attributes
783                .contains_key(UPLOAD_ID_ATTRIBUTE)
784        );
785        assert_eq!(
786            first
787                .grant
788                .request
789                .form_fields
790                .get("size-bytes")
791                .map(String::as_str),
792            Some("3")
793        );
794        assert_eq!(
795            first.grant.request.expires_at,
796            first.pending.capability_expires_at
797        );
798    }
799
800    #[tokio::test]
801    async fn issuance_rejects_empty_oversized_or_unchecksummed_objects() {
802        let service = service(Arc::new(MemoryObjectStore::default()));
803
804        let mut empty = request();
805        empty.size_bytes = 0;
806        assert!(matches!(
807            service.issue(empty).await,
808            Err(ObjectUploadError::EmptyObject)
809        ));
810
811        let mut oversized = request();
812        oversized.size_bytes = 1_025;
813        assert!(matches!(
814            service.issue(oversized).await,
815            Err(ObjectUploadError::ObjectTooLarge {
816                actual: 1_025,
817                maximum: 1_024
818            })
819        ));
820
821        let mut invalid_checksum = request();
822        invalid_checksum.sha256 = "not-a-sha256".into();
823        assert!(matches!(
824            service.issue(invalid_checksum).await,
825            Err(ObjectUploadError::InvalidSha256)
826        ));
827    }
828
829    #[tokio::test]
830    async fn verification_accepts_only_the_issued_metadata_contract() {
831        let store = Arc::new(MemoryObjectStore::default());
832        let service = service(Arc::clone(&store));
833        let issued = service.issue(request()).await.unwrap();
834        ObjectStore::put(
835            store.as_ref(),
836            PutObject {
837                key: issued.pending.key.clone(),
838                bytes: b"png".to_vec(),
839                content_type: issued.pending.expected_content_type.clone(),
840                attributes: issued.pending.expected_attributes.clone(),
841            },
842        )
843        .await
844        .unwrap();
845        let verified = service.verify(&issued.pending).await.unwrap();
846        assert_eq!(verified.metadata.size_bytes, 3);
847        assert_eq!(
848            verified.metadata.sha256.as_deref(),
849            Some(sha256(b"png").as_str())
850        );
851
852        let mut wrong_size = issued.pending.clone();
853        wrong_size.expected_size_bytes = 4;
854        assert!(matches!(
855            service.verify(&wrong_size).await,
856            Err(ObjectUploadError::ObjectSizeMismatch {
857                actual: 3,
858                expected: 4
859            })
860        ));
861
862        let mut wrong_checksum = issued.pending.clone();
863        wrong_checksum.expected_sha256 = sha256(b"jpg");
864        assert!(matches!(
865            service.verify(&wrong_checksum).await,
866            Err(ObjectUploadError::ChecksumMismatch)
867        ));
868
869        let mut wrong_attributes = issued.pending;
870        wrong_attributes.expected_attributes.clear();
871        assert!(matches!(
872            service.verify(&wrong_attributes).await,
873            Err(ObjectUploadError::InvalidPendingUpload)
874        ));
875    }
876
877    #[derive(Debug)]
878    struct CountingMetadataReader(Arc<AtomicUsize>);
879
880    #[async_trait]
881    impl ObjectMetadataReader for CountingMetadataReader {
882        async fn head(&self, _key: &ObjectKey) -> Result<Option<ObjectHead>, ObjectStoreError> {
883            self.0.fetch_add(1, Ordering::SeqCst);
884            Ok(None)
885        }
886    }
887
888    #[tokio::test]
889    async fn verification_revalidates_persisted_state_before_provider_contact() {
890        let calls = Arc::new(AtomicUsize::new(0));
891        let service = ObjectUploadService::new(
892            Arc::new(TestSigner),
893            ObjectMetadataService::new(Arc::new(CountingMetadataReader(Arc::clone(&calls)))),
894            policy(),
895        );
896        let issued = service.issue(request()).await.unwrap();
897        let mut invalid = Vec::new();
898
899        let mut wrong_key = issued.pending.clone();
900        wrong_key.key = ObjectKey::parse("uploads/images/different").unwrap();
901        invalid.push(wrong_key);
902
903        let mut noncanonical_type = issued.pending.clone();
904        noncanonical_type.expected_content_type = "IMAGE/PNG".into();
905        invalid.push(noncanonical_type);
906
907        let mut invalid_size = issued.pending.clone();
908        invalid_size.expected_size_bytes = 0;
909        invalid.push(invalid_size);
910
911        let mut oversized = issued.pending.clone();
912        oversized.expected_size_bytes = 1_025;
913        invalid.push(oversized);
914
915        let mut noncanonical_checksum = issued.pending.clone();
916        noncanonical_checksum.expected_sha256 =
917            noncanonical_checksum.expected_sha256.to_ascii_uppercase();
918        invalid.push(noncanonical_checksum);
919
920        let mut missing_upload_id = issued.pending.clone();
921        missing_upload_id
922            .expected_attributes
923            .remove(UPLOAD_ID_ATTRIBUTE);
924        invalid.push(missing_upload_id);
925
926        let mut malformed_upload_id = issued.pending;
927        malformed_upload_id
928            .expected_attributes
929            .insert(UPLOAD_ID_ATTRIBUTE.into(), "not-a-uuid".into());
930        invalid.push(malformed_upload_id);
931
932        let mut wrong_upload_id_version = invalid[0].clone();
933        let nil_upload_id = Uuid::nil().to_string();
934        wrong_upload_id_version.key = generated_key(&policy().key_prefix, &nil_upload_id).unwrap();
935        wrong_upload_id_version
936            .expected_attributes
937            .insert(UPLOAD_ID_ATTRIBUTE.into(), nil_upload_id);
938        invalid.push(wrong_upload_id_version);
939
940        for pending in invalid {
941            assert!(matches!(
942                service.verify(&pending).await,
943                Err(ObjectUploadError::InvalidPendingUpload)
944            ));
945        }
946        assert_eq!(calls.load(Ordering::SeqCst), 0);
947    }
948
949    #[derive(Debug)]
950    struct WrongKeyMetadataReader;
951
952    #[async_trait]
953    impl ObjectMetadataReader for WrongKeyMetadataReader {
954        async fn head(&self, _key: &ObjectKey) -> Result<Option<ObjectHead>, ObjectStoreError> {
955            Ok(Some(ObjectHead {
956                key: ObjectKey::parse("uploads/images/different").unwrap(),
957                content_type: "image/png".into(),
958                size_bytes: 3,
959                sha256: Some(sha256(b"png")),
960                created_at: Utc::now(),
961                attributes: BTreeMap::new(),
962            }))
963        }
964    }
965
966    #[tokio::test]
967    async fn verification_rejects_metadata_for_a_different_logical_key() {
968        let service = ObjectUploadService::new(
969            Arc::new(TestSigner),
970            ObjectMetadataService::new(Arc::new(WrongKeyMetadataReader)),
971            policy(),
972        );
973        let issued = service.issue(request()).await.unwrap();
974        assert!(matches!(
975            service.verify(&issued.pending).await,
976            Err(ObjectUploadError::ObjectKeyMismatch)
977        ));
978    }
979
980    #[test]
981    fn managed_plugin_advertises_and_installs_the_lifecycle() {
982        let store = Arc::new(MemoryObjectStore::default());
983        let object_store: Arc<dyn ObjectStore> = store.clone();
984        let metadata: Arc<dyn ObjectMetadataReader> = store;
985        let mut manager = PluginManager::default();
986        manager
987            .register(ManagedObjectStoragePlugin::new_with_signers(
988                object_store,
989                Arc::new(TestSigner),
990                Arc::new(TestSigner),
991                metadata,
992                policy(),
993            ))
994            .unwrap();
995        let id = PluginId::new("object-storage").unwrap();
996        let mut selection = PluginSelection::default();
997        selection.enabled.insert(id);
998        let application = manager.compose(&selection).unwrap();
999        assert!(
1000            application
1001                .graph
1002                .capabilities
1003                .contains_key("storage.object.upload")
1004        );
1005        assert!(application.services.get::<ObjectUploadService>().is_ok());
1006        assert!(application.services.get::<ObjectMetadataService>().is_ok());
1007    }
1008}