Skip to main content

entrenar/storage/cloud/
s3.rs

1//! S3 backend configuration and mock implementation
2
3use crate::storage::cloud::error::Result;
4use crate::storage::cloud::memory::InMemoryBackend;
5use crate::storage::cloud::metadata::ArtifactMetadata;
6use crate::storage::cloud::traits::ArtifactBackend;
7use serde::{Deserialize, Serialize};
8
9/// S3 backend configuration
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct S3Config {
12    /// S3 bucket name
13    pub bucket: String,
14    /// Key prefix within bucket
15    pub prefix: String,
16    /// AWS region (e.g., "us-east-1")
17    pub region: Option<String>,
18    /// Custom endpoint (for MinIO, R2, etc.)
19    pub endpoint: Option<String>,
20    /// Access key ID (if not using IAM role)
21    pub access_key_id: Option<String>,
22    /// Secret access key (if not using IAM role)
23    pub secret_access_key: Option<String>,
24}
25
26impl S3Config {
27    /// Create a new S3 configuration
28    pub fn new(bucket: &str, prefix: &str) -> Self {
29        Self {
30            bucket: bucket.to_string(),
31            prefix: prefix.to_string(),
32            region: None,
33            endpoint: None,
34            access_key_id: None,
35            secret_access_key: None,
36        }
37    }
38
39    /// Set region
40    pub fn with_region(mut self, region: &str) -> Self {
41        self.region = Some(region.to_string());
42        self
43    }
44
45    /// Set custom endpoint (for S3-compatible services)
46    pub fn with_endpoint(mut self, endpoint: &str) -> Self {
47        self.endpoint = Some(endpoint.to_string());
48        self
49    }
50
51    /// Set credentials
52    pub fn with_credentials(mut self, access_key_id: &str, secret_access_key: &str) -> Self {
53        self.access_key_id = Some(access_key_id.to_string());
54        self.secret_access_key = Some(secret_access_key.to_string());
55        self
56    }
57
58    /// Get the full key path for a hash
59    pub fn key_for_hash(&self, hash: &str) -> String {
60        if self.prefix.is_empty() {
61            hash.to_string()
62        } else {
63            format!("{}/{}", self.prefix.trim_end_matches('/'), hash)
64        }
65    }
66}
67
68/// Mock S3 backend for testing (simulates S3 behavior in memory)
69#[derive(Debug)]
70pub struct MockS3Backend {
71    config: S3Config,
72    inner: InMemoryBackend,
73}
74
75impl MockS3Backend {
76    /// Create a new mock S3 backend
77    pub fn new(config: S3Config) -> Self {
78        Self { config, inner: InMemoryBackend::new() }
79    }
80
81    /// Get the configuration
82    pub fn config(&self) -> &S3Config {
83        &self.config
84    }
85}
86
87impl ArtifactBackend for MockS3Backend {
88    fn put(&self, name: &str, data: &[u8]) -> Result<String> {
89        self.inner.put(name, data)
90    }
91
92    fn get(&self, hash: &str) -> Result<Vec<u8>> {
93        self.inner.get(hash)
94    }
95
96    fn exists(&self, hash: &str) -> Result<bool> {
97        self.inner.exists(hash)
98    }
99
100    fn delete(&self, hash: &str) -> Result<()> {
101        self.inner.delete(hash)
102    }
103
104    fn get_metadata(&self, hash: &str) -> Result<ArtifactMetadata> {
105        self.inner.get_metadata(hash)
106    }
107
108    fn list(&self) -> Result<Vec<ArtifactMetadata>> {
109        self.inner.list()
110    }
111
112    fn backend_type(&self) -> &'static str {
113        "s3"
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_s3_config_new() {
123        let config = S3Config::new("my-bucket", "artifacts/");
124        assert_eq!(config.bucket, "my-bucket");
125        assert_eq!(config.prefix, "artifacts/");
126    }
127
128    #[test]
129    fn test_s3_config_with_region() {
130        let config = S3Config::new("bucket", "prefix").with_region("us-west-2");
131        assert_eq!(config.region, Some("us-west-2".to_string()));
132    }
133
134    #[test]
135    fn test_s3_config_with_endpoint() {
136        let config = S3Config::new("bucket", "prefix").with_endpoint("http://minio:9000");
137        assert_eq!(config.endpoint, Some("http://minio:9000".to_string()));
138    }
139
140    #[test]
141    fn test_s3_config_key_for_hash() {
142        let config = S3Config::new("bucket", "artifacts");
143        assert_eq!(config.key_for_hash("abc123"), "artifacts/abc123");
144
145        let config = S3Config::new("bucket", "");
146        assert_eq!(config.key_for_hash("abc123"), "abc123");
147    }
148
149    #[test]
150    fn test_mock_s3_backend_put_get() {
151        let config = S3Config::new("test-bucket", "prefix");
152        let backend = MockS3Backend::new(config);
153
154        let data = b"s3 test data";
155        let hash = backend.put("file.bin", data).expect("operation should succeed");
156
157        let retrieved = backend.get(&hash).expect("key should exist");
158        assert_eq!(retrieved, data);
159    }
160
161    #[test]
162    fn test_mock_s3_backend_type() {
163        let config = S3Config::new("bucket", "prefix");
164        let backend = MockS3Backend::new(config);
165        assert_eq!(backend.backend_type(), "s3");
166    }
167
168    #[test]
169    fn test_s3_config_with_credentials() {
170        let config = S3Config::new("bucket", "prefix").with_credentials("access_key", "secret_key");
171        assert_eq!(config.access_key_id, Some("access_key".to_string()));
172        assert_eq!(config.secret_access_key, Some("secret_key".to_string()));
173    }
174
175    #[test]
176    fn test_s3_config_key_for_hash_with_trailing_slash() {
177        let config = S3Config::new("bucket", "artifacts/");
178        assert_eq!(config.key_for_hash("abc123"), "artifacts/abc123");
179    }
180
181    #[test]
182    fn test_mock_s3_backend_exists() {
183        let config = S3Config::new("bucket", "prefix");
184        let backend = MockS3Backend::new(config);
185
186        let hash = backend.put("file.bin", b"data").expect("operation should succeed");
187        assert!(backend.exists(&hash).expect("operation should succeed"));
188        assert!(!backend.exists("nonexistent").expect("operation should succeed"));
189    }
190
191    #[test]
192    fn test_mock_s3_backend_delete() {
193        let config = S3Config::new("bucket", "prefix");
194        let backend = MockS3Backend::new(config);
195
196        let hash = backend.put("file.bin", b"data").expect("operation should succeed");
197        backend.delete(&hash).expect("operation should succeed");
198        assert!(!backend.exists(&hash).expect("operation should succeed"));
199    }
200
201    #[test]
202    fn test_mock_s3_backend_get_metadata() {
203        let config = S3Config::new("bucket", "prefix");
204        let backend = MockS3Backend::new(config);
205
206        let hash = backend.put("model.bin", b"model data").expect("operation should succeed");
207        let meta = backend.get_metadata(&hash).expect("operation should succeed");
208        assert_eq!(meta.name, "model.bin");
209    }
210
211    #[test]
212    fn test_mock_s3_backend_list() {
213        let config = S3Config::new("bucket", "prefix");
214        let backend = MockS3Backend::new(config);
215
216        backend.put("file1.bin", b"data1").expect("operation should succeed");
217        backend.put("file2.bin", b"data2").expect("operation should succeed");
218
219        let list = backend.list().expect("operation should succeed");
220        assert_eq!(list.len(), 2);
221    }
222
223    #[test]
224    fn test_mock_s3_backend_config() {
225        let config = S3Config::new("test-bucket", "models/").with_region("us-east-1");
226        let backend = MockS3Backend::new(config);
227
228        assert_eq!(backend.config().bucket, "test-bucket");
229        assert_eq!(backend.config().prefix, "models/");
230        assert_eq!(backend.config().region, Some("us-east-1".to_string()));
231    }
232
233    #[test]
234    fn test_s3_config_serde() {
235        let config = S3Config::new("bucket", "prefix")
236            .with_region("us-west-2")
237            .with_endpoint("http://localhost:9000")
238            .with_credentials("key", "secret");
239
240        let json = serde_json::to_string(&config).expect("JSON serialization should succeed");
241        let parsed: S3Config =
242            serde_json::from_str(&json).expect("JSON deserialization should succeed");
243
244        assert_eq!(config.bucket, parsed.bucket);
245        assert_eq!(config.region, parsed.region);
246        assert_eq!(config.endpoint, parsed.endpoint);
247    }
248}
249
250#[cfg(test)]
251mod property_tests {
252    use super::*;
253    use proptest::prelude::*;
254
255    proptest! {
256        #![proptest_config(ProptestConfig::with_cases(200))]
257
258        #[test]
259        fn prop_s3_key_contains_hash(
260            prefix in "[a-zA-Z0-9/]{0,20}",
261            hash in "[a-f0-9]{64}"
262        ) {
263            let config = S3Config::new("bucket", &prefix);
264            let key = config.key_for_hash(&hash);
265            prop_assert!(key.contains(&hash));
266        }
267    }
268}