use chrono::{DateTime, Utc};
use dashmap::DashMap;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use tracing::debug;
use super::{
keystore::ObjectStore,
multipart::MultipartUpload,
object::{CannedAcl, Owner},
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersioningStatus {
#[default]
Disabled,
Enabled,
Suspended,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BucketEncryption {
pub sse_algorithm: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kms_master_key_id: Option<String>,
#[serde(default)]
pub bucket_key_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CorsRuleConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub allowed_origins: Vec<String>,
pub allowed_methods: Vec<String>,
#[serde(default)]
pub allowed_headers: Vec<String>,
#[serde(default)]
pub expose_headers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_age_seconds: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::struct_excessive_bools)]
pub struct PublicAccessBlockConfig {
#[serde(default)]
pub block_public_acls: bool,
#[serde(default)]
pub ignore_public_acls: bool,
#[serde(default)]
pub block_public_policy: bool,
#[serde(default)]
pub restrict_public_buckets: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OwnershipControlsConfig {
pub object_ownership: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectLockConfiguration {
pub object_lock_enabled: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule: Option<ObjectLockRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectLockRule {
#[serde(skip_serializing_if = "Option::is_none")]
pub default_retention: Option<DefaultRetention>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DefaultRetention {
pub mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub days: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub years: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebsiteConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub index_document_suffix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_document_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_all_requests_to_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_all_requests_to_protocol: Option<String>,
}
pub struct S3Bucket {
pub name: String,
pub region: String,
pub creation_date: DateTime<Utc>,
pub owner: Owner,
pub objects: RwLock<ObjectStore>,
pub multipart_uploads: DashMap<String, MultipartUpload>,
pub versioning: RwLock<VersioningStatus>,
pub encryption: RwLock<Option<BucketEncryption>>,
pub cors_rules: RwLock<Option<Vec<CorsRuleConfig>>>,
pub lifecycle: RwLock<Option<rustack_s3_model::types::BucketLifecycleConfiguration>>,
pub policy: RwLock<Option<String>>,
pub tags: RwLock<Vec<(String, String)>>,
pub acl: RwLock<CannedAcl>,
pub notification_configuration:
RwLock<Option<rustack_s3_model::types::NotificationConfiguration>>,
pub logging: RwLock<Option<serde_json::Value>>,
pub public_access_block: RwLock<Option<PublicAccessBlockConfig>>,
pub ownership_controls: RwLock<Option<OwnershipControlsConfig>>,
pub object_lock_enabled: RwLock<bool>,
pub object_lock_configuration: RwLock<Option<ObjectLockConfiguration>>,
pub accelerate: RwLock<Option<String>>,
pub request_payment: RwLock<String>,
pub website: RwLock<Option<WebsiteConfig>>,
pub replication: RwLock<Option<serde_json::Value>>,
pub analytics: RwLock<Option<serde_json::Value>>,
pub metrics: RwLock<Option<serde_json::Value>>,
pub inventory: RwLock<Option<serde_json::Value>>,
pub intelligent_tiering: RwLock<Option<serde_json::Value>>,
}
impl std::fmt::Debug for S3Bucket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("S3Bucket")
.field("name", &self.name)
.field("region", &self.region)
.field("creation_date", &self.creation_date)
.field("owner", &self.owner)
.field("versioning", &*self.versioning.read())
.finish_non_exhaustive()
}
}
impl S3Bucket {
#[must_use]
pub fn new(name: String, region: String, owner: Owner) -> Self {
Self {
name,
region,
creation_date: Utc::now(),
owner,
objects: RwLock::new(ObjectStore::default()),
multipart_uploads: DashMap::new(),
versioning: RwLock::new(VersioningStatus::default()),
encryption: RwLock::new(None),
cors_rules: RwLock::new(None),
lifecycle: RwLock::new(None),
policy: RwLock::new(None),
tags: RwLock::new(Vec::new()),
acl: RwLock::new(CannedAcl::default()),
notification_configuration: RwLock::new(None),
logging: RwLock::new(None),
public_access_block: RwLock::new(None),
ownership_controls: RwLock::new(None),
object_lock_enabled: RwLock::new(false),
object_lock_configuration: RwLock::new(None),
accelerate: RwLock::new(None),
request_payment: RwLock::new("BucketOwner".to_owned()),
website: RwLock::new(None),
replication: RwLock::new(None),
analytics: RwLock::new(None),
metrics: RwLock::new(None),
inventory: RwLock::new(None),
intelligent_tiering: RwLock::new(None),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.objects.read().is_empty() && self.multipart_uploads.is_empty()
}
#[must_use]
pub fn is_versioning_enabled(&self) -> bool {
*self.versioning.read() == VersioningStatus::Enabled
}
pub fn enable_versioning(&self) {
let mut status = self.versioning.write();
if *status != VersioningStatus::Enabled {
debug!(bucket = %self.name, "enabling versioning");
let mut store = self.objects.write();
store.transition_to_versioned();
*status = VersioningStatus::Enabled;
}
}
pub fn suspend_versioning(&self) {
let mut status = self.versioning.write();
if *status == VersioningStatus::Enabled {
debug!(bucket = %self.name, "suspending versioning");
*status = VersioningStatus::Suspended;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_bucket(name: &str) -> S3Bucket {
S3Bucket::new(name.to_owned(), "us-east-1".to_owned(), Owner::default())
}
#[test]
fn test_should_create_bucket_with_defaults() {
let bucket = make_bucket("test-bucket");
assert_eq!(bucket.name, "test-bucket");
assert_eq!(bucket.region, "us-east-1");
assert!(bucket.is_empty());
assert!(!bucket.is_versioning_enabled());
assert_eq!(*bucket.versioning.read(), VersioningStatus::Disabled);
assert_eq!(*bucket.acl.read(), CannedAcl::Private);
assert_eq!(*bucket.request_payment.read(), "BucketOwner");
}
#[test]
fn test_should_debug_format_bucket() {
let bucket = make_bucket("debug-bucket");
let debug_str = format!("{bucket:?}");
assert!(debug_str.contains("debug-bucket"));
assert!(debug_str.contains("S3Bucket"));
}
#[test]
fn test_should_enable_versioning() {
let bucket = make_bucket("versioned-bucket");
assert!(!bucket.is_versioning_enabled());
assert!(!bucket.objects.read().is_versioned());
bucket.enable_versioning();
assert!(bucket.is_versioning_enabled());
assert!(bucket.objects.read().is_versioned());
}
#[test]
fn test_should_suspend_versioning() {
let bucket = make_bucket("suspend-bucket");
bucket.enable_versioning();
assert!(bucket.is_versioning_enabled());
bucket.suspend_versioning();
assert!(!bucket.is_versioning_enabled());
assert_eq!(*bucket.versioning.read(), VersioningStatus::Suspended);
assert!(bucket.objects.read().is_versioned());
}
#[test]
fn test_should_not_suspend_if_never_enabled() {
let bucket = make_bucket("never-versioned");
bucket.suspend_versioning();
assert_eq!(*bucket.versioning.read(), VersioningStatus::Disabled);
}
#[test]
fn test_should_enable_versioning_idempotent() {
let bucket = make_bucket("idem-bucket");
bucket.enable_versioning();
bucket.enable_versioning();
assert!(bucket.is_versioning_enabled());
}
#[test]
fn test_should_report_empty_with_no_objects_or_uploads() {
let bucket = make_bucket("empty-bucket");
assert!(bucket.is_empty());
}
#[test]
fn test_should_report_not_empty_with_multipart() {
let bucket = make_bucket("mp-bucket");
let upload = super::super::multipart::MultipartUpload::new(
"upload-1".to_owned(),
"key".to_owned(),
Owner::default(),
super::super::object::ObjectMetadata::default(),
);
bucket
.multipart_uploads
.insert("upload-1".to_owned(), upload);
assert!(!bucket.is_empty());
}
#[test]
fn test_should_default_versioning_status_to_disabled() {
assert_eq!(VersioningStatus::default(), VersioningStatus::Disabled);
}
#[test]
fn test_should_create_cors_rule_config() {
let rule = CorsRuleConfig {
id: Some("rule-1".to_owned()),
allowed_origins: vec!["*".to_owned()],
allowed_methods: vec!["GET".to_owned(), "PUT".to_owned()],
allowed_headers: vec!["*".to_owned()],
expose_headers: Vec::new(),
max_age_seconds: Some(3600),
};
assert_eq!(rule.id, Some("rule-1".to_owned()));
assert_eq!(rule.allowed_methods.len(), 2);
}
#[test]
fn test_should_create_public_access_block_config() {
let config = PublicAccessBlockConfig {
block_public_acls: true,
ignore_public_acls: true,
block_public_policy: true,
restrict_public_buckets: true,
};
assert!(config.block_public_acls);
assert!(config.restrict_public_buckets);
}
#[test]
fn test_should_create_object_lock_configuration() {
let config = ObjectLockConfiguration {
object_lock_enabled: "Enabled".to_owned(),
rule: Some(ObjectLockRule {
default_retention: Some(DefaultRetention {
mode: "GOVERNANCE".to_owned(),
days: Some(30),
years: None,
}),
}),
};
let retention = config
.rule
.as_ref()
.and_then(|r| r.default_retention.as_ref());
assert!(retention.is_some());
assert_eq!(retention.map(|r| r.days), Some(Some(30)));
}
#[test]
fn test_should_create_bucket_encryption() {
let enc = BucketEncryption {
sse_algorithm: "aws:kms".to_owned(),
kms_master_key_id: Some("arn:aws:kms:us-east-1:123456789012:key/abc".to_owned()),
bucket_key_enabled: true,
};
assert_eq!(enc.sse_algorithm, "aws:kms");
assert!(enc.bucket_key_enabled);
}
}