Skip to main content

minco_plugin_object_storage/
lib.rs

1//! Provider-neutral object storage and a deterministic in-memory reference adapter.
2#![forbid(unsafe_code)]
3
4use async_trait::async_trait;
5use chrono::{DateTime, TimeDelta, Utc};
6use minco_core::{
7    CapabilityProvision, DataClass, Plugin, PluginContext, PluginDescriptor, PluginError, PluginId,
8    PluginStability,
9};
10use semver::{Version, VersionReq};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::{collections::BTreeMap, sync::Arc};
14use tokio::sync::RwLock;
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(transparent)]
18pub struct ObjectKey(String);
19
20impl ObjectKey {
21    pub fn parse(value: impl Into<String>) -> Result<Self, ObjectStoreError> {
22        let value = value.into();
23        if value.is_empty()
24            || value.len() > 1024
25            || value.starts_with('/')
26            || value.ends_with('/')
27            || value.split('/').any(|part| {
28                part.is_empty() || part == "." || part == ".." || part.chars().any(char::is_control)
29            })
30        {
31            return Err(ObjectStoreError::InvalidKey(value));
32        }
33        Ok(Self(value))
34    }
35
36    pub fn as_str(&self) -> &str {
37        &self.0
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct ObjectMetadata {
43    pub content_type: String,
44    pub size_bytes: u64,
45    pub sha256: String,
46    pub created_at: DateTime<Utc>,
47    #[serde(default)]
48    pub attributes: BTreeMap<String, String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct StoredObject {
53    pub key: ObjectKey,
54    pub bytes: Vec<u8>,
55    pub metadata: ObjectMetadata,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PutObject {
60    pub key: ObjectKey,
61    pub bytes: Vec<u8>,
62    pub content_type: String,
63    pub attributes: BTreeMap<String, String>,
64}
65
66#[async_trait]
67pub trait ObjectStore: Send + Sync + std::fmt::Debug {
68    async fn put(&self, object: PutObject) -> Result<ObjectMetadata, ObjectStoreError>;
69    async fn get(&self, key: &ObjectKey) -> Result<Option<StoredObject>, ObjectStoreError>;
70    async fn delete(&self, key: &ObjectKey) -> Result<bool, ObjectStoreError>;
71}
72
73#[derive(Clone)]
74pub struct ObjectStoreService(pub Arc<dyn ObjectStore>);
75
76impl std::fmt::Debug for ObjectStoreService {
77    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        formatter.debug_tuple("ObjectStoreService").finish()
79    }
80}
81
82impl ObjectStoreService {
83    pub fn new(store: Arc<dyn ObjectStore>) -> Self {
84        Self(store)
85    }
86
87    pub async fn put(&self, object: PutObject) -> Result<ObjectMetadata, ObjectStoreError> {
88        self.0.put(object).await
89    }
90
91    pub async fn get(&self, key: &ObjectKey) -> Result<Option<StoredObject>, ObjectStoreError> {
92        self.0.get(key).await
93    }
94
95    pub async fn delete(&self, key: &ObjectKey) -> Result<bool, ObjectStoreError> {
96        self.0.delete(key).await
97    }
98}
99
100/// HTTP method required by a signed direct-object request.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "UPPERCASE")]
103pub enum PresignedMethod {
104    Get,
105    Put,
106    Post,
107}
108
109/// Browser- or client-usable request produced by a provider adapter such as S3.
110///
111/// `form_fields` is populated for multipart POST uploads. This is required for
112/// providers such as S3 where the signed POST policy, rather than a presigned
113/// PUT URL, enforces an upload-size range.
114#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct PresignedObjectRequest {
116    pub method: PresignedMethod,
117    pub url: String,
118    #[serde(default)]
119    pub headers: BTreeMap<String, String>,
120    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
121    pub form_fields: BTreeMap<String, String>,
122    pub expires_at: DateTime<Utc>,
123}
124
125impl std::fmt::Debug for PresignedObjectRequest {
126    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        formatter
128            .debug_struct("PresignedObjectRequest")
129            .field("method", &self.method)
130            .field("url", &"[REDACTED PRESIGNED URL]")
131            .field("header_names", &self.headers.keys().collect::<Vec<_>>())
132            .field(
133                "form_field_names",
134                &self.form_fields.keys().collect::<Vec<_>>(),
135            )
136            .field("expires_at", &self.expires_at)
137            .finish()
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct PresignPutObject {
143    pub key: ObjectKey,
144    pub content_type: String,
145    pub maximum_size_bytes: u64,
146    pub expires_in: TimeDelta,
147    pub attributes: BTreeMap<String, String>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct PresignGetObject {
152    pub key: ObjectKey,
153    pub expires_in: TimeDelta,
154    pub download_file_name: Option<String>,
155}
156
157/// Provider adapter for direct upload and download URLs.
158///
159/// Keeping signing separate from [`ObjectStore`] lets applications use server-side storage without
160/// exposing direct browser access. AWS implementations can map this port to S3 presigning while
161/// local/test implementations remain deterministic.
162#[async_trait]
163pub trait ObjectAccessSigner: Send + Sync + std::fmt::Debug {
164    async fn sign_put(
165        &self,
166        request: PresignPutObject,
167    ) -> Result<PresignedObjectRequest, ObjectStoreError>;
168
169    async fn sign_get(
170        &self,
171        request: PresignGetObject,
172    ) -> Result<PresignedObjectRequest, ObjectStoreError>;
173}
174
175#[derive(Clone)]
176pub struct ObjectAccessService(pub Arc<dyn ObjectAccessSigner>);
177
178impl std::fmt::Debug for ObjectAccessService {
179    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        formatter.debug_tuple("ObjectAccessService").finish()
181    }
182}
183
184impl ObjectAccessService {
185    pub fn new(signer: Arc<dyn ObjectAccessSigner>) -> Self {
186        Self(signer)
187    }
188
189    pub async fn sign_put(
190        &self,
191        request: PresignPutObject,
192    ) -> Result<PresignedObjectRequest, ObjectStoreError> {
193        validate_expiry(request.expires_in)?;
194        if request.content_type.trim().is_empty() {
195            return Err(ObjectStoreError::InvalidContentType);
196        }
197        if request.maximum_size_bytes == 0 {
198            return Err(ObjectStoreError::InvalidMaximumSize);
199        }
200        self.0.sign_put(request).await
201    }
202
203    pub async fn sign_get(
204        &self,
205        request: PresignGetObject,
206    ) -> Result<PresignedObjectRequest, ObjectStoreError> {
207        validate_expiry(request.expires_in)?;
208        self.0.sign_get(request).await
209    }
210}
211
212fn validate_expiry(expires_in: TimeDelta) -> Result<(), ObjectStoreError> {
213    if expires_in <= TimeDelta::zero() || expires_in > TimeDelta::hours(24) {
214        return Err(ObjectStoreError::InvalidExpiry);
215    }
216    Ok(())
217}
218
219#[derive(Debug, Default)]
220pub struct MemoryObjectStore {
221    objects: RwLock<BTreeMap<ObjectKey, StoredObject>>,
222}
223
224impl MemoryObjectStore {
225    /// Number of objects currently retained by the deterministic memory adapter.
226    ///
227    /// This is primarily useful for conformance tests and local diagnostics.
228    pub async fn len(&self) -> usize {
229        self.objects.read().await.len()
230    }
231
232    pub async fn is_empty(&self) -> bool {
233        self.len().await == 0
234    }
235}
236
237#[async_trait]
238impl ObjectStore for MemoryObjectStore {
239    async fn put(&self, object: PutObject) -> Result<ObjectMetadata, ObjectStoreError> {
240        if object.content_type.trim().is_empty() {
241            return Err(ObjectStoreError::InvalidContentType);
242        }
243        let metadata = ObjectMetadata {
244            content_type: object.content_type,
245            size_bytes: u64::try_from(object.bytes.len())
246                .map_err(|_| ObjectStoreError::ObjectTooLarge)?,
247            sha256: format!("{:x}", Sha256::digest(&object.bytes)),
248            created_at: Utc::now(),
249            attributes: object.attributes,
250        };
251        self.objects.write().await.insert(
252            object.key.clone(),
253            StoredObject {
254                key: object.key,
255                bytes: object.bytes,
256                metadata: metadata.clone(),
257            },
258        );
259        Ok(metadata)
260    }
261
262    async fn get(&self, key: &ObjectKey) -> Result<Option<StoredObject>, ObjectStoreError> {
263        Ok(self.objects.read().await.get(key).cloned())
264    }
265
266    async fn delete(&self, key: &ObjectKey) -> Result<bool, ObjectStoreError> {
267        Ok(self.objects.write().await.remove(key).is_some())
268    }
269}
270
271#[derive(Debug, Clone)]
272pub struct ObjectStoragePlugin {
273    store: ObjectStoreService,
274    access: Option<ObjectAccessService>,
275}
276
277impl ObjectStoragePlugin {
278    pub fn new(store: Arc<dyn ObjectStore>) -> Self {
279        Self {
280            store: ObjectStoreService::new(store),
281            access: None,
282        }
283    }
284
285    pub fn memory() -> Self {
286        Self::new(Arc::new(MemoryObjectStore::default()))
287    }
288
289    #[must_use]
290    pub fn with_access_signer(mut self, signer: Arc<dyn ObjectAccessSigner>) -> Self {
291        self.access = Some(ObjectAccessService::new(signer));
292        self
293    }
294}
295
296impl Plugin for ObjectStoragePlugin {
297    fn descriptor(&self) -> PluginDescriptor {
298        let mut descriptor = PluginDescriptor::new(
299            PluginId::new("object-storage").expect("static plugin ID"),
300            Version::new(1, 0, 0),
301            "Provider-neutral object storage used by uploads, exports, and feedback attachments",
302        );
303        descriptor.documentation = Some("https://docs.rs/minco-plugin-object-storage".into());
304        descriptor.core_compatibility =
305            VersionReq::parse(concat!("^", env!("CARGO_PKG_VERSION"))).expect("package version");
306        descriptor.stability = PluginStability::Beta;
307        descriptor
308            .data_classes
309            .extend([DataClass::CustomerProvided, DataClass::Confidential]);
310        descriptor.provides.push(CapabilityProvision {
311            name: "storage.object".into(),
312            version: Version::new(1, 0, 0),
313        });
314        if self.access.is_some() {
315            descriptor.provides.push(CapabilityProvision {
316                name: "storage.object.presign".into(),
317                version: Version::new(1, 0, 0),
318            });
319        }
320        descriptor
321    }
322
323    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
324        context.services().insert(Arc::new(self.store.clone()))?;
325        if let Some(access) = &self.access {
326            context.services().insert(Arc::new(access.clone()))?;
327        }
328        Ok(())
329    }
330}
331
332#[derive(Debug, thiserror::Error)]
333pub enum ObjectStoreError {
334    #[error("invalid object key: {0}")]
335    InvalidKey(String),
336    #[error("content type must not be empty")]
337    InvalidContentType,
338    #[error("maximum object size must be greater than zero")]
339    InvalidMaximumSize,
340    #[error("presigned request expiry must be greater than zero and no more than 24 hours")]
341    InvalidExpiry,
342    #[error("object is too large for this platform")]
343    ObjectTooLarge,
344    #[error("object store failed: {0}")]
345    Store(String),
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use minco_core::{PluginManager, PluginSelection};
352
353    #[tokio::test]
354    async fn memory_store_round_trips_bytes_and_metadata() {
355        let store = MemoryObjectStore::default();
356        let key = ObjectKey::parse("feedback/one/screenshot.png").unwrap();
357        let metadata = store
358            .put(PutObject {
359                key: key.clone(),
360                bytes: b"png".to_vec(),
361                content_type: "image/png".into(),
362                attributes: BTreeMap::new(),
363            })
364            .await
365            .unwrap();
366        assert_eq!(metadata.size_bytes, 3);
367        assert_eq!(store.get(&key).await.unwrap().unwrap().bytes, b"png");
368        assert!(store.delete(&key).await.unwrap());
369        assert!(store.get(&key).await.unwrap().is_none());
370    }
371
372    #[test]
373    fn unsafe_or_ambiguous_keys_are_rejected() {
374        for key in ["", "/absolute", "folder/", "a//b", "a/../b"] {
375            assert!(ObjectKey::parse(key).is_err(), "{key}");
376        }
377    }
378
379    #[test]
380    fn presigned_request_debug_redacts_capability_values() {
381        let request = PresignedObjectRequest {
382            method: PresignedMethod::Post,
383            url: "https://objects.example/key?X-Amz-Signature=secret-signature".into(),
384            headers: BTreeMap::from([("authorization".into(), "secret-header".into())]),
385            form_fields: BTreeMap::from([
386                ("x-amz-security-token".into(), "secret-token".into()),
387                ("x-amz-signature".into(), "secret-signature".into()),
388            ]),
389            expires_at: Utc::now() + TimeDelta::minutes(5),
390        };
391        let debug = format!("{request:?}");
392        assert!(!debug.contains("secret-token"));
393        assert!(!debug.contains("secret-signature"));
394        assert!(!debug.contains("secret-header"));
395        assert!(debug.contains("x-amz-security-token"));
396    }
397
398    #[derive(Debug)]
399    struct TestSigner;
400
401    #[async_trait]
402    impl ObjectAccessSigner for TestSigner {
403        async fn sign_put(
404            &self,
405            request: PresignPutObject,
406        ) -> Result<PresignedObjectRequest, ObjectStoreError> {
407            Ok(PresignedObjectRequest {
408                method: PresignedMethod::Put,
409                url: format!("https://objects.example/{}", request.key.as_str()),
410                headers: BTreeMap::from([("content-type".into(), request.content_type)]),
411                form_fields: BTreeMap::new(),
412                expires_at: Utc::now() + request.expires_in,
413            })
414        }
415
416        async fn sign_get(
417            &self,
418            request: PresignGetObject,
419        ) -> Result<PresignedObjectRequest, ObjectStoreError> {
420            Ok(PresignedObjectRequest {
421                method: PresignedMethod::Get,
422                url: format!("https://objects.example/{}", request.key.as_str()),
423                headers: BTreeMap::new(),
424                form_fields: BTreeMap::new(),
425                expires_at: Utc::now() + request.expires_in,
426            })
427        }
428    }
429
430    #[tokio::test]
431    async fn optional_presigning_is_typed_and_advertised_only_when_configured() {
432        let mut manager = PluginManager::default();
433        manager
434            .register(ObjectStoragePlugin::memory().with_access_signer(Arc::new(TestSigner)))
435            .unwrap();
436        let id = PluginId::new("object-storage").unwrap();
437        let mut selection = PluginSelection::default();
438        selection.enabled.insert(id);
439        let application = manager.compose(&selection).unwrap();
440        assert!(
441            application
442                .graph
443                .capabilities
444                .contains_key("storage.object.presign")
445        );
446
447        let access = application.services.get::<ObjectAccessService>().unwrap();
448        let signed = access
449            .sign_get(PresignGetObject {
450                key: ObjectKey::parse("documents/report.pdf").unwrap(),
451                expires_in: TimeDelta::minutes(5),
452                download_file_name: Some("report.pdf".into()),
453            })
454            .await
455            .unwrap();
456        assert_eq!(signed.method, PresignedMethod::Get);
457    }
458}