use super::*;
mod cors;
pub(crate) use cors::{cors_response_origin, cors_value_matches, parse_header_list};
pub(crate) fn lifecycle_rule_matches(rule: &LifecycleRule, key: &str) -> bool {
rule.prefix
.as_deref()
.is_none_or(|prefix| key.starts_with(prefix))
}
pub(crate) fn tag_filter_matches(
filter: &BTreeMap<String, String>,
tags: &BTreeMap<String, String>,
) -> bool {
filter
.iter()
.all(|(key, value)| tags.get(key) == Some(value))
}
pub(crate) fn object_tag_context(version: &StoredVersion) -> RequestContext {
version
.tags
.iter()
.map(|(key, value)| (format!("s3:ExistingObjectTag/{key}"), value.clone()))
.collect()
}
pub(crate) fn lifecycle_age_matches(
now_epoch_seconds: u64,
start_epoch_seconds: u64,
days: Option<u64>,
) -> bool {
let Some(days) = days else {
return false;
};
now_epoch_seconds.saturating_sub(start_epoch_seconds) >= days.saturating_mul(86_400)
}
pub(crate) fn validate_notification_rules(rules: &[NotificationRule]) -> Result<(), RuntimeError> {
for rule in rules {
if rule.target_arn.is_empty() {
return Err(RuntimeError::InvalidNotificationConfiguration(format!(
"rule {} target ARN is required",
rule.id
)));
}
if rule.events.is_empty() {
return Err(RuntimeError::InvalidNotificationConfiguration(format!(
"rule {} requires at least one Event",
rule.id
)));
}
for event in &rule.events {
if !notification_event_pattern_supported(event) {
return Err(RuntimeError::InvalidNotificationConfiguration(format!(
"unsupported notification event: {event}"
)));
}
}
}
Ok(())
}
pub(crate) fn validate_website_configuration(
config: &BucketWebsiteConfiguration,
) -> Result<(), RuntimeError> {
if config.index_document_suffix.trim().is_empty()
|| config.index_document_suffix.starts_with('/')
{
return Err(RuntimeError::InvalidWebsiteConfiguration(
"IndexDocument Suffix must be a relative key suffix".to_string(),
));
}
if let Some(key) = &config.error_document_key {
if key.trim().is_empty() || key.starts_with('/') {
return Err(RuntimeError::InvalidWebsiteConfiguration(
"ErrorDocument Key must be a relative object key".to_string(),
));
}
}
for rule in &config.routing_rules {
if rule.redirect_host_name.is_none()
&& rule.redirect_replace_key_prefix_with.is_none()
&& rule.redirect_http_redirect_code.is_none()
{
return Err(RuntimeError::InvalidWebsiteConfiguration(
"RoutingRule Redirect must include a supported redirect field".to_string(),
));
}
}
Ok(())
}
pub(crate) fn website_index_key(requested_key: &str, index_document_suffix: &str) -> String {
let key = requested_key.trim_start_matches('/');
if key.is_empty() {
return index_document_suffix.to_string();
}
if key.ends_with('/') {
return format!("{key}{index_document_suffix}");
}
key.to_string()
}
pub(crate) fn notification_event_pattern_supported(event: &str) -> bool {
matches!(
event,
"s3:ObjectCreated:*"
| "s3:ObjectCreated:Put"
| "s3:ObjectCreated:Post"
| "s3:ObjectCreated:Copy"
| "s3:ObjectCreated:CompleteMultipartUpload"
| "s3:ObjectRemoved:*"
| "s3:ObjectRemoved:Delete"
| "s3:ObjectRemoved:DeleteMarkerCreated"
| "s3:ObjectRestore:*"
| "s3:ObjectRestore:Completed"
| "s3:Replication:*"
| "s3:Replication:ObjectReplicated"
| "s3:Replication:DeleteMarkerReplicated"
| "s3:Replication:ObjectDeleted"
| "s3:LifecycleExpiration:*"
| "s3:LifecycleExpiration:Delete"
| "s3:LifecycleExpiration:DeleteMarkerCreated"
| "s3:LifecycleExpiration:AbortMultipartUpload"
| "s3:ObjectLock:*"
| "s3:ObjectLock:LegalHoldUpdated"
| "s3:ObjectLock:RetentionUpdated"
)
}
pub(crate) fn notification_rule_matches(
rule: &NotificationRule,
event_name: &str,
key: &str,
) -> bool {
notification_event_matches(&rule.events, event_name)
&& rule
.filter_prefix
.as_deref()
.is_none_or(|prefix| key.starts_with(prefix))
&& rule
.filter_suffix
.as_deref()
.is_none_or(|suffix| key.ends_with(suffix))
}
pub(crate) fn notification_event_matches(events: &[String], event_name: &str) -> bool {
events.iter().any(|event| {
event == event_name
|| event
.strip_suffix('*')
.is_some_and(|prefix| event_name.starts_with(prefix))
})
}
pub(crate) fn validate_replication_rules(rules: &[ReplicationRule]) -> Result<(), RuntimeError> {
if rules.is_empty() {
return Err(RuntimeError::InvalidReplicationConfiguration(
"at least one replication Rule is required".to_string(),
));
}
for rule in rules {
if rule.status != "Enabled" && rule.status != "Disabled" {
return Err(RuntimeError::InvalidReplicationConfiguration(format!(
"unsupported replication status: {}",
rule.status
)));
}
if replication_destination_bucket(rule).is_none() {
return Err(RuntimeError::InvalidReplicationConfiguration(format!(
"rule {} Destination Bucket is required",
rule.id
)));
}
}
Ok(())
}
pub(crate) fn replication_rule_matches(rule: &ReplicationRule, key: &str) -> bool {
rule.prefix
.as_deref()
.is_none_or(|prefix| key.starts_with(prefix))
}
pub(crate) fn replication_destination_bucket(rule: &ReplicationRule) -> Option<&str> {
let value = rule.destination_bucket.trim();
if value.is_empty() {
return None;
}
Some(value.strip_prefix("arn:aws:s3:::").unwrap_or(value))
}
pub(crate) fn parse_positive_u64(value: &str) -> Result<u64, RuntimeError> {
let parsed = value.parse::<u64>().map_err(|_| {
RuntimeError::InvalidObjectLockConfiguration(format!("invalid positive integer: {value}"))
})?;
if parsed == 0 {
return Err(RuntimeError::InvalidObjectLockConfiguration(
"retention duration must be positive".to_string(),
));
}
Ok(parsed)
}
pub(crate) fn parse_legal_hold_status(value: &str) -> Result<bool, RuntimeError> {
match value {
"ON" => Ok(true),
"OFF" => Ok(false),
value => Err(RuntimeError::InvalidLegalHold(format!(
"unsupported legal hold status: {value}"
))),
}
}
pub(crate) fn parse_retention_mode(value: &str) -> Result<RetentionMode, RuntimeError> {
retention_mode_from_text(value)
}
pub(crate) fn retention_mode_from_text(value: &str) -> Result<RetentionMode, RuntimeError> {
match value.to_ascii_uppercase().as_str() {
"GOVERNANCE" => Ok(RetentionMode::Governance),
"COMPLIANCE" => Ok(RetentionMode::Compliance),
value => Err(RuntimeError::InvalidRetention(format!(
"unsupported retention mode: {value}"
))),
}
}
pub(crate) fn retention_mode_text(mode: RetentionMode) -> &'static str {
match mode {
RetentionMode::Governance => "GOVERNANCE",
RetentionMode::Compliance => "COMPLIANCE",
}
}
pub(crate) fn parse_retention_timestamp(value: &str) -> Result<u64, RuntimeError> {
if let Ok(epoch) = value.parse::<u64>() {
return Ok(epoch);
}
let value = value
.strip_suffix('Z')
.ok_or_else(|| RuntimeError::InvalidRetention(format!("invalid timestamp: {value}")))?;
let (date, time) = value
.split_once('T')
.ok_or_else(|| RuntimeError::InvalidRetention(format!("invalid timestamp: {value}")))?;
let mut date_parts = date.split('-');
let year = date_parts
.next()
.and_then(|part| part.parse::<i64>().ok())
.ok_or_else(|| RuntimeError::InvalidRetention(format!("invalid timestamp: {value}")))?;
let month = date_parts
.next()
.and_then(|part| part.parse::<u32>().ok())
.ok_or_else(|| RuntimeError::InvalidRetention(format!("invalid timestamp: {value}")))?;
let day = date_parts
.next()
.and_then(|part| part.parse::<u32>().ok())
.ok_or_else(|| RuntimeError::InvalidRetention(format!("invalid timestamp: {value}")))?;
let mut time_parts = time.split(':');
let hour = time_parts
.next()
.and_then(|part| part.parse::<u32>().ok())
.ok_or_else(|| RuntimeError::InvalidRetention(format!("invalid timestamp: {value}")))?;
let minute = time_parts
.next()
.and_then(|part| part.parse::<u32>().ok())
.ok_or_else(|| RuntimeError::InvalidRetention(format!("invalid timestamp: {value}")))?;
let second = time_parts
.next()
.and_then(|part| part.parse::<u32>().ok())
.ok_or_else(|| RuntimeError::InvalidRetention(format!("invalid timestamp: {value}")))?;
if !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| hour > 23
|| minute > 59
|| second > 59
{
return Err(RuntimeError::InvalidRetention(format!(
"invalid timestamp: {value}"
)));
}
let days = days_from_civil(year, month, day);
if days < 0 {
return Err(RuntimeError::InvalidRetention(format!(
"timestamp is before Unix epoch: {value}"
)));
}
Ok(days as u64 * 86_400 + u64::from(hour) * 3_600 + u64::from(minute) * 60 + u64::from(second))
}
pub(crate) fn format_retention_timestamp(epoch_seconds: u64) -> String {
let days = (epoch_seconds / 86_400) as i64;
let seconds = epoch_seconds % 86_400;
let (year, month, day) = civil_from_days(days);
let hour = seconds / 3_600;
let minute = (seconds % 3_600) / 60;
let second = seconds % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
pub(crate) fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
let year = year - i64::from(month <= 2);
let era = if year >= 0 { year } else { year - 399 } / 400;
let yoe = year - era * 400;
let month = i64::from(month);
let day = i64::from(day);
let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) {
let days = days + 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let doe = days - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = mp + if mp < 10 { 3 } else { -9 };
(year + i64::from(month <= 2), month as u32, day as u32)
}
pub(crate) fn validate_tags(tags: &BTreeMap<String, String>) -> Result<(), RuntimeError> {
if tags.len() > 10 {
return Err(RuntimeError::InvalidTagging(
"objects can have at most 10 tags".to_string(),
));
}
if tags.keys().any(|key| key.is_empty()) {
return Err(RuntimeError::InvalidTagging(
"tag keys cannot be empty".to_string(),
));
}
Ok(())
}
pub(crate) fn validate_acl_write(
headers: &BTreeMap<String, String>,
body: &[u8],
) -> Result<(), RuntimeError> {
if headers
.keys()
.any(|key| key.to_ascii_lowercase().starts_with("x-amz-grant-"))
{
return Err(RuntimeError::AccessControlListNotSupported(
"explicit ACL grant headers are outside the current BucketOwnerEnforced boundary"
.to_string(),
));
}
if let Some(acl) = header(headers, "x-amz-acl") {
validate_canned_acl(acl)?;
}
let xml = String::from_utf8_lossy(body);
let body = xml.trim();
if body.is_empty() {
return Ok(());
}
if body.contains("AllUsers")
|| body.contains("AuthenticatedUsers")
|| body.contains("<Permission>READ</Permission>")
|| body.contains("<Permission>WRITE</Permission>")
|| body.contains("<Permission>READ_ACP</Permission>")
|| body.contains("<Permission>WRITE_ACP</Permission>")
{
return Err(RuntimeError::AccessControlListNotSupported(
"public or grant-bearing ACLs cannot grant access in BucketOwnerEnforced mode"
.to_string(),
));
}
if body.contains("<AccessControlPolicy") || body.contains("<AccessControlList") {
return Ok(());
}
Err(RuntimeError::AccessControlListNotSupported(
"ACL writes must be empty, a supported canned ACL, or owner-only FULL_CONTROL XML"
.to_string(),
))
}
pub(crate) fn validate_canned_acl(value: &str) -> Result<(), RuntimeError> {
match value.trim().to_ascii_lowercase().as_str() {
"private" | "bucket-owner-full-control" => Ok(()),
_ => Err(RuntimeError::AccessControlListNotSupported(format!(
"canned ACL {value} is outside the current BucketOwnerEnforced boundary"
))),
}
}
pub(crate) fn validate_object_ownership(value: &str) -> Result<(), RuntimeError> {
if value == "BucketOwnerEnforced" {
Ok(())
} else {
Err(RuntimeError::InvalidOwnershipControls(format!(
"unsupported object ownership mode: {value}; only BucketOwnerEnforced is supported"
)))
}
}
pub(crate) fn validate_bucket_region(value: &str) -> Result<(), RuntimeError> {
let valid = !value.is_empty()
&& value.len() <= 63
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
&& !value.starts_with('-')
&& !value.ends_with('-')
&& value.contains('-');
if valid {
Ok(())
} else {
Err(RuntimeError::InvalidBucketLocation(format!(
"unsupported bucket location constraint: {value}"
)))
}
}
pub(crate) fn text_values(value: &str, tag: &str) -> Vec<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let mut values = Vec::new();
let mut remainder = value;
while let Some(start) = remainder.find(&open) {
remainder = &remainder[start + open.len()..];
let Some(end) = remainder.find(&close) else {
break;
};
values.push(remainder[..end].trim().to_string());
remainder = &remainder[end + close.len()..];
}
values
}