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