bucketwarden-server 0.1.0

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

const MAX_BUCKET_ANALYTICS_CONFIGURATIONS: usize = 1_000;
const MAX_BUCKET_ANALYTICS_PAGE_SIZE: usize = 100;

impl BucketWarden {
    pub fn put_bucket_analytics_configuration(
        &mut self,
        principal: &str,
        bucket: &str,
        mut configuration: AnalyticsConfiguration,
    ) -> Result<AnalyticsConfiguration, RuntimeError> {
        self.authorize(principal, S3Action::PutBucketAnalyticsConfiguration, bucket)?;
        validate_analytics_configuration(&configuration)?;
        let bucket_state = self.require_bucket_mut(bucket)?;
        if !bucket_state
            .analytics_configurations
            .contains_key(&configuration.id)
            && bucket_state.analytics_configurations.len() >= MAX_BUCKET_ANALYTICS_CONFIGURATIONS
        {
            return Err(RuntimeError::TooManyConfigurations(bucket.to_string()));
        }
        configuration.bucket = bucket.to_string();
        bucket_state
            .analytics_configurations
            .insert(configuration.id.clone(), configuration.clone());
        self.audit_allowed(
            principal,
            S3Action::PutBucketAnalyticsConfiguration,
            bucket,
            Some(configuration.id.clone()),
        );
        Ok(configuration)
    }

    pub fn get_bucket_analytics_configuration(
        &mut self,
        principal: &str,
        bucket: &str,
        id: &str,
    ) -> Result<AnalyticsConfiguration, RuntimeError> {
        self.authorize(principal, S3Action::GetBucketAnalyticsConfiguration, bucket)?;
        let configuration = self
            .require_bucket(bucket)?
            .analytics_configurations
            .get(id)
            .cloned()
            .ok_or_else(|| RuntimeError::NoSuchAnalyticsConfiguration {
                bucket: bucket.to_string(),
                id: id.to_string(),
            })?;
        self.audit_allowed(
            principal,
            S3Action::GetBucketAnalyticsConfiguration,
            bucket,
            Some(id.to_string()),
        );
        Ok(configuration)
    }

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

    pub fn list_bucket_analytics_configurations(
        &mut self,
        principal: &str,
        bucket: &str,
        continuation_token: Option<&str>,
    ) -> Result<ListAnalyticsConfigurationsResult, RuntimeError> {
        self.authorize(
            principal,
            S3Action::ListBucketAnalyticsConfigurations,
            bucket,
        )?;
        let state = self.require_bucket(bucket)?;
        let all = state
            .analytics_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_ANALYTICS_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 = ListAnalyticsConfigurationsResult {
            analytics_configurations: page,
            continuation_token: continuation_token.map(str::to_string),
            is_truncated: next_index < all.len(),
            next_continuation_token,
        };
        self.audit_allowed(
            principal,
            S3Action::ListBucketAnalyticsConfigurations,
            bucket,
            Some(result.analytics_configurations.len().to_string()),
        );
        Ok(result)
    }
}

fn validate_analytics_configuration(
    configuration: &AnalyticsConfiguration,
) -> Result<(), RuntimeError> {
    validate_analytics_configuration_id(&configuration.id)?;
    if let Some(filter) = configuration.filter.as_ref() {
        validate_analytics_filter(filter)?;
    }
    validate_storage_class_analysis(&configuration.storage_class_analysis)?;
    Ok(())
}

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

fn validate_analytics_filter(filter: &AnalyticsFilter) -> Result<(), RuntimeError> {
    let root_count = usize::from(filter.and.is_some())
        + usize::from(filter.prefix.is_some())
        + usize::from(filter.tag.is_some());
    if root_count == 0 {
        return Err(RuntimeError::InvalidAnalyticsConfiguration(
            "Analytics filter must include at least one selector".to_string(),
        ));
    }
    if root_count > 1 {
        return Err(RuntimeError::InvalidAnalyticsConfiguration(
            "Analytics filter must contain exactly one top-level selector".to_string(),
        ));
    }
    if let Some(tag) = filter.tag.as_ref() {
        validate_analytics_tag(tag)?;
    }
    if let Some(and) = filter.and.as_ref() {
        let child_count = usize::from(and.prefix.is_some()) + and.tags.len();
        if child_count < 2 {
            return Err(RuntimeError::InvalidAnalyticsConfiguration(
                "Analytics And filter must combine at least two predicates".to_string(),
            ));
        }
        for tag in &and.tags {
            validate_analytics_tag(tag)?;
        }
    }
    Ok(())
}

fn validate_analytics_tag(tag: &AnalyticsTag) -> Result<(), RuntimeError> {
    if tag.key.trim().is_empty() {
        return Err(RuntimeError::InvalidAnalyticsConfiguration(
            "Analytics tag key must not be empty".to_string(),
        ));
    }
    Ok(())
}

fn validate_storage_class_analysis(analysis: &StorageClassAnalysis) -> Result<(), RuntimeError> {
    if let Some(export) = analysis.data_export.as_ref() {
        if export.output_schema_version != "V_1" {
            return Err(RuntimeError::InvalidAnalyticsConfiguration(
                "Analytics data export OutputSchemaVersion must be V_1".to_string(),
            ));
        }
        let destination = &export.destination.s3_bucket_destination;
        if !destination.bucket_arn.starts_with("arn:aws:s3:::") {
            return Err(RuntimeError::InvalidAnalyticsConfiguration(
                "Analytics export destination Bucket must be an S3 bucket ARN".to_string(),
            ));
        }
        if destination.format != "CSV" {
            return Err(RuntimeError::InvalidAnalyticsConfiguration(
                "Analytics export destination Format must be CSV".to_string(),
            ));
        }
    }
    Ok(())
}