1#![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#[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#[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#[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 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}