From 99af59a914a85111992e65b2af253d66575439c9 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 9 Oct 2026 06:43:53 -0700 Subject: [PATCH] Merge commit from fork --- rustfs/src/storage/access.rs | 99 ++++++++++++++++++++++++++++++--- rustfs/src/storage/ecfs.rs | 32 +++++++---- rustfs/src/storage/ecfs_test.rs | 41 +++++++++++++- 3 files changed, 151 insertions(+), 21 deletions(-) diff --git a/rustfs/src/storage/access.rs b/rustfs/src/storage/access.rs index 47df4524c..38109dc38 100644 --- a/rustfs/src/storage/access.rs +++ b/rustfs/src/storage/access.rs @@ -140,6 +140,9 @@ struct InternalObjectAuthorization; #[derive(Clone, Debug)] struct RequestedObjectLockRetainUntil(OffsetDateTime); +#[derive(Clone, Debug)] +struct RequestedObjectLockRetention(ObjectLockRetention); + #[derive(Clone, Debug)] struct StagedMultipartPartAuthorization; @@ -885,6 +888,21 @@ fn authorization_conditions( } merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions); merge_request_object_tag_conditions(action, &req.headers, &mut conditions)?; + if let Some(RequestedObjectLockRetention(retention)) = req.extensions.get::() { + // Standalone retention writes apply the XML body, so headers must not + // supply a different value to the policy decision. + conditions.remove("object-lock-mode"); + conditions.remove("object-lock-retain-until-date"); + if let Some(mode) = &retention.mode { + conditions.insert("object-lock-mode".to_string(), vec![mode.as_str().to_string()]); + } + if let Some(date) = &retention.retain_until_date { + let date = OffsetDateTime::from(date.clone()) + .format(&time::format_description::well_known::Rfc3339) + .map_err(|_| S3Error::with_message(S3ErrorCode::InvalidArgument, "Invalid retain-until date"))?; + conditions.insert("object-lock-retain-until-date".to_string(), vec![date]); + } + } merge_object_lock_remaining_retention_days_condition( req.extensions.get::(), OffsetDateTime::now_utc(), @@ -908,6 +926,12 @@ pub(crate) fn set_requested_object_lock_retain_until(req: &mut S3Request, } } +fn set_requested_object_lock_retention(req: &mut S3Request) { + let retention = req.input.retention.clone().unwrap_or_default(); + set_requested_object_lock_retain_until(req, retention.retain_until_date.as_ref()); + req.extensions.insert(RequestedObjectLockRetention(retention)); +} + fn merge_object_lock_remaining_retention_days_condition( requested: Option<&RequestedObjectLockRetainUntil>, now: OffsetDateTime, @@ -3355,12 +3379,7 @@ impl S3Access for FS { let bucket = req.input.bucket.clone(); let bucket_generation = load_bucket_generation(self, req, &bucket).await; - let requested_retain_until = req - .input - .retention - .as_ref() - .and_then(|retention| retention.retain_until_date.clone()); - set_requested_object_lock_retain_until(req, requested_retain_until.as_ref()); + set_requested_object_lock_retention(req); authorize_request(req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?; // S3 Standard: When bypass_governance header is set, must have s3:BypassGovernanceRetention permission @@ -3506,7 +3525,7 @@ mod tests { table_data_plane_content_mutation, table_data_plane_resource_for_request, table_publication_guard_error, validate_post_object_success_controls, versioned_read_action, versioned_tagging_action, }; - use super::{remaining_retention_days, set_requested_object_lock_retain_until}; + use super::{remaining_retention_days, set_requested_object_lock_retain_until, set_requested_object_lock_retention}; use crate::auth::OBJECT_LOCK_REMAINING_RETENTION_DAYS_CONDITION; use crate::error::ApiError; use crate::storage::storage_api::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; @@ -4349,11 +4368,75 @@ mod tests { } } - fn remaining_retention_days_conditions(req: &S3Request<()>, action: Action) -> HashMap> { + fn remaining_retention_days_conditions(req: &S3Request, action: Action) -> HashMap> { let credentials = rustfs_credentials::Credentials::default(); authorization_conditions(req, &credentials, None, None, None, None, action).expect("conditions should build") } + #[tokio::test] + async fn retention_policy_checks_body_instead_of_conflicting_headers() { + use rustfs_policy::policy::{Args, Policy}; + let mut req = build_request( + PutObjectRetentionInput { + retention: Some(ObjectLockRetention { + mode: Some(ObjectLockRetentionMode::from_static("COMPLIANCE")), + retain_until_date: Some( + Timestamp::parse(TimestampFormat::DateTime, "2035-01-02T00:00:00+23:59").expect("body date should parse"), + ), + }), + ..Default::default() + }, + Method::PUT, + ); + req.headers + .insert("x-amz-object-lock-mode", HeaderValue::from_static("GOVERNANCE")); + req.headers + .insert("x-amz-object-lock-retain-until-date", HeaderValue::from_static("2029-01-01T00:00:00Z")); + set_requested_object_lock_retention(&mut req); + let action = Action::S3Action(S3Action::PutObjectRetentionAction); + let conditions = remaining_retention_days_conditions(&req, action); + assert_eq!(conditions.get("object-lock-mode"), Some(&vec!["COMPLIANCE".to_string()])); + let applied = OffsetDateTime::from( + req.input + .retention + .as_ref() + .expect("body retention") + .retain_until_date + .clone() + .expect("body date"), + ); + let authorized = OffsetDateTime::parse( + &conditions["object-lock-retain-until-date"][0], + &time::format_description::well_known::Rfc3339, + ) + .expect("condition date should parse"); + assert_eq!(authorized.unix_timestamp(), applied.unix_timestamp()); + let policy = Policy::parse_config(br#"{ + "Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObjectRetention","Resource":"arn:aws:s3:::bucket/*", + "Condition":{"DateLessThan":{"s3:object-lock-retain-until-date":"2030-01-01T00:00:00Z"}}}] + }"#).expect("bounded retention policy should parse"); + let args = Args { + account: "writer", + groups: &None, + action, + bucket: "bucket", + conditions: &conditions, + is_owner: false, + object: "object", + claims: &HashMap::new(), + deny_only: false, + }; + assert!( + !policy.is_allowed(&args).await, + "an in-range header cannot authorize an out-of-range body" + ); + req.input.retention = None; + set_requested_object_lock_retention(&mut req); + let conditions = remaining_retention_days_conditions(&req, action); + assert!(!conditions.contains_key("object-lock-mode")); + assert!(!conditions.contains_key("object-lock-retain-until-date")); + } + fn request_retaining_for(retain_for: time::Duration) -> S3Request<()> { let mut req = build_request((), Method::PUT); let retain_until = OffsetDateTime::now_utc() + retain_for; diff --git a/rustfs/src/storage/ecfs.rs b/rustfs/src/storage/ecfs.rs index 51fecc893..f1c045f84 100644 --- a/rustfs/src/storage/ecfs.rs +++ b/rustfs/src/storage/ecfs.rs @@ -402,6 +402,25 @@ async fn validate_table_catalog_object_mutation(bucket: &str, key: &str) -> S3Re const MAXIMUM_RETENTION_DAYS: i32 = 36_500; const MAXIMUM_RETENTION_YEARS: i32 = 100; +pub(crate) fn object_lock_retention_from_metadata(metadata: &HashMap) -> S3Result { + let mode = metadata + .get(AMZ_OBJECT_LOCK_MODE_LOWER) + .map(|value| ObjectLockRetentionMode::from(value.as_str().to_string())); + let retain_until_date = metadata + .get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) + .and_then(|value| OffsetDateTime::parse(value, &Rfc3339).ok()) + // The S3 timestamp serializer emits a literal Z, so its wall clock + // must be UTC before it is serialized. + .map(|date| { + date.checked_to_offset(time::UtcOffset::UTC) + .filter(|date| (0..=9999).contains(&date.year())) + .map(Timestamp::from) + .ok_or_else(|| s3_error!(InternalError, "Stored retention date is outside the supported UTC range")) + }) + .transpose()?; + Ok(ObjectLockRetention { mode, retain_until_date }) +} + fn invalid_object_lock_configuration(message: impl Into) -> S3Error { S3Error::with_message(S3ErrorCode::MalformedXML, message.into()) } @@ -1112,19 +1131,8 @@ impl S3 for FS { s3_error!(InternalError, "{}", e.to_string()) })?; - let mode = object_info - .user_defined - .get(AMZ_OBJECT_LOCK_MODE_LOWER) - .map(|v| ObjectLockRetentionMode::from(v.as_str().to_string())); - - let retain_until_date = object_info - .user_defined - .get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) - .and_then(|v| OffsetDateTime::parse(v.as_str(), &Rfc3339).ok()) - .map(Timestamp::from); - let output = GetObjectRetentionOutput { - retention: Some(ObjectLockRetention { mode, retain_until_date }), + retention: Some(object_lock_retention_from_metadata(&object_info.user_defined)?), }; let version_id = req.input.version_id.clone().unwrap_or_default(); helper = helper.object(object_info).version_id(version_id); diff --git a/rustfs/src/storage/ecfs_test.rs b/rustfs/src/storage/ecfs_test.rs index 9135ad2ca..18f96abfa 100644 --- a/rustfs/src/storage/ecfs_test.rs +++ b/rustfs/src/storage/ecfs_test.rs @@ -18,7 +18,8 @@ mod tests { use crate::server::cors; use crate::storage::StorageError; use crate::storage::ecfs::{ - FS, parse_object_version_id, propagate_object_lock_peer_reload, validate_object_lock_configuration_input, + FS, object_lock_retention_from_metadata, parse_object_version_id, propagate_object_lock_peer_reload, + validate_object_lock_configuration_input, }; use crate::storage::ecfs_extend::{apply_bucket_default_lock_retention, map_bucket_object_lock_config_state}; use crate::storage::s3_api::common::{rustfs_initiator, rustfs_owner}; @@ -988,6 +989,44 @@ mod tests { assert!(validate_list_object_unordered_with_delimiter(delimiter_some, complex_query_without_unordered).is_ok()); } + #[test] + fn retention_response_serialization_preserves_offset_metadata_instant() { + for value in [ + "2035-01-02T00:00:00+23:59", + "2035-01-02T00:00:00-23:59", + "2035-01-02T00:00:00Z", + ] { + let metadata = HashMap::from([ + (AMZ_OBJECT_LOCK_MODE_LOWER.to_owned(), "COMPLIANCE".to_owned()), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_owned(), value.to_owned()), + ]); + let retention = object_lock_retention_from_metadata(&metadata).expect("representable retention instant"); + assert_eq!(retention.mode.as_ref().expect("mode").as_str(), "COMPLIANCE"); + let mut serialized = Vec::new(); + retention + .retain_until_date + .expect("stored retention date") + .format(s3s::dto::TimestampFormat::DateTime, &mut serialized) + .expect("serialize the response timestamp"); + let serialized = String::from_utf8(serialized).expect("UTF-8 timestamp"); + let returned = OffsetDateTime::parse(&serialized, &Rfc3339).expect("parse wire timestamp"); + let stored = OffsetDateTime::parse(value, &Rfc3339).expect("parse stored timestamp"); + assert_eq!(returned.unix_timestamp(), stored.unix_timestamp(), "stored date {value}"); + } + } + + #[test] + fn retention_response_rejects_instants_outside_the_utc_wire_range() { + for value in ["9999-12-31T23:59:59-23:59", "0000-01-01T00:00:00+23:59"] { + let metadata = HashMap::from([ + (AMZ_OBJECT_LOCK_MODE_LOWER.to_owned(), "COMPLIANCE".to_owned()), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_owned(), value.to_owned()), + ]); + let error = object_lock_retention_from_metadata(&metadata).expect_err("unrepresentable UTC timestamp"); + assert_eq!(error.code(), &S3ErrorCode::InternalError); + } + } + #[test] fn test_parse_object_lock_retention() { use time::macros::datetime;