Skip to main content

rustack_s3_core/state/
bucket.rs

1//! S3 bucket data structure and configuration types.
2//!
3//! An [`S3Bucket`] holds all per-bucket state: objects, multipart uploads,
4//! versioning status, and the many optional configurations (encryption, CORS,
5//! lifecycle, policy, tags, ACL, notification, logging, public-access-block,
6//! ownership controls, object lock, accelerate, request-payment, website,
7//! replication, analytics, metrics, inventory, intelligent-tiering).
8//!
9//! Interior mutability is achieved through `parking_lot::RwLock` for
10//! single-valued configuration fields and for the object store, and
11//! `DashMap` for the multipart upload table.
12
13use chrono::{DateTime, Utc};
14use dashmap::DashMap;
15use parking_lot::RwLock;
16use serde::{Deserialize, Serialize};
17use tracing::debug;
18
19use super::{
20    keystore::ObjectStore,
21    multipart::MultipartUpload,
22    object::{CannedAcl, Owner},
23};
24
25// ---------------------------------------------------------------------------
26// Supporting configuration types
27// ---------------------------------------------------------------------------
28
29/// Bucket versioning status.
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
31pub enum VersioningStatus {
32    /// Versioning has never been enabled on this bucket.
33    #[default]
34    Disabled,
35    /// Versioning is currently enabled.
36    Enabled,
37    /// Versioning was previously enabled but is now suspended.
38    Suspended,
39}
40
41/// Server-side encryption configuration for a bucket.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct BucketEncryption {
45    /// The encryption algorithm (e.g. `AES256`, `aws:kms`, `aws:kms:dsse`).
46    pub sse_algorithm: String,
47    /// KMS master key ID (only for `aws:kms` or `aws:kms:dsse`).
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub kms_master_key_id: Option<String>,
50    /// Whether an S3 Bucket Key is enabled for SSE-KMS.
51    #[serde(default)]
52    pub bucket_key_enabled: bool,
53}
54
55/// CORS rule configuration stored on a bucket.
56///
57/// This is the raw configuration value, not the evaluated CORS rule used at
58/// request time (see `cors.rs` for the runtime representation).
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct CorsRuleConfig {
62    /// Optional identifier for the rule.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub id: Option<String>,
65    /// Origins that are allowed to make cross-domain requests.
66    pub allowed_origins: Vec<String>,
67    /// HTTP methods that the origin is allowed to execute.
68    pub allowed_methods: Vec<String>,
69    /// Headers that are allowed in a pre-flight `OPTIONS` request.
70    #[serde(default)]
71    pub allowed_headers: Vec<String>,
72    /// Headers in the response that customers are able to access.
73    #[serde(default)]
74    pub expose_headers: Vec<String>,
75    /// Time in seconds that the browser should cache the preflight response.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub max_age_seconds: Option<i32>,
78}
79
80/// Public access block configuration for a bucket.
81///
82/// AWS defines exactly four boolean fields for this configuration.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85#[allow(clippy::struct_excessive_bools)]
86pub struct PublicAccessBlockConfig {
87    /// Whether Amazon S3 should block public ACLs for this bucket.
88    #[serde(default)]
89    pub block_public_acls: bool,
90    /// Whether Amazon S3 should ignore public ACLs for this bucket.
91    #[serde(default)]
92    pub ignore_public_acls: bool,
93    /// Whether Amazon S3 should block public bucket policies.
94    #[serde(default)]
95    pub block_public_policy: bool,
96    /// Whether Amazon S3 should restrict public bucket policies.
97    #[serde(default)]
98    pub restrict_public_buckets: bool,
99}
100
101/// Bucket ownership controls configuration.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct OwnershipControlsConfig {
105    /// The object ownership setting (e.g. `BucketOwnerPreferred`,
106    /// `ObjectWriter`, `BucketOwnerEnforced`).
107    pub object_ownership: String,
108}
109
110/// Object Lock configuration for a bucket.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct ObjectLockConfiguration {
114    /// Whether object lock is enabled (`Enabled`).
115    pub object_lock_enabled: String,
116    /// Optional default retention rule.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub rule: Option<ObjectLockRule>,
119}
120
121/// A default retention rule within an Object Lock configuration.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct ObjectLockRule {
125    /// The default retention settings.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub default_retention: Option<DefaultRetention>,
128}
129
130/// Default retention settings for Object Lock.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct DefaultRetention {
134    /// The retention mode (`GOVERNANCE` or `COMPLIANCE`).
135    pub mode: String,
136    /// Number of days to retain the object.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub days: Option<i32>,
139    /// Number of years to retain the object.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub years: Option<i32>,
142}
143
144/// Static website hosting configuration for a bucket.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct WebsiteConfig {
148    /// The name of the index document (e.g. `index.html`).
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub index_document_suffix: Option<String>,
151    /// The key of the error document (e.g. `error.html`).
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub error_document_key: Option<String>,
154    /// Redirect all requests to another host/protocol.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub redirect_all_requests_to_host: Option<String>,
157    /// Protocol for the redirect (`http` or `https`).
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub redirect_all_requests_to_protocol: Option<String>,
160}
161
162// ---------------------------------------------------------------------------
163// S3Bucket
164// ---------------------------------------------------------------------------
165
166/// An S3 bucket with all its state and configuration.
167///
168/// Thread-safe: interior fields use `parking_lot::RwLock` for configuration
169/// and objects, `DashMap` for multipart uploads.
170pub struct S3Bucket {
171    /// Bucket name.
172    pub name: String,
173    /// AWS region where this bucket was created.
174    pub region: String,
175    /// When the bucket was created.
176    pub creation_date: DateTime<Utc>,
177    /// The bucket owner.
178    pub owner: Owner,
179
180    // -- object storage --
181    /// Object key storage (un-versioned or versioned).
182    pub objects: RwLock<ObjectStore>,
183    /// In-progress multipart uploads, keyed by upload ID.
184    pub multipart_uploads: DashMap<String, MultipartUpload>,
185
186    // -- versioning --
187    /// Bucket versioning status.
188    pub versioning: RwLock<VersioningStatus>,
189
190    // -- configurations (all wrapped in RwLock for interior mutability) --
191    /// Server-side encryption configuration.
192    pub encryption: RwLock<Option<BucketEncryption>>,
193    /// CORS rules.
194    pub cors_rules: RwLock<Option<Vec<CorsRuleConfig>>>,
195    /// Lifecycle configuration.
196    pub lifecycle: RwLock<Option<rustack_s3_model::types::BucketLifecycleConfiguration>>,
197    /// Bucket policy (JSON string).
198    pub policy: RwLock<Option<String>>,
199    /// Bucket tags.
200    pub tags: RwLock<Vec<(String, String)>>,
201    /// Canned ACL for the bucket.
202    pub acl: RwLock<CannedAcl>,
203    /// Notification configuration for the bucket.
204    pub notification_configuration:
205        RwLock<Option<rustack_s3_model::types::NotificationConfiguration>>,
206    /// Logging configuration (stored as opaque JSON).
207    pub logging: RwLock<Option<serde_json::Value>>,
208    /// Public access block settings.
209    pub public_access_block: RwLock<Option<PublicAccessBlockConfig>>,
210    /// Ownership controls.
211    pub ownership_controls: RwLock<Option<OwnershipControlsConfig>>,
212    /// Whether Object Lock is enabled on this bucket.
213    pub object_lock_enabled: RwLock<bool>,
214    /// Object Lock configuration (retention rules).
215    pub object_lock_configuration: RwLock<Option<ObjectLockConfiguration>>,
216    /// Transfer acceleration status (e.g. `"Enabled"`, `"Suspended"`).
217    pub accelerate: RwLock<Option<String>>,
218    /// Request payment configuration (default `"BucketOwner"`).
219    pub request_payment: RwLock<String>,
220    /// Static website hosting configuration.
221    pub website: RwLock<Option<WebsiteConfig>>,
222    /// Replication configuration (stored as opaque JSON).
223    pub replication: RwLock<Option<serde_json::Value>>,
224    /// Analytics configuration (stored as opaque JSON).
225    pub analytics: RwLock<Option<serde_json::Value>>,
226    /// Metrics configuration (stored as opaque JSON).
227    pub metrics: RwLock<Option<serde_json::Value>>,
228    /// Inventory configuration (stored as opaque JSON).
229    pub inventory: RwLock<Option<serde_json::Value>>,
230    /// Intelligent-Tiering configuration (stored as opaque JSON).
231    pub intelligent_tiering: RwLock<Option<serde_json::Value>>,
232}
233
234impl std::fmt::Debug for S3Bucket {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.debug_struct("S3Bucket")
237            .field("name", &self.name)
238            .field("region", &self.region)
239            .field("creation_date", &self.creation_date)
240            .field("owner", &self.owner)
241            .field("versioning", &*self.versioning.read())
242            .finish_non_exhaustive()
243    }
244}
245
246impl S3Bucket {
247    /// Create a new bucket with the given name, region, and owner.
248    ///
249    /// All configuration fields are initialized to their defaults.
250    #[must_use]
251    pub fn new(name: String, region: String, owner: Owner) -> Self {
252        Self {
253            name,
254            region,
255            creation_date: Utc::now(),
256            owner,
257            objects: RwLock::new(ObjectStore::default()),
258            multipart_uploads: DashMap::new(),
259            versioning: RwLock::new(VersioningStatus::default()),
260            encryption: RwLock::new(None),
261            cors_rules: RwLock::new(None),
262            lifecycle: RwLock::new(None),
263            policy: RwLock::new(None),
264            tags: RwLock::new(Vec::new()),
265            acl: RwLock::new(CannedAcl::default()),
266            notification_configuration: RwLock::new(None),
267            logging: RwLock::new(None),
268            public_access_block: RwLock::new(None),
269            ownership_controls: RwLock::new(None),
270            object_lock_enabled: RwLock::new(false),
271            object_lock_configuration: RwLock::new(None),
272            accelerate: RwLock::new(None),
273            request_payment: RwLock::new("BucketOwner".to_owned()),
274            website: RwLock::new(None),
275            replication: RwLock::new(None),
276            analytics: RwLock::new(None),
277            metrics: RwLock::new(None),
278            inventory: RwLock::new(None),
279            intelligent_tiering: RwLock::new(None),
280        }
281    }
282
283    /// Whether the bucket contains zero objects (and no in-progress multipart uploads).
284    #[must_use]
285    pub fn is_empty(&self) -> bool {
286        self.objects.read().is_empty() && self.multipart_uploads.is_empty()
287    }
288
289    /// Whether versioning is currently enabled on this bucket.
290    #[must_use]
291    pub fn is_versioning_enabled(&self) -> bool {
292        *self.versioning.read() == VersioningStatus::Enabled
293    }
294
295    /// Enable versioning on this bucket.
296    ///
297    /// If the bucket is currently un-versioned, the object store is
298    /// transitioned to a [`super::keystore::VersionedKeyStore`]. If
299    /// versioning was suspended, it is simply re-enabled.
300    pub fn enable_versioning(&self) {
301        let mut status = self.versioning.write();
302        if *status != VersioningStatus::Enabled {
303            debug!(bucket = %self.name, "enabling versioning");
304            // Transition the object store to versioned if it is not already.
305            let mut store = self.objects.write();
306            store.transition_to_versioned();
307            *status = VersioningStatus::Enabled;
308        }
309    }
310
311    /// Suspend versioning on this bucket.
312    ///
313    /// Objects already stored retain their version history. New puts will
314    /// receive a version ID of `"null"` (overwriting any existing `"null"`
315    /// version).
316    pub fn suspend_versioning(&self) {
317        let mut status = self.versioning.write();
318        if *status == VersioningStatus::Enabled {
319            debug!(bucket = %self.name, "suspending versioning");
320            *status = VersioningStatus::Suspended;
321        }
322    }
323}
324
325// ---------------------------------------------------------------------------
326// Tests
327// ---------------------------------------------------------------------------
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn make_bucket(name: &str) -> S3Bucket {
334        S3Bucket::new(name.to_owned(), "us-east-1".to_owned(), Owner::default())
335    }
336
337    #[test]
338    fn test_should_create_bucket_with_defaults() {
339        let bucket = make_bucket("test-bucket");
340        assert_eq!(bucket.name, "test-bucket");
341        assert_eq!(bucket.region, "us-east-1");
342        assert!(bucket.is_empty());
343        assert!(!bucket.is_versioning_enabled());
344        assert_eq!(*bucket.versioning.read(), VersioningStatus::Disabled);
345        assert_eq!(*bucket.acl.read(), CannedAcl::Private);
346        assert_eq!(*bucket.request_payment.read(), "BucketOwner");
347    }
348
349    #[test]
350    fn test_should_debug_format_bucket() {
351        let bucket = make_bucket("debug-bucket");
352        let debug_str = format!("{bucket:?}");
353        assert!(debug_str.contains("debug-bucket"));
354        assert!(debug_str.contains("S3Bucket"));
355    }
356
357    #[test]
358    fn test_should_enable_versioning() {
359        let bucket = make_bucket("versioned-bucket");
360        assert!(!bucket.is_versioning_enabled());
361        assert!(!bucket.objects.read().is_versioned());
362
363        bucket.enable_versioning();
364        assert!(bucket.is_versioning_enabled());
365        assert!(bucket.objects.read().is_versioned());
366    }
367
368    #[test]
369    fn test_should_suspend_versioning() {
370        let bucket = make_bucket("suspend-bucket");
371        bucket.enable_versioning();
372        assert!(bucket.is_versioning_enabled());
373
374        bucket.suspend_versioning();
375        assert!(!bucket.is_versioning_enabled());
376        assert_eq!(*bucket.versioning.read(), VersioningStatus::Suspended);
377        // Object store remains versioned even when suspended.
378        assert!(bucket.objects.read().is_versioned());
379    }
380
381    #[test]
382    fn test_should_not_suspend_if_never_enabled() {
383        let bucket = make_bucket("never-versioned");
384        bucket.suspend_versioning();
385        // Should remain Disabled, not Suspended.
386        assert_eq!(*bucket.versioning.read(), VersioningStatus::Disabled);
387    }
388
389    #[test]
390    fn test_should_enable_versioning_idempotent() {
391        let bucket = make_bucket("idem-bucket");
392        bucket.enable_versioning();
393        bucket.enable_versioning();
394        assert!(bucket.is_versioning_enabled());
395    }
396
397    #[test]
398    fn test_should_report_empty_with_no_objects_or_uploads() {
399        let bucket = make_bucket("empty-bucket");
400        assert!(bucket.is_empty());
401    }
402
403    #[test]
404    fn test_should_report_not_empty_with_multipart() {
405        let bucket = make_bucket("mp-bucket");
406        let upload = super::super::multipart::MultipartUpload::new(
407            "upload-1".to_owned(),
408            "key".to_owned(),
409            Owner::default(),
410            super::super::object::ObjectMetadata::default(),
411        );
412        bucket
413            .multipart_uploads
414            .insert("upload-1".to_owned(), upload);
415        assert!(!bucket.is_empty());
416    }
417
418    #[test]
419    fn test_should_default_versioning_status_to_disabled() {
420        assert_eq!(VersioningStatus::default(), VersioningStatus::Disabled);
421    }
422
423    #[test]
424    fn test_should_create_cors_rule_config() {
425        let rule = CorsRuleConfig {
426            id: Some("rule-1".to_owned()),
427            allowed_origins: vec!["*".to_owned()],
428            allowed_methods: vec!["GET".to_owned(), "PUT".to_owned()],
429            allowed_headers: vec!["*".to_owned()],
430            expose_headers: Vec::new(),
431            max_age_seconds: Some(3600),
432        };
433        assert_eq!(rule.id, Some("rule-1".to_owned()));
434        assert_eq!(rule.allowed_methods.len(), 2);
435    }
436
437    #[test]
438    fn test_should_create_public_access_block_config() {
439        let config = PublicAccessBlockConfig {
440            block_public_acls: true,
441            ignore_public_acls: true,
442            block_public_policy: true,
443            restrict_public_buckets: true,
444        };
445        assert!(config.block_public_acls);
446        assert!(config.restrict_public_buckets);
447    }
448
449    #[test]
450    fn test_should_create_object_lock_configuration() {
451        let config = ObjectLockConfiguration {
452            object_lock_enabled: "Enabled".to_owned(),
453            rule: Some(ObjectLockRule {
454                default_retention: Some(DefaultRetention {
455                    mode: "GOVERNANCE".to_owned(),
456                    days: Some(30),
457                    years: None,
458                }),
459            }),
460        };
461        let retention = config
462            .rule
463            .as_ref()
464            .and_then(|r| r.default_retention.as_ref());
465        assert!(retention.is_some());
466        assert_eq!(retention.map(|r| r.days), Some(Some(30)));
467    }
468
469    #[test]
470    fn test_should_create_bucket_encryption() {
471        let enc = BucketEncryption {
472            sse_algorithm: "aws:kms".to_owned(),
473            kms_master_key_id: Some("arn:aws:kms:us-east-1:123456789012:key/abc".to_owned()),
474            bucket_key_enabled: true,
475        };
476        assert_eq!(enc.sse_algorithm, "aws:kms");
477        assert!(enc.bucket_key_enabled);
478    }
479}