bucketwarden-server 0.1.0

BucketWarden storage server runtime.
Documentation
use super::*;

const MAX_BUCKET_INVENTORY_CONFIGURATIONS: usize = 1_000;
const MAX_BUCKET_INVENTORY_PAGE_SIZE: usize = 100;

impl BucketWarden {
    pub fn put_bucket_inventory_configuration(
        &mut self,
        principal: &str,
        bucket: &str,
        mut configuration: InventoryConfiguration,
    ) -> Result<InventoryConfiguration, RuntimeError> {
        self.authorize(principal, S3Action::PutBucketInventoryConfiguration, bucket)?;
        validate_inventory_configuration(&configuration)?;
        let bucket_state = self.require_bucket_mut(bucket)?;
        if !bucket_state
            .inventory_configurations
            .contains_key(&configuration.id)
            && bucket_state.inventory_configurations.len() >= MAX_BUCKET_INVENTORY_CONFIGURATIONS
        {
            return Err(RuntimeError::TooManyConfigurations(bucket.to_string()));
        }
        configuration.bucket = bucket.to_string();
        bucket_state
            .inventory_configurations
            .insert(configuration.id.clone(), configuration.clone());
        self.audit_allowed(
            principal,
            S3Action::PutBucketInventoryConfiguration,
            bucket,
            Some(configuration.id.clone()),
        );
        Ok(configuration)
    }

    pub fn get_bucket_inventory_configuration(
        &mut self,
        principal: &str,
        bucket: &str,
        id: &str,
    ) -> Result<InventoryConfiguration, RuntimeError> {
        self.authorize(principal, S3Action::GetBucketInventoryConfiguration, bucket)?;
        let configuration = self
            .require_bucket(bucket)?
            .inventory_configurations
            .get(id)
            .cloned()
            .ok_or_else(|| RuntimeError::NoSuchInventoryConfiguration {
                bucket: bucket.to_string(),
                id: id.to_string(),
            })?;
        self.audit_allowed(
            principal,
            S3Action::GetBucketInventoryConfiguration,
            bucket,
            Some(id.to_string()),
        );
        Ok(configuration)
    }

    pub fn delete_bucket_inventory_configuration(
        &mut self,
        principal: &str,
        bucket: &str,
        id: &str,
    ) -> Result<(), RuntimeError> {
        self.authorize(
            principal,
            S3Action::DeleteBucketInventoryConfiguration,
            bucket,
        )?;
        self.require_bucket_mut(bucket)?
            .inventory_configurations
            .remove(id);
        self.audit_allowed(
            principal,
            S3Action::DeleteBucketInventoryConfiguration,
            bucket,
            Some(id.to_string()),
        );
        Ok(())
    }

    pub fn list_bucket_inventory_configurations(
        &mut self,
        principal: &str,
        bucket: &str,
        continuation_token: Option<&str>,
    ) -> Result<ListInventoryConfigurationsResult, RuntimeError> {
        self.authorize(
            principal,
            S3Action::ListBucketInventoryConfigurations,
            bucket,
        )?;
        let state = self.require_bucket(bucket)?;
        let all = state
            .inventory_configurations
            .values()
            .cloned()
            .collect::<Vec<_>>();
        let start_index = continuation_token
            .map(|token| all.partition_point(|configuration| configuration.id.as_str() <= token))
            .unwrap_or(0);
        let page = all
            .iter()
            .skip(start_index)
            .take(MAX_BUCKET_INVENTORY_PAGE_SIZE)
            .cloned()
            .collect::<Vec<_>>();
        let next_index = start_index + page.len();
        let next_continuation_token = (next_index < all.len())
            .then(|| page.last().map(|configuration| configuration.id.clone()))
            .flatten();
        let result = ListInventoryConfigurationsResult {
            continuation_token: continuation_token.map(str::to_string),
            inventory_configurations: page,
            is_truncated: next_index < all.len(),
            next_continuation_token,
        };
        self.audit_allowed(
            principal,
            S3Action::ListBucketInventoryConfigurations,
            bucket,
            Some(result.inventory_configurations.len().to_string()),
        );
        Ok(result)
    }
}

fn validate_inventory_configuration(
    configuration: &InventoryConfiguration,
) -> Result<(), RuntimeError> {
    validate_inventory_configuration_id(&configuration.id)?;
    match configuration.included_object_versions.as_str() {
        "All" | "Current" => {}
        other => {
            return Err(RuntimeError::InvalidInventoryConfiguration(format!(
                "IncludedObjectVersions must be All or Current, got {other}"
            )));
        }
    }
    match configuration.schedule.frequency.as_str() {
        "Daily" | "Weekly" => {}
        other => {
            return Err(RuntimeError::InvalidInventoryConfiguration(format!(
                "Schedule frequency must be Daily or Weekly, got {other}"
            )));
        }
    }
    let destination = &configuration.destination.s3_bucket_destination;
    if !destination.bucket_arn.starts_with("arn:aws:s3:::") {
        return Err(RuntimeError::InvalidInventoryConfiguration(
            "Inventory destination Bucket must be an S3 bucket ARN".to_string(),
        ));
    }
    if destination.format != "CSV" {
        return Err(RuntimeError::InvalidInventoryConfiguration(
            "Inventory destination Format must be CSV".to_string(),
        ));
    }
    if let Some(account_id) = destination.account_id.as_ref() {
        if account_id.len() != 12 || !account_id.chars().all(|ch| ch.is_ascii_digit()) {
            return Err(RuntimeError::InvalidInventoryConfiguration(
                "Inventory destination AccountId must be a 12-digit AWS account ID".to_string(),
            ));
        }
    }
    if let Some(filter) = configuration.filter.as_ref() {
        if filter
            .prefix
            .as_ref()
            .is_some_and(|prefix| prefix.trim().is_empty())
        {
            return Err(RuntimeError::InvalidInventoryConfiguration(
                "Inventory filter Prefix must not be empty".to_string(),
            ));
        }
    }
    for field in &configuration.optional_fields {
        if field.trim().is_empty() {
            return Err(RuntimeError::InvalidInventoryConfiguration(
                "Inventory optional fields must not be empty".to_string(),
            ));
        }
    }
    Ok(())
}

fn validate_inventory_configuration_id(id: &str) -> Result<(), RuntimeError> {
    let trimmed = id.trim();
    if trimmed.is_empty() || trimmed.len() > 64 {
        return Err(RuntimeError::InvalidInventoryConfiguration(
            "Inventory configuration Id must be 1-64 characters".to_string(),
        ));
    }
    if !trimmed
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
    {
        return Err(RuntimeError::InvalidInventoryConfiguration(
            "Inventory configuration Id contains unsupported characters".to_string(),
        ));
    }
    Ok(())
}