Skip to main content

rc_core/
traits.rs

1//! ObjectStore trait definition
2//!
3//! This trait defines the interface for S3-compatible storage operations.
4//! It allows the CLI to be decoupled from the specific S3 SDK implementation.
5
6use std::collections::HashMap;
7
8use async_trait::async_trait;
9use jiff::Timestamp;
10use serde::{Deserialize, Serialize};
11use tokio::io::AsyncWrite;
12
13use crate::cors::CorsRule;
14use crate::encryption::{BucketEncryption, ObjectEncryptionRequest};
15use crate::error::Result;
16use crate::lifecycle::LifecycleRule;
17use crate::path::RemotePath;
18use crate::replication::ReplicationConfiguration;
19use crate::select::SelectOptions;
20
21/// Metadata for an object version
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ObjectVersion {
24    /// Object key
25    pub key: String,
26
27    /// Version ID
28    pub version_id: String,
29
30    /// Whether this is the latest version
31    pub is_latest: bool,
32
33    /// Whether this is a delete marker
34    pub is_delete_marker: bool,
35
36    /// Last modified timestamp
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub last_modified: Option<Timestamp>,
39
40    /// Size in bytes
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub size_bytes: Option<i64>,
43
44    /// ETag
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub etag: Option<String>,
47}
48
49/// Result of an object version list operation
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ObjectVersionListResult {
52    /// Listed object versions and delete markers
53    pub items: Vec<ObjectVersion>,
54
55    /// Whether the result is truncated (more items available)
56    pub truncated: bool,
57
58    /// Continuation key marker for pagination
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub continuation_token: Option<String>,
61
62    /// Continuation version marker for pagination
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub version_id_marker: Option<String>,
65}
66
67/// Metadata for an object or bucket
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ObjectInfo {
70    /// Object key or bucket name
71    pub key: String,
72
73    /// Size in bytes (None for buckets)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub size_bytes: Option<i64>,
76
77    /// Human-readable size
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub size_human: Option<String>,
80
81    /// Last modified timestamp
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub last_modified: Option<Timestamp>,
84
85    /// ETag (usually MD5 for single-part uploads)
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub etag: Option<String>,
88
89    /// Storage class
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub storage_class: Option<String>,
92
93    /// Content type
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub content_type: Option<String>,
96
97    /// User-defined metadata
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub metadata: Option<HashMap<String, String>>,
100
101    /// Whether this is a directory/prefix
102    pub is_dir: bool,
103}
104
105impl ObjectInfo {
106    /// Create a new ObjectInfo for a file
107    pub fn file(key: impl Into<String>, size: i64) -> Self {
108        Self {
109            key: key.into(),
110            size_bytes: Some(size),
111            size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
112            last_modified: None,
113            etag: None,
114            storage_class: None,
115            content_type: None,
116            metadata: None,
117            is_dir: false,
118        }
119    }
120
121    /// Create a new ObjectInfo for a directory/prefix
122    pub fn dir(key: impl Into<String>) -> Self {
123        Self {
124            key: key.into(),
125            size_bytes: None,
126            size_human: None,
127            last_modified: None,
128            etag: None,
129            storage_class: None,
130            content_type: None,
131            metadata: None,
132            is_dir: true,
133        }
134    }
135
136    /// Create a new ObjectInfo for a bucket
137    pub fn bucket(name: impl Into<String>) -> Self {
138        Self {
139            key: name.into(),
140            size_bytes: None,
141            size_human: None,
142            last_modified: None,
143            etag: None,
144            storage_class: None,
145            content_type: None,
146            metadata: None,
147            is_dir: true,
148        }
149    }
150}
151
152/// Result of a list operation
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ListResult {
155    /// Listed objects
156    pub items: Vec<ObjectInfo>,
157
158    /// Whether the result is truncated (more items available)
159    pub truncated: bool,
160
161    /// Continuation token for pagination
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub continuation_token: Option<String>,
164}
165
166/// Options for list operations
167#[derive(Debug, Clone, Default)]
168pub struct ListOptions {
169    /// Maximum number of keys to return per request
170    pub max_keys: Option<i32>,
171
172    /// Delimiter for grouping (usually "/")
173    pub delimiter: Option<String>,
174
175    /// Prefix to filter by
176    pub prefix: Option<String>,
177
178    /// Continuation token for pagination
179    pub continuation_token: Option<String>,
180
181    /// Whether to list recursively (ignore delimiter)
182    pub recursive: bool,
183}
184
185/// Backend capability information
186#[derive(Debug, Clone, Default)]
187pub struct Capabilities {
188    /// Supports bucket versioning
189    pub versioning: bool,
190
191    /// Supports object lock/retention
192    pub object_lock: bool,
193
194    /// Supports object tagging
195    pub tagging: bool,
196
197    /// Supports anonymous bucket access policies
198    pub anonymous: bool,
199
200    /// S3 Select (`SelectObjectContent`).
201    ///
202    /// This remains `false` in generic capability hints because support is determined by issuing
203    /// a real request against the target object.
204    pub select: bool,
205
206    /// Supports event notifications
207    pub notifications: bool,
208
209    /// Supports lifecycle configuration
210    pub lifecycle: bool,
211
212    /// Supports bucket replication
213    pub replication: bool,
214
215    /// Supports bucket CORS configuration
216    pub cors: bool,
217}
218
219/// Bucket notification target type
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "lowercase")]
222pub enum NotificationTarget {
223    /// SQS queue target
224    Queue,
225    /// SNS topic target
226    Topic,
227    /// Lambda function target
228    Lambda,
229}
230
231/// Bucket notification rule
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct BucketNotification {
234    /// Optional rule id
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub id: Option<String>,
237    /// Notification target type
238    pub target: NotificationTarget,
239    /// Target ARN
240    pub arn: String,
241    /// Event patterns
242    pub events: Vec<String>,
243    /// Optional key prefix filter
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub prefix: Option<String>,
246    /// Optional key suffix filter
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub suffix: Option<String>,
249}
250
251/// Trait for S3-compatible storage operations
252///
253/// This trait is implemented by the S3 adapter and can be mocked for testing.
254#[async_trait]
255pub trait ObjectStore: Send + Sync {
256    /// List buckets
257    async fn list_buckets(&self) -> Result<Vec<ObjectInfo>>;
258
259    /// List objects in a bucket or prefix
260    async fn list_objects(&self, path: &RemotePath, options: ListOptions) -> Result<ListResult>;
261
262    /// Get object metadata
263    async fn head_object(&self, path: &RemotePath) -> Result<ObjectInfo>;
264
265    /// Check if a bucket exists
266    async fn bucket_exists(&self, bucket: &str) -> Result<bool>;
267
268    /// Create a bucket
269    async fn create_bucket(&self, bucket: &str) -> Result<()>;
270
271    /// Delete a bucket
272    async fn delete_bucket(&self, bucket: &str) -> Result<()>;
273
274    /// Get backend capabilities
275    async fn capabilities(&self) -> Result<Capabilities>;
276
277    /// Get object content as bytes
278    async fn get_object(&self, path: &RemotePath) -> Result<Vec<u8>>;
279
280    /// Upload object from bytes
281    async fn put_object(
282        &self,
283        path: &RemotePath,
284        data: Vec<u8>,
285        content_type: Option<&str>,
286        encryption: Option<&ObjectEncryptionRequest>,
287    ) -> Result<ObjectInfo>;
288
289    /// Delete an object
290    async fn delete_object(&self, path: &RemotePath) -> Result<()>;
291
292    /// Delete multiple objects (batch delete)
293    async fn delete_objects(&self, bucket: &str, keys: Vec<String>) -> Result<Vec<String>>;
294
295    /// Copy object within S3 (server-side copy)
296    async fn copy_object(
297        &self,
298        src: &RemotePath,
299        dst: &RemotePath,
300        encryption: Option<&ObjectEncryptionRequest>,
301    ) -> Result<ObjectInfo>;
302
303    /// Generate a presigned URL for an object
304    async fn presign_get(&self, path: &RemotePath, expires_secs: u64) -> Result<String>;
305
306    /// Generate a presigned URL for uploading an object
307    async fn presign_put(
308        &self,
309        path: &RemotePath,
310        expires_secs: u64,
311        content_type: Option<&str>,
312    ) -> Result<String>;
313
314    // Phase 5: Optional operations (capability-dependent)
315
316    /// Get bucket versioning status
317    async fn get_versioning(&self, bucket: &str) -> Result<Option<bool>>;
318
319    /// Set bucket versioning status
320    async fn set_versioning(&self, bucket: &str, enabled: bool) -> Result<()>;
321
322    /// Get bucket default encryption. Returns None when encryption is not configured.
323    async fn get_bucket_encryption(&self, bucket: &str) -> Result<Option<BucketEncryption>>;
324
325    /// Set bucket default encryption.
326    async fn set_bucket_encryption(&self, bucket: &str, encryption: BucketEncryption)
327    -> Result<()>;
328
329    /// Delete bucket default encryption.
330    async fn delete_bucket_encryption(&self, bucket: &str) -> Result<()>;
331
332    /// List object versions
333    async fn list_object_versions(
334        &self,
335        path: &RemotePath,
336        max_keys: Option<i32>,
337    ) -> Result<Vec<ObjectVersion>>;
338
339    /// Get object tags
340    async fn get_object_tags(
341        &self,
342        path: &RemotePath,
343    ) -> Result<std::collections::HashMap<String, String>>;
344
345    /// Get bucket tags
346    async fn get_bucket_tags(
347        &self,
348        bucket: &str,
349    ) -> Result<std::collections::HashMap<String, String>>;
350
351    /// Set object tags
352    async fn set_object_tags(
353        &self,
354        path: &RemotePath,
355        tags: std::collections::HashMap<String, String>,
356    ) -> Result<()>;
357
358    /// Set bucket tags
359    async fn set_bucket_tags(
360        &self,
361        bucket: &str,
362        tags: std::collections::HashMap<String, String>,
363    ) -> Result<()>;
364
365    /// Delete object tags
366    async fn delete_object_tags(&self, path: &RemotePath) -> Result<()>;
367
368    /// Delete bucket tags
369    async fn delete_bucket_tags(&self, bucket: &str) -> Result<()>;
370
371    /// Get bucket policy as raw JSON string. Returns `None` when no policy exists.
372    async fn get_bucket_policy(&self, bucket: &str) -> Result<Option<String>>;
373
374    /// Replace bucket policy using raw JSON string.
375    async fn set_bucket_policy(&self, bucket: &str, policy: &str) -> Result<()>;
376
377    /// Remove bucket policy (set anonymous access to private).
378    async fn delete_bucket_policy(&self, bucket: &str) -> Result<()>;
379
380    /// Get bucket notification configuration as flat rules.
381    async fn get_bucket_notifications(&self, bucket: &str) -> Result<Vec<BucketNotification>>;
382
383    /// Replace bucket notification configuration with flat rules.
384    async fn set_bucket_notifications(
385        &self,
386        bucket: &str,
387        notifications: Vec<BucketNotification>,
388    ) -> Result<()>;
389
390    // Lifecycle operations (capability-dependent)
391
392    /// Get bucket lifecycle rules. Returns empty vec if no lifecycle config exists.
393    async fn get_bucket_lifecycle(&self, bucket: &str) -> Result<Vec<LifecycleRule>>;
394
395    /// Set bucket lifecycle configuration (replaces all rules).
396    async fn set_bucket_lifecycle(&self, bucket: &str, rules: Vec<LifecycleRule>) -> Result<()>;
397
398    /// Delete bucket lifecycle configuration.
399    async fn delete_bucket_lifecycle(&self, bucket: &str) -> Result<()>;
400
401    /// Restore a transitioned (archived) object.
402    async fn restore_object(&self, path: &RemotePath, days: i32) -> Result<()>;
403
404    // Replication operations (capability-dependent)
405
406    /// Get bucket replication configuration. Returns None if not configured.
407    async fn get_bucket_replication(
408        &self,
409        bucket: &str,
410    ) -> Result<Option<ReplicationConfiguration>>;
411
412    /// Set bucket replication configuration.
413    async fn set_bucket_replication(
414        &self,
415        bucket: &str,
416        config: ReplicationConfiguration,
417    ) -> Result<()>;
418
419    /// Delete bucket replication configuration.
420    async fn delete_bucket_replication(&self, bucket: &str) -> Result<()>;
421
422    /// Get bucket CORS rules. Returns empty vec if no CORS config exists.
423    async fn get_bucket_cors(&self, bucket: &str) -> Result<Vec<CorsRule>>;
424
425    /// Set bucket CORS configuration (replaces all rules).
426    async fn set_bucket_cors(&self, bucket: &str, rules: Vec<CorsRule>) -> Result<()>;
427
428    /// Delete bucket CORS configuration.
429    async fn delete_bucket_cors(&self, bucket: &str) -> Result<()>;
430
431    /// Run S3 Select on an object and stream result payloads to `writer`.
432    async fn select_object_content(
433        &self,
434        path: &RemotePath,
435        options: &SelectOptions,
436        writer: &mut (dyn AsyncWrite + Send + Unpin),
437    ) -> Result<()>;
438    // async fn get_versioning(&self, bucket: &str) -> Result<bool>;
439    // async fn set_versioning(&self, bucket: &str, enabled: bool) -> Result<()>;
440    // async fn get_tags(&self, path: &RemotePath) -> Result<HashMap<String, String>>;
441    // async fn set_tags(&self, path: &RemotePath, tags: HashMap<String, String>) -> Result<()>;
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_object_info_file() {
450        let info = ObjectInfo::file("test.txt", 1024);
451        assert_eq!(info.key, "test.txt");
452        assert_eq!(info.size_bytes, Some(1024));
453        assert!(!info.is_dir);
454    }
455
456    #[test]
457    fn test_object_info_dir() {
458        let info = ObjectInfo::dir("path/to/dir/");
459        assert_eq!(info.key, "path/to/dir/");
460        assert!(info.is_dir);
461        assert!(info.size_bytes.is_none());
462    }
463
464    #[test]
465    fn test_object_info_bucket() {
466        let info = ObjectInfo::bucket("my-bucket");
467        assert_eq!(info.key, "my-bucket");
468        assert!(info.is_dir);
469    }
470
471    #[test]
472    fn test_object_info_metadata_default_none() {
473        let info = ObjectInfo::file("test.txt", 1024);
474        assert!(info.metadata.is_none());
475    }
476
477    #[test]
478    fn test_object_info_metadata_set() {
479        let mut info = ObjectInfo::file("test.txt", 1024);
480        let mut meta = HashMap::new();
481        meta.insert("content-disposition".to_string(), "attachment".to_string());
482        meta.insert("custom-key".to_string(), "custom-value".to_string());
483        info.metadata = Some(meta);
484
485        let metadata = info.metadata.as_ref().expect("metadata should be Some");
486        assert_eq!(metadata.len(), 2);
487        assert_eq!(metadata.get("content-disposition").unwrap(), "attachment");
488        assert_eq!(metadata.get("custom-key").unwrap(), "custom-value");
489    }
490}