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#[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 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct SignObjectUpload {
90 pub key: ObjectKey,
91 pub content_type: String,
92 pub size_bytes: u64,
93 pub sha256: String,
95 pub expires_in: TimeDelta,
96 pub attributes: BTreeMap<String, String>,
97}
98
99#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct IssueObjectUpload {
176 pub content_type: String,
177 pub size_bytes: u64,
178 pub sha256: String,
180 #[serde(default)]
181 pub attributes: BTreeMap<String, String>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct ObjectUploadGrant {
188 pub key: ObjectKey,
189 pub request: PresignedObjectRequest,
190}
191
192#[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 pub capability_expires_at: DateTime<Utc>,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct IssuedObjectUpload {
216 pub grant: ObjectUploadGrant,
217 pub pending: PendingObjectUpload,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct VerifiedObjectUpload {
223 pub key: ObjectKey,
224 pub metadata: ObjectHead,
225}
226
227#[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#[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#[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 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 #[must_use]
456 pub fn with_transfer_services(mut self, transfers: ManagedObjectTransferServices) -> Self {
457 self.transfers = Some(transfers);
458 self
459 }
460
461 #[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}