Skip to main content

fakecloud_s3/
state.rs

1use bytes::Bytes;
2use chrono::{DateTime, Utc};
3use fakecloud_persistence::cache::{BodyCache, BodyKey};
4use fakecloud_persistence::BodyRef;
5use parking_lot::RwLock;
6use std::collections::BTreeMap;
7use std::io::{self, Read, Seek, SeekFrom};
8use std::sync::Arc;
9
10/// An ACL grant entry.
11#[derive(Debug, Clone)]
12pub struct AclGrant {
13    pub grantee_type: String, // "CanonicalUser" or "Group"
14    pub grantee_id: Option<String>,
15    pub grantee_display_name: Option<String>,
16    pub grantee_uri: Option<String>,
17    pub permission: String, // READ, WRITE, READ_ACP, WRITE_ACP, FULL_CONTROL
18}
19
20#[derive(Debug, Clone, Default)]
21pub struct S3Object {
22    pub key: String,
23    pub body: BodyRef,
24    pub content_type: String,
25    pub etag: String,
26    pub size: u64,
27    pub last_modified: DateTime<Utc>,
28    pub metadata: BTreeMap<String, String>,
29    pub storage_class: String,
30    pub tags: BTreeMap<String, String>,
31    pub acl_grants: Vec<AclGrant>,
32    pub acl_owner_id: Option<String>,
33    /// If created from multipart upload, the number of parts.
34    pub parts_count: Option<u32>,
35    /// Per-part sizes for multipart objects (part_number, size).
36    pub part_sizes: Option<Vec<(u32, u64)>>,
37    /// Server-side encryption algorithm.
38    pub sse_algorithm: Option<String>,
39    /// KMS key ID for SSE-KMS.
40    pub sse_kms_key_id: Option<String>,
41    /// Whether bucket key is enabled.
42    pub bucket_key_enabled: Option<bool>,
43    pub version_id: Option<String>,
44    pub is_delete_marker: bool,
45    pub content_encoding: Option<String>,
46    /// System metadata headers AWS stores verbatim and echoes on GET/HEAD.
47    pub cache_control: Option<String>,
48    pub content_disposition: Option<String>,
49    pub content_language: Option<String>,
50    pub expires: Option<String>,
51    pub website_redirect_location: Option<String>,
52    /// Glacier restore: ongoing request status.
53    pub restore_ongoing: Option<bool>,
54    /// Glacier restore: expiry date string.
55    pub restore_expiry: Option<String>,
56    /// Checksum algorithm (CRC32, SHA1, SHA256).
57    pub checksum_algorithm: Option<String>,
58    /// Base64-encoded checksum value.
59    pub checksum_value: Option<String>,
60    /// Object lock mode (GOVERNANCE or COMPLIANCE).
61    pub lock_mode: Option<String>,
62    /// Object lock retain-until date (ISO 8601).
63    pub lock_retain_until: Option<DateTime<Utc>>,
64    /// Legal hold status (ON or OFF).
65    pub lock_legal_hold: Option<String>,
66}
67
68/// A part uploaded via the multipart upload API.
69#[derive(Debug, Clone)]
70pub struct UploadPart {
71    pub part_number: u32,
72    pub body: BodyRef,
73    pub etag: String,
74    pub size: u64,
75    pub last_modified: DateTime<Utc>,
76}
77
78/// An in-progress multipart upload.
79#[derive(Debug, Clone)]
80pub struct MultipartUpload {
81    pub upload_id: String,
82    pub key: String,
83    pub initiated: DateTime<Utc>,
84    /// Parts keyed by part number.
85    pub parts: BTreeMap<u32, UploadPart>,
86    /// Metadata provided at CreateMultipartUpload time.
87    pub metadata: BTreeMap<String, String>,
88    pub content_type: String,
89    pub storage_class: String,
90    pub sse_algorithm: Option<String>,
91    pub sse_kms_key_id: Option<String>,
92    pub tagging: Option<String>,
93    pub acl_grants: Vec<AclGrant>,
94    pub checksum_algorithm: Option<String>,
95    pub cache_control: Option<String>,
96    pub content_disposition: Option<String>,
97    pub content_language: Option<String>,
98    pub expires: Option<String>,
99}
100
101#[derive(Debug, Clone)]
102pub struct S3Bucket {
103    pub name: String,
104    pub creation_date: DateTime<Utc>,
105    pub region: String,
106    /// Objects keyed by their full key path.
107    pub objects: BTreeMap<String, S3Object>,
108    pub tags: BTreeMap<String, String>,
109    pub acl_grants: Vec<AclGrant>,
110    pub acl_owner_id: String,
111    /// In-progress multipart uploads keyed by upload ID.
112    pub multipart_uploads: BTreeMap<String, MultipartUpload>,
113    /// Versioning status: None = never enabled, Some("Enabled"), Some("Suspended").
114    pub versioning: Option<String>,
115    /// MFA-Delete status: None = unset, Some("Enabled"), Some("Disabled").
116    pub mfa_delete: Option<String>,
117    /// Object versions keyed by key, each value is a list of versions.
118    pub object_versions: BTreeMap<String, Vec<S3Object>>,
119    /// Bucket ACL (canned or XML).
120    pub acl: Option<String>,
121    pub encryption_config: Option<String>,
122    pub lifecycle_config: Option<String>,
123    /// Value of the `x-amz-transition-default-minimum-object-size` header
124    /// supplied on PutBucketLifecycleConfiguration. Echoed back as a header
125    /// on the corresponding GET (and PUT) response. Real AWS defaults to
126    /// `all_storage_classes_128K` for general purpose buckets.
127    pub lifecycle_transition_default_min_size: Option<String>,
128    pub policy: Option<String>,
129    pub cors_config: Option<String>,
130    pub notification_config: Option<String>,
131    pub logging_config: Option<String>,
132    pub website_config: Option<String>,
133    pub accelerate_status: Option<String>,
134    pub public_access_block: Option<String>,
135    pub object_lock_config: Option<String>,
136    pub replication_config: Option<String>,
137    pub ownership_controls: Option<String>,
138    pub inventory_configs: BTreeMap<String, String>,
139    /// Whether EventBridge notifications are enabled for this bucket.
140    pub eventbridge_enabled: bool,
141    /// Per-id analytics configurations (XML body).
142    pub analytics_configs: BTreeMap<String, String>,
143    /// Per-id intelligent-tiering configurations (XML body).
144    pub intelligent_tiering_configs: BTreeMap<String, String>,
145    /// Per-id metrics configurations (XML body).
146    pub metrics_configs: BTreeMap<String, String>,
147    /// Request payment configuration (XML body).
148    pub request_payment: Option<String>,
149    /// Per-bucket ABAC config (XML body) — see PutBucketAbac/GetBucketAbac.
150    pub abac_config: Option<String>,
151    /// Bucket-level metadata configuration (S3 metadata table v2).
152    pub metadata_configuration: Option<String>,
153    /// Bucket-level metadata table configuration (S3 metadata table v1).
154    pub metadata_table_configuration: Option<String>,
155}
156
157impl S3Bucket {
158    pub fn new(name: &str, region: &str, owner_id: &str) -> Self {
159        Self {
160            name: name.to_string(),
161            creation_date: Utc::now(),
162            region: region.to_string(),
163            objects: BTreeMap::new(),
164            tags: BTreeMap::new(),
165            acl_grants: vec![AclGrant {
166                grantee_type: "CanonicalUser".to_string(),
167                grantee_id: Some(owner_id.to_string()),
168                grantee_display_name: Some(owner_id.to_string()),
169                grantee_uri: None,
170                permission: "FULL_CONTROL".to_string(),
171            }],
172            acl_owner_id: owner_id.to_string(),
173            multipart_uploads: BTreeMap::new(),
174            versioning: None,
175            mfa_delete: None,
176            object_versions: BTreeMap::new(),
177            acl: None,
178            encryption_config: None,
179            lifecycle_config: None,
180            lifecycle_transition_default_min_size: None,
181            policy: None,
182            cors_config: None,
183            notification_config: None,
184            logging_config: None,
185            website_config: None,
186            accelerate_status: None,
187            public_access_block: None,
188            object_lock_config: None,
189            replication_config: None,
190            ownership_controls: None,
191            inventory_configs: BTreeMap::new(),
192            eventbridge_enabled: false,
193            analytics_configs: BTreeMap::new(),
194            intelligent_tiering_configs: BTreeMap::new(),
195            metrics_configs: BTreeMap::new(),
196            request_payment: None,
197            abac_config: None,
198            metadata_configuration: None,
199            metadata_table_configuration: None,
200        }
201    }
202}
203
204/// A recorded S3 notification event for introspection.
205#[derive(Debug, Clone)]
206pub struct S3NotificationEvent {
207    pub bucket: String,
208    pub key: String,
209    pub event_type: String,
210    pub timestamp: DateTime<Utc>,
211}
212
213/// Stored response from a Lambda function invoked via S3 Object Lambda.
214/// Keyed by `request_token` in [`S3State::object_lambda_responses`].
215#[derive(Debug, Clone)]
216pub struct ObjectLambdaResponse {
217    pub route: String,
218    pub token: String,
219    pub body: Vec<u8>,
220    pub content_type: Option<String>,
221    pub fwd_status: Option<u16>,
222    pub fwd_error_message: Option<String>,
223    pub metadata: BTreeMap<String, String>,
224    pub encryption: Option<String>,
225    pub kms_key_id: Option<String>,
226    pub stored_at: DateTime<Utc>,
227}
228
229#[derive(Debug, Clone)]
230pub struct S3AccessPoint {
231    pub name: String,
232    pub bucket: String,
233    pub account_id: String,
234    pub network_origin: String,
235    pub vpc_configuration: Option<String>,
236    pub creation_date: DateTime<Utc>,
237    pub public_access_block: Option<String>,
238    pub bucket_account_id: Option<String>,
239}
240
241pub struct S3State {
242    pub account_id: String,
243    pub region: String,
244    pub buckets: BTreeMap<String, S3Bucket>,
245    pub notification_events: Vec<S3NotificationEvent>,
246    pub body_cache: Option<Arc<BodyCache>>,
247    /// Object Lambda responses keyed by request token.
248    pub object_lambda_responses: BTreeMap<String, ObjectLambdaResponse>,
249    pub access_points: BTreeMap<String, S3AccessPoint>,
250}
251
252impl S3State {
253    pub fn new(account_id: &str, region: &str) -> Self {
254        Self {
255            account_id: account_id.to_string(),
256            region: region.to_string(),
257            buckets: BTreeMap::new(),
258            notification_events: Vec::new(),
259            body_cache: None,
260            object_lambda_responses: BTreeMap::new(),
261            access_points: BTreeMap::new(),
262        }
263    }
264
265    pub fn set_body_cache(&mut self, cache: Arc<BodyCache>) {
266        self.body_cache = Some(cache);
267    }
268
269    pub fn reset(&mut self) {
270        self.buckets.clear();
271        self.notification_events.clear();
272        self.object_lambda_responses.clear();
273    }
274
275    /// Read the full body referenced by a [`BodyRef`] without touching the
276    /// [`BodyCache`] or any `S3State`. Because it borrows nothing from state,
277    /// callers can read part bodies after dropping the global S3 lock — used by
278    /// CompleteMultipartUpload to assemble a multi-GB object off-lock instead of
279    /// serializing every other S3 operation behind the assembly (bug-audit
280    /// 2026-05-28, 4.7). Disk bodies are read straight from their path.
281    pub fn read_body_uncached(body: &BodyRef) -> io::Result<Bytes> {
282        match body {
283            BodyRef::Memory(b) => Ok(b.clone()),
284            BodyRef::Disk { path, .. } => Ok(Bytes::from(std::fs::read(path)?)),
285        }
286    }
287
288    /// Read the full body referenced by a [`BodyRef`], consulting the
289    /// persistent [`BodyCache`] when one is configured.
290    pub fn read_body(&self, body: &BodyRef) -> io::Result<Bytes> {
291        match body {
292            BodyRef::Memory(b) => Ok(b.clone()),
293            BodyRef::Disk {
294                bucket,
295                key,
296                version,
297                path,
298                ..
299            } => {
300                let cache_key = BodyKey::new(bucket.clone(), key.clone(), version.clone());
301                if let Some(cache) = &self.body_cache {
302                    if let Some(hit) = cache.get(&cache_key) {
303                        return Ok(hit);
304                    }
305                }
306                let data = std::fs::read(path)?;
307                let bytes = Bytes::from(data);
308                if let Some(cache) = &self.body_cache {
309                    cache.insert(cache_key, bytes.clone());
310                }
311                Ok(bytes)
312            }
313        }
314    }
315
316    /// Read a byte range from the body without loading the full object into
317    /// memory. Memory bodies are sliced directly; disk bodies are seek+read'd.
318    /// Ranges bypass the body cache (the cache stores whole objects only).
319    pub fn read_body_range(&self, body: &BodyRef, offset: u64, len: u64) -> io::Result<Bytes> {
320        match body {
321            BodyRef::Memory(b) => {
322                let start = offset as usize;
323                let end = start.saturating_add(len as usize).min(b.len());
324                if start > b.len() {
325                    return Ok(Bytes::new());
326                }
327                Ok(b.slice(start..end))
328            }
329            BodyRef::Disk { path, .. } => {
330                let mut f = std::fs::File::open(path)?;
331                f.seek(SeekFrom::Start(offset))?;
332                let mut buf = vec![0u8; len as usize];
333                f.read_exact(&mut buf)?;
334                Ok(Bytes::from(buf))
335            }
336        }
337    }
338}
339
340impl fakecloud_core::multi_account::AccountState for S3State {
341    fn new_for_account(account_id: &str, region: &str, _endpoint: &str) -> Self {
342        Self::new(account_id, region)
343    }
344
345    fn inherit_from(&mut self, sibling: &Self) {
346        if let Some(cache) = &sibling.body_cache {
347            self.body_cache = Some(cache.clone());
348        }
349    }
350}
351
352pub type SharedS3State = Arc<RwLock<fakecloud_core::multi_account::MultiAccountState<S3State>>>;
353
354/// Construct a memory-backed [`BodyRef`] from [`Bytes`].
355pub fn memory_body(bytes: Bytes) -> BodyRef {
356    BodyRef::Memory(bytes)
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::io::Write;
363
364    #[test]
365    fn new_bucket_seeds_full_control_acl() {
366        let b = S3Bucket::new("my-bucket", "us-east-1", "owner-id");
367        assert_eq!(b.name, "my-bucket");
368        assert_eq!(b.region, "us-east-1");
369        assert_eq!(b.acl_owner_id, "owner-id");
370        assert_eq!(b.acl_grants.len(), 1);
371        assert_eq!(b.acl_grants[0].permission, "FULL_CONTROL");
372        assert_eq!(b.acl_grants[0].grantee_type, "CanonicalUser");
373        assert!(!b.eventbridge_enabled);
374        assert!(b.versioning.is_none());
375    }
376
377    #[test]
378    fn s3state_new_and_reset_clears_buckets() {
379        let mut state = S3State::new("123456789012", "us-east-1");
380        assert!(state.buckets.is_empty());
381        state
382            .buckets
383            .insert("b".to_string(), S3Bucket::new("b", "us-east-1", "owner"));
384        state.notification_events.push(S3NotificationEvent {
385            bucket: "b".to_string(),
386            key: "k".to_string(),
387            event_type: "s3:ObjectCreated:Put".to_string(),
388            timestamp: Utc::now(),
389        });
390        state.reset();
391        assert!(state.buckets.is_empty());
392        assert!(state.notification_events.is_empty());
393    }
394
395    #[test]
396    fn read_body_from_memory_returns_bytes() {
397        let state = S3State::new("123", "us-east-1");
398        let body = memory_body(Bytes::from_static(b"hello"));
399        assert_eq!(state.read_body(&body).unwrap(), &b"hello"[..]);
400    }
401
402    #[test]
403    fn read_body_from_disk_reads_file() {
404        let tmp = tempfile::NamedTempFile::new().unwrap();
405        tmp.as_file().write_all(b"file-body").unwrap();
406        let body = BodyRef::Disk {
407            bucket: "b".to_string(),
408            key: "k".to_string(),
409            version: None,
410            path: tmp.path().to_path_buf(),
411            size: 9,
412        };
413        let state = S3State::new("123", "us-east-1");
414        assert_eq!(state.read_body(&body).unwrap(), &b"file-body"[..]);
415    }
416
417    #[test]
418    fn read_body_uncached_reads_memory_and_disk() {
419        // bug-audit 4.7: the off-lock multipart assembler relies on this
420        // state-free reader for both body kinds.
421        let mem = memory_body(Bytes::from_static(b"hello"));
422        assert_eq!(S3State::read_body_uncached(&mem).unwrap(), &b"hello"[..]);
423
424        let tmp = tempfile::NamedTempFile::new().unwrap();
425        tmp.as_file().write_all(b"file-body").unwrap();
426        let disk = BodyRef::Disk {
427            bucket: "b".to_string(),
428            key: "k".to_string(),
429            version: None,
430            path: tmp.path().to_path_buf(),
431            size: 9,
432        };
433        assert_eq!(
434            S3State::read_body_uncached(&disk).unwrap(),
435            &b"file-body"[..]
436        );
437    }
438
439    #[test]
440    fn read_body_range_slices_memory() {
441        let state = S3State::new("123", "us-east-1");
442        let body = memory_body(Bytes::from_static(b"abcdefghij"));
443        assert_eq!(state.read_body_range(&body, 2, 4).unwrap(), &b"cdef"[..]);
444    }
445
446    #[test]
447    fn read_body_range_memory_beyond_length_returns_empty() {
448        let state = S3State::new("123", "us-east-1");
449        let body = memory_body(Bytes::from_static(b"abc"));
450        assert!(state.read_body_range(&body, 100, 4).unwrap().is_empty());
451    }
452
453    #[test]
454    fn read_body_range_memory_clamps_to_length() {
455        let state = S3State::new("123", "us-east-1");
456        let body = memory_body(Bytes::from_static(b"abcdef"));
457        assert_eq!(state.read_body_range(&body, 4, 100).unwrap(), &b"ef"[..]);
458    }
459
460    #[test]
461    fn read_body_range_from_disk() {
462        let tmp = tempfile::NamedTempFile::new().unwrap();
463        tmp.as_file().write_all(b"0123456789").unwrap();
464        let body = BodyRef::Disk {
465            bucket: "b".to_string(),
466            key: "k".to_string(),
467            version: None,
468            path: tmp.path().to_path_buf(),
469            size: 10,
470        };
471        let state = S3State::new("123", "us-east-1");
472        assert_eq!(state.read_body_range(&body, 3, 4).unwrap(), &b"3456"[..]);
473    }
474
475    #[test]
476    fn account_state_impl_new_for_account() {
477        use fakecloud_core::multi_account::AccountState;
478        let s = S3State::new_for_account("111122223333", "eu-west-1", "http://x");
479        assert_eq!(s.account_id, "111122223333");
480        assert_eq!(s.region, "eu-west-1");
481    }
482}