mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-16 18:08:21 +00:00
98d3619613
* fix: address rc.1 release blockers
* fix: route release guards through architecture boundaries
* fix: close remaining rc.1 regression gaps
* refactor: group multipart listing options
* fix: resolve rc.1 CI regressions
* fix(ecstore): keep bucket-config writes off the caller's stack
A bucket-config write nests incarnation resolution (which can drive legacy
migration and a peer fan-out), a full metadata load, and `save` — itself an
object PUT that pulls in the whole erasure write path. Every request that
mutates bucket config is already several futures deep, so inlining all of
that into one state machine overflows the 2MiB worker stack in debug builds.
Two CI lanes aborted with SIGABRT on this:
ILM Integration (serial)
rustfs app::lifecycle_transition_api_test::
compensation_driven_complete_multipart_upload_still_transitions
Test and Lint (swift)
rustfs-protocols::swift_metadata_persistence::
swift_metadata_writes_are_durable
Neither test file is touched by this branch and both lanes are green on
main. Stack-pointer probing showed ~780KiB consumed between
`metadata_sys::update` and the config read alone, with single hops of
363KiB (`update` -> `acquire_config_write_guard_for_incarnation`), 125KiB
and 105KiB.
Box the deep sub-futures on both read-modify-write paths (`update` /
`update_checked` and `update_config_with` / `update_config_with_checked`)
so each guard's own state machine stays small. Behaviour is unchanged;
`update` -> guard drops to 253KiB and both tests pass on the default stack.
* fix(lifecycle): unbreak restore under the bucket generation fence
The ILM lane aborted on a stack overflow before reaching these, so they
were never reported; with that fixed, four restore tests fail. All four
are green on main and none of their test files are touched by this branch.
1. RestoreObject and ListMultipartUploads hard-required
`opts.expected_bucket_incarnation_id`, but `apply_bucket_generation_guard`
deliberately leaves it unset when no guard extension is present — only the
S3 access layer installs one. Every direct caller therefore got
`InternalError: ... bucket generation guard is missing`. Resolve the
current generation instead, the way the copy path already does. The fence
is unaffected: RestoreObject still re-reads the incarnation from disk and
compares before admitting the restore, and the multipart listing is
filtered by the value it resolves.
2. `restore_expiry_snapshot_matches` (new on this branch) rejected every
restored-copy expiry whose `restore_expires` had not already elapsed.
Whether the restored copy is due to expire is the ILM evaluator's
decision, made when it emitted DeleteRestoredAction; re-deriving it in
the set layer only adds a way for a legitimate action to be rejected.
The stale-event risk it appears to guard is already covered by the
surrounding snapshot match — a re-restore rewrites `restore_expires`,
so a replayed event fails the equality check. Drop the clause; the
fifteen identity clauses are unchanged.
Fixed:
rustfs app::lifecycle_transition_api_test::
restore_object_usecase_accepts_exactly_one_of_two_concurrent_restores
restore_object_usecase_completes_suspended_null_version_in_place
restore_object_usecase_reports_ongoing_conflict
rustfs-scanner::lifecycle_integration_test serial_tests::
test_restore_chain_local_read_expiry_keeps_remote_and_allows_re_restore
Verification: the CI ILM lane filter now runs 53/53 green locally.
* chore: address review follow-ups on this branch
Four items from the adversarial review that were still open.
- Restore the assertion `test_bucket_replication_replayed_delete_marker_
preserves_source_mtime_without_source_restart` is named for. The branch
had replaced the backlog#867 mtime check with `assert_replication_
converged`, which any successful replication satisfies, and deleted the
two helpers it needed — so the regression the test exists to catch would
now pass. This matters here specifically because the branch changes the
flag feeding `replication_delete_remove_options` and routes replay
through a new file and ordering.
- Drop `read_config_no_lock_preserve_empty`: zero production callers (the
one real consumer calls the `_with_metadata` variant directly). Its test
stanza now exercises that variant, so the coverage moves to live code
rather than being deleted.
- Revert the `bytesize` bump. It is a no-op: `Cargo.lock` already pinned
2.7.0 before this branch and is untouched, so the caret range already
resolved there. Nothing in the diff uses the crate.
- Split the AGENTS.md "Adversarial Validation" policy change out of this
branch. The edit is defensible on its own, but it relaxes the review gate
that this branch has to pass, so it should land as its own PR reviewed on
its own merits rather than bundled with the change that benefits from it.
The reverted hunks are unchanged and ready to re-apply.
Not changed, deliberately: the missing-sidecar path still fails closed.
`missing_bucket_incarnation_sidecar_for_new_metadata_fails_closed` pins
that on purpose, and serving a non-authoritative Object Lock state would
be the wrong trade. The residual concern stands and is recorded in review
— a crash between the two writes in `persist_new_and_set` leaves the
bucket unloadable until DeleteBucket+CreateBucket, and the repair branches
in `migrate_legacy_metadata` and `make_bucket` are unreachable dead code
for that case. Resolving it needs the read path and the (transaction-lock
holding) repair path to be separated, which is more than a follow-up edit.
* test(ci): serialize the new bucket-incarnation tests
The five tests this branch adds around the incarnation / lifecycle fence
drive `init_bucket_metadata_sys` and `bucket_metadata_sys_of` — process-global
OnceLock state that `serial_test`'s `#[serial]` cannot protect across
nextest's process boundary — and they delete+recreate buckets, the shape that
raced into InsufficientWriteQuorum in backlog#937.
Add them to the `ecstore-serial-flaky` group in both the default and ci
profiles (nextest evaluates a named profile's own overrides list, so the
ci mirror is required). Preventive serialization only, no retries.
Not a full fix for the review comment: `bucket_delete_waits_for_config_
mutation_fence` still proves liveness with a fixed 200ms sleep plus
`assert!(!delete.is_finished())`. Turning that into readiness polling needs
a production-side signal to wait on — asserting "still blocked" is inherently
a negative. Serializing the group removes the parallel-load pressure that
makes the window fragile; the sleep itself is left for a follow-up.
* test(ecstore): pin that a drained bucket is actually deletable
`DeleteBucket`'s emptiness check is `has_xlmeta_files`, a raw scan of the
bucket directory on local disks — not an S3-level listing. So "the client
drained the bucket" and "the bucket is deletable" are two different
contracts, and only the first one was covered.
That gap is what the `S3 Implemented Tests` lane is failing on: 219 cases,
all `BucketNotEmpty` on `nuke_prefixed_buckets`, with every test body
passing. The first one is `test_versioning_obj_suspend_versions`, reported
by pytest as PASSED followed by ERROR at teardown.
Add the missing assertion for the unversioned path: PUT, client DELETE,
then assert no `xl.meta` survives and `DeleteBucket` succeeds. It passes —
which is itself a result: the plain delete path leaves no residue, so the
s3-tests failure is not there.
The versioning-suspended path is the remaining suspect (the client DELETE
leaves a null delete marker, and draining means purging it by
`versionId=null`). It is not covered here: `BucketVersioningSys` resolves
through the ambient `get_bucket_metadata_sys()` OnceLock, which this unit
env cannot set, so the bucket never actually reports as suspended. That
repro belongs at the e2e layer where a real server owns the versioning
state.
* fix(ecstore): let an explicit null-version delete purge its delete marker
Root cause of the `S3 Implemented Tests` lane: 219 cases, all
`BucketNotEmpty` on `nuke_prefixed_buckets`, every test body passing.
On a versioning-suspended bucket a client DELETE leaves a null delete
marker — correct S3 semantics, and an `xl.meta` on disk. Draining the
bucket therefore means purging that marker as `?versionId=null`, which is
what `nuke_bucket` does before `DeleteBucket`. That purge was rejected:
explicit null-version purge of the null delete marker must succeed,
got [Some(MethodNotAllowed)]
so the marker survived, and `DeleteBucket`'s emptiness check — a raw
`has_xlmeta_files` scan of the bucket directory, not an S3 listing — kept
reporting the bucket as non-empty.
The two sides of the version comparison in the batch delete loop are in
different namespaces. `goi.version_id` is the client-facing identity, where
`from_file_info` synthesizes `Some(Uuid::nil())` for a null version on a
versioned *or versioning-suspended* bucket. `version_id` is the storage
identity, where `delete_file_info_version_id` maps an explicit
`?versionId=null` to `None`. Comparing them raw makes the purge look like a
version mismatch, so `explicit_delete_marker` is false and the
`MethodNotAllowed` from the lookup is recorded as a delete failure.
This only became reachable on this branch: previously `check_opts` did not
carry `dobj.version_id`, so `set_disk_delete_creates_delete_marker` was
true, `object_lock_check_required` was false, and the lookup that produces
`MethodNotAllowed` never ran. Adding the version id to `check_opts` lit up
a comparison that was already wrong.
Normalize both sides through `delete_file_info_version_id`.
The regression test injects a real Suspended bucket-config snapshot — the
delete path reads versioned/suspended from that snapshot, not from `opts`,
so without it `from_file_info` never synthesizes the null version id and
the branch is not reached. Mutation-checked: restoring the raw comparison
fails the test with the exact `MethodNotAllowed` above.
* fix(app): drop the now-needless struct update
Reverting `crates/replication` to main removed the extra `MrfReplicateEntry`
fields, so this literal specifies every field again and `..Default::default()`
trips `clippy::needless_update` under `-D warnings`.
Caught by CI, not locally: I had run `cargo check --workspace --all-targets`,
which does not see clippy-only lints. Ran `cargo clippy --workspace
--all-targets -- -D warnings` here — clean.
* test(e2e): assert the fresh-volume classification
four_node_empty_legacy_volumes_start_as_fresh only started the cluster and
listed buckets — no assertion, so any classification path that still permits
startup left it green without proving the pre-created empty `.minio.sys`
directories were treated as fresh volumes.
Pin what that classification actually leaves behind: no buckets adopted into
the namespace, `.rustfs.sys/format.json` written on every drive, and the empty
legacy directory left untouched rather than migrated into.
* fix(bucket): apply the requested Object Lock to existing buckets
Site replication replays make-with-versioning against the destination,
carrying the source's `lockEnabled`. When the destination bucket already
exists it takes `force_create`, and the whole option-application block was
gated on `confirmed_missing` — so the call returned success while the replica
stayed unlocked. Replicated versions could then be deleted without the
retention the source enforces.
Object Lock enable is one-way, so applying it to an existing bucket is safe:
move it out of the creation-only gate, keeping `created` and versioning-only
options creation-scoped as before.
An existing authoritative bucket takes the `cache_bucket_metadata_in` branch,
which only caches, so the enable would have been dropped on restart. Persist
instead when the enable actually changed something.
Mutation-checked: restoring the creation-only gate fails the new
`force_create_enables_object_lock_on_an_existing_bucket` with "Object Lock
must be enabled on the existing bucket".
cargo nextest run -p rustfs-ecstore --lib: 3633 passed.
* fix(ecstore): box the generation-checked config mutation paths too
The earlier stack fix boxed `update` and `delete`, but an authorized
bucket-config mutation carrying an incarnation takes `update_if_incarnation`
/ `delete_if_incarnation` instead — which were still inlining the whole
resolve/load/save chain into an already-deep request future. Same overflow,
sibling path.
* fix(restore): keep the nil-version normalization the strip removed
Reverting the replication subsystem to main took `set_disk/replication.rs`
with it, but one line in that file was this branch's own fix rather than
replication work:
- self.version_id.filter(|v| !v.is_nil()) == fi.version_id.filter(|v| !v.is_nil())
+ self.version_id == fi.version_id
For a versioning-suspended object the expected version is `Some(Uuid::nil())`
while the read-back `FileInfo` carries `None`, so the raw compare reports
every suspended restore as "restored object changed before restore metadata
finalization" and the copy-back never commits. Same nil-vs-None mismatch as
the null delete-marker purge fixed earlier on this branch.
Caught by `Test and Lint (rio-v2)`, not by my local runs: the test lives in
`transition_commit_failure_tests`, gated behind `feature = "test-util"`, so
the 3633-test suite I had been running never included it. Re-ran with
`--features rio-v2,test-util`: 3722 passed.
852 lines
34 KiB
Rust
852 lines
34 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use crate::bucket::metadata_sys::{ObjectLockConfigState, get_object_lock_config, get_object_lock_config_state};
|
|
use crate::bucket::object_lock::objectlock;
|
|
use crate::error::{Error, Result, StorageError};
|
|
use crate::object_api::ObjectInfo;
|
|
use s3s::dto::{Date, DefaultRetention, ObjectLockConfiguration, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
|
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
|
|
use std::sync::Arc;
|
|
use time::OffsetDateTime;
|
|
|
|
pub struct BucketObjectLockSys {}
|
|
|
|
impl BucketObjectLockSys {
|
|
#[allow(clippy::new_ret_no_self)]
|
|
pub async fn new() -> Arc<Self> {
|
|
Arc::new(Self {})
|
|
}
|
|
|
|
pub async fn get(bucket: &str) -> Option<DefaultRetention> {
|
|
if let Ok(object_lock_config) = get_object_lock_config(bucket).await
|
|
&& let Some(object_lock_rule) = object_lock_config.0.rule
|
|
{
|
|
return object_lock_rule.default_retention;
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
pub(crate) fn ensure_recursive_force_delete_allowed_for_state(bucket: &str, state: &ObjectLockConfigState) -> Result<()> {
|
|
match state {
|
|
ObjectLockConfigState::ConfirmedAbsent => Ok(()),
|
|
ObjectLockConfigState::Configured { .. } => Err(StorageError::InvalidArgument(
|
|
bucket.to_string(),
|
|
String::new(),
|
|
"force-delete is forbidden on Object Locking enabled buckets".to_string(),
|
|
)),
|
|
ObjectLockConfigState::Fabricated => {
|
|
Err(Error::other(format!("bucket Object Lock metadata is not authoritative: {bucket}")))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if a retention period is still active based on mode and retain_until_date
|
|
pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool {
|
|
if mode != ObjectLockRetentionMode::COMPLIANCE && mode != ObjectLockRetentionMode::GOVERNANCE {
|
|
return false;
|
|
}
|
|
if let Some(retain_until) = retain_until_date {
|
|
let now = objectlock::utc_now_ntp();
|
|
return OffsetDateTime::from(retain_until.clone()).unix_timestamp() > now.unix_timestamp();
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Check if retention modification is blocked for the given object.
|
|
pub fn check_retention_for_modification(
|
|
user_defined: &std::collections::HashMap<String, String>,
|
|
new_mode: Option<&str>,
|
|
new_retain_until: Option<OffsetDateTime>,
|
|
bypass_governance: bool,
|
|
) -> Option<ObjectLockBlockReason> {
|
|
let retention = objectlock::get_object_retention_meta(user_defined);
|
|
|
|
let Some(mode) = &retention.mode else {
|
|
return None;
|
|
};
|
|
|
|
let mode_str = mode.as_str();
|
|
if !is_retention_active(mode_str, retention.retain_until_date.as_ref()) {
|
|
return None;
|
|
}
|
|
|
|
let existing_retain_until = retention.retain_until_date.as_ref().map(|d| OffsetDateTime::from(d.clone()));
|
|
let mode_changed = new_mode != Some(mode_str);
|
|
|
|
// Check if new retention period is shorter than existing
|
|
let is_shortening = match (&existing_retain_until, &new_retain_until) {
|
|
(Some(existing), Some(new)) => new < existing,
|
|
(Some(_), None) => true, // Clearing retention is shortening
|
|
_ => false,
|
|
};
|
|
|
|
// COMPLIANCE mode: cannot shorten retention at all (even with bypass)
|
|
// Can only extend the retention period
|
|
if mode_str == ObjectLockRetentionMode::COMPLIANCE {
|
|
if mode_changed || is_shortening {
|
|
return Some(ObjectLockBlockReason::Retention {
|
|
mode: mode_str.to_string(),
|
|
retain_until: existing_retain_until,
|
|
});
|
|
}
|
|
// Extending retention in COMPLIANCE mode is allowed
|
|
return None;
|
|
}
|
|
|
|
// GOVERNANCE mode: extending is always allowed, shortening requires bypass
|
|
// This matches AWS S3 behavior where:
|
|
// - Extending retention: allowed without bypass permission
|
|
// - Shortening/removing retention: requires bypass permission
|
|
if mode_str == ObjectLockRetentionMode::GOVERNANCE {
|
|
if (mode_changed || is_shortening) && !bypass_governance {
|
|
return Some(ObjectLockBlockReason::Retention {
|
|
mode: mode_str.to_string(),
|
|
retain_until: existing_retain_until,
|
|
});
|
|
}
|
|
// Extending retention or shortening with bypass is allowed
|
|
return None;
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime {
|
|
let target_year = dt.year() + years;
|
|
dt.replace_year(target_year)
|
|
.or_else(|_| {
|
|
// Feb 29 -> non-leap year: use Feb 28
|
|
dt.replace_day(28).and_then(|d| d.replace_year(target_year))
|
|
})
|
|
.unwrap_or(dt)
|
|
}
|
|
|
|
/// Check if an object has legal hold enabled.
|
|
/// Returns true if legal hold is ON.
|
|
fn has_legal_hold(user_defined: &std::collections::HashMap<String, String>) -> bool {
|
|
let lhold = objectlock::get_object_legalhold_meta(user_defined);
|
|
matches!(lhold.status, Some(ref st) if st.as_str() == ObjectLockLegalHoldStatus::ON)
|
|
}
|
|
|
|
/// Check if an object is locked based on its metadata.
|
|
/// This is a common function used by both lifecycle evaluation and deletion checks.
|
|
///
|
|
/// # Arguments
|
|
/// * `user_defined` - The object's user-defined metadata
|
|
/// * `is_delete_marker` - Whether the object is a delete marker
|
|
///
|
|
/// # Returns
|
|
/// * `true` if the object is locked (cannot be deleted/modified)
|
|
/// * `false` if the object is not locked
|
|
pub fn is_object_locked_by_metadata(user_defined: &std::collections::HashMap<String, String>, is_delete_marker: bool) -> bool {
|
|
// Delete markers are never locked
|
|
if is_delete_marker {
|
|
return false;
|
|
}
|
|
|
|
// Check legal hold - always blocks if ON
|
|
if has_legal_hold(user_defined) {
|
|
return true;
|
|
}
|
|
|
|
// Check retention - reuse is_retention_active to avoid code duplication
|
|
let ret = objectlock::get_object_retention_meta(user_defined);
|
|
if let Some(mode) = &ret.mode
|
|
&& is_retention_active(mode.as_str(), ret.retain_until_date.as_ref())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Reason why object deletion is blocked by Object Lock
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ObjectLockBlockReason {
|
|
/// Object has legal hold enabled (must be explicitly removed)
|
|
LegalHold,
|
|
/// Object is under retention until the specified date
|
|
Retention {
|
|
mode: String,
|
|
retain_until: Option<OffsetDateTime>,
|
|
},
|
|
}
|
|
|
|
impl ObjectLockBlockReason {
|
|
/// Get a user-friendly error message for this block reason
|
|
pub fn error_message(&self) -> String {
|
|
match self {
|
|
ObjectLockBlockReason::LegalHold => {
|
|
"Object has a legal hold and cannot be deleted. Remove the legal hold first.".to_string()
|
|
}
|
|
ObjectLockBlockReason::Retention { mode, retain_until } => {
|
|
if let Some(until) = retain_until {
|
|
format!("Object is under {} retention and cannot be deleted until {}", mode, until)
|
|
} else {
|
|
format!("Object is under {} retention and cannot be deleted", mode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if retention blocks deletion based on mode and bypass permission.
|
|
/// Returns Some(ObjectLockBlockReason) if blocked, None if allowed.
|
|
fn check_retention_blocks_deletion(
|
|
mode_str: &str,
|
|
retain_until: Option<OffsetDateTime>,
|
|
bypass_governance: bool,
|
|
) -> Option<ObjectLockBlockReason> {
|
|
// COMPLIANCE mode cannot be bypassed; GOVERNANCE can only be bypassed with permission
|
|
let can_bypass = mode_str == ObjectLockRetentionMode::GOVERNANCE && bypass_governance;
|
|
if !can_bypass {
|
|
return Some(ObjectLockBlockReason::Retention {
|
|
mode: mode_str.to_string(),
|
|
retain_until,
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Check an object's lock metadata using an already resolved bucket Object
|
|
/// Lock configuration. `None` means the configuration is confirmed absent.
|
|
///
|
|
/// # S3 Standard Behavior
|
|
/// - COMPLIANCE mode: Cannot be deleted even with bypass header
|
|
/// - GOVERNANCE mode: Can be deleted if bypass_governance is true (caller must verify s3:BypassGovernanceRetention permission)
|
|
/// - Legal Hold: Cannot be bypassed regardless of mode
|
|
pub(crate) fn check_object_lock_for_deletion_with_config(
|
|
config: Option<&ObjectLockConfiguration>,
|
|
obj_info: &ObjectInfo,
|
|
bypass_governance: bool,
|
|
) -> Result<Option<ObjectLockBlockReason>> {
|
|
if obj_info.delete_marker {
|
|
return Ok(None);
|
|
}
|
|
|
|
if let Some(status) = obj_info.user_defined.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()) {
|
|
if status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::ON) {
|
|
return Ok(Some(ObjectLockBlockReason::LegalHold));
|
|
}
|
|
if !status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::OFF) {
|
|
return Err(Error::other("persisted object legal-hold metadata is invalid"));
|
|
}
|
|
}
|
|
|
|
let mode = obj_info.user_defined.get(X_AMZ_OBJECT_LOCK_MODE.as_str());
|
|
let retain_until = obj_info.user_defined.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
|
|
let explicit_ret = match (mode, retain_until) {
|
|
(None, None) => None,
|
|
(Some(mode), Some(retain_until)) => {
|
|
let mode =
|
|
objectlock::parse_ret_mode(mode).ok_or_else(|| Error::other("persisted object retention mode is invalid"))?;
|
|
let retain_until = OffsetDateTime::parse(retain_until, &time::format_description::well_known::Iso8601::DEFAULT)
|
|
.map(Date::from)
|
|
.map_err(|_| Error::other("persisted object retention date is invalid"))?;
|
|
Some((mode, retain_until))
|
|
}
|
|
_ => return Err(Error::other("persisted object retention metadata is incomplete")),
|
|
};
|
|
|
|
if let Some((mode, retain_until)) = &explicit_ret {
|
|
let mode_str = mode.as_str();
|
|
if is_retention_active(mode_str, Some(retain_until))
|
|
&& let Some(reason) =
|
|
check_retention_blocks_deletion(mode_str, Some(OffsetDateTime::from(retain_until.clone())), bypass_governance)
|
|
{
|
|
return Ok(Some(reason));
|
|
}
|
|
}
|
|
|
|
if explicit_ret.is_none()
|
|
&& let Some(default_retention) = config.and_then(|config| config.rule.as_ref()?.default_retention.as_ref())
|
|
&& let Some(mode) = &default_retention.mode
|
|
{
|
|
let mode_str = mode.as_str();
|
|
if mode_str == ObjectLockRetentionMode::COMPLIANCE || mode_str == ObjectLockRetentionMode::GOVERNANCE {
|
|
// Calculate retention expiration date from object modification time
|
|
let mod_time = obj_info
|
|
.mod_time
|
|
.ok_or_else(|| Error::other("persisted object modification time is missing"))?;
|
|
let now = objectlock::utc_now_ntp();
|
|
let retain_until = if let Some(days) = default_retention.days {
|
|
mod_time.saturating_add(time::Duration::days(i64::from(days)))
|
|
} else {
|
|
let years = default_retention
|
|
.years
|
|
.ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?;
|
|
add_years(mod_time, years)
|
|
};
|
|
|
|
if retain_until.unix_timestamp() > now.unix_timestamp()
|
|
&& let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance)
|
|
{
|
|
return Ok(Some(reason));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
pub(crate) fn check_object_lock_for_deletion_with_state(
|
|
state: &ObjectLockConfigState,
|
|
obj_info: &ObjectInfo,
|
|
bypass_governance: bool,
|
|
) -> Result<Option<ObjectLockBlockReason>> {
|
|
match state {
|
|
ObjectLockConfigState::Configured { config, .. } => {
|
|
check_object_lock_for_deletion_with_config(Some(config), obj_info, bypass_governance)
|
|
}
|
|
ObjectLockConfigState::ConfirmedAbsent => check_object_lock_for_deletion_with_config(None, obj_info, bypass_governance),
|
|
ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")),
|
|
}
|
|
}
|
|
|
|
/// Compatibility wrapper for callers that predate fallible metadata lookup.
|
|
/// An authority/read/parse failure is represented as a blocking reason rather
|
|
/// than the old fail-open `None` result.
|
|
pub async fn check_object_lock_for_deletion(
|
|
bucket: &str,
|
|
obj_info: &ObjectInfo,
|
|
bypass_governance: bool,
|
|
) -> Option<ObjectLockBlockReason> {
|
|
match get_object_lock_config_state(bucket)
|
|
.await
|
|
.and_then(|state| check_object_lock_for_deletion_with_state(&state, obj_info, bypass_governance))
|
|
{
|
|
Ok(reason) => reason,
|
|
Err(_) => Some(ObjectLockBlockReason::LegalHold),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use s3s::dto::{ObjectLockEnabled, ObjectLockRule};
|
|
use time::{Date, Month, PrimitiveDateTime, Time};
|
|
|
|
fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime {
|
|
let date = Date::from_calendar_date(year, Month::try_from(month).unwrap(), day).unwrap();
|
|
let time = Time::from_hms(0, 0, 0).unwrap();
|
|
PrimitiveDateTime::new(date, time).assume_utc()
|
|
}
|
|
|
|
fn default_retention_config(mode: &'static str) -> ObjectLockConfiguration {
|
|
ObjectLockConfiguration {
|
|
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
|
rule: Some(ObjectLockRule {
|
|
default_retention: Some(DefaultRetention {
|
|
mode: Some(ObjectLockRetentionMode::from_static(mode)),
|
|
days: Some(30),
|
|
years: None,
|
|
}),
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_with_config_blocks_active_default_compliance_even_with_bypass() {
|
|
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
|
let obj_info = ObjectInfo {
|
|
mod_time: Some(OffsetDateTime::now_utc()),
|
|
..Default::default()
|
|
};
|
|
|
|
let result = check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true);
|
|
|
|
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_with_config_allows_active_default_governance_with_bypass() {
|
|
let config = default_retention_config(ObjectLockRetentionMode::GOVERNANCE);
|
|
let obj_info = ObjectInfo {
|
|
mod_time: Some(OffsetDateTime::now_utc()),
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(matches!(
|
|
check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true),
|
|
Ok(None)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_with_default_retention_rejects_missing_object_mod_time() {
|
|
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
|
|
|
let err = check_object_lock_for_deletion_with_config(Some(&config), &ObjectInfo::default(), false)
|
|
.expect_err("default retention needs an authoritative object modification time");
|
|
|
|
assert!(err.to_string().contains("modification time"));
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_with_confirmed_absence_still_blocks_explicit_compliance() {
|
|
let retain_until = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
retain_until
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.expect("retain-until date should format"),
|
|
);
|
|
let obj_info = ObjectInfo {
|
|
user_defined: Arc::new(user_defined),
|
|
..Default::default()
|
|
};
|
|
|
|
let result = check_object_lock_for_deletion_with_config(None, &obj_info, true);
|
|
|
|
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_with_fabricated_bucket_metadata_fails_closed() {
|
|
let err = check_object_lock_for_deletion_with_state(&ObjectLockConfigState::Fabricated, &ObjectInfo::default(), false)
|
|
.expect_err("non-authoritative Object Lock metadata must block deletion");
|
|
|
|
assert!(err.to_string().contains("not authoritative"));
|
|
}
|
|
|
|
#[test]
|
|
fn recursive_force_delete_with_fabricated_bucket_metadata_fails_closed() {
|
|
let err = ensure_recursive_force_delete_allowed_for_state("bucket", &ObjectLockConfigState::Fabricated)
|
|
.expect_err("non-authoritative Object Lock metadata must block recursive deletion");
|
|
|
|
assert!(err.to_string().contains("not authoritative"));
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_rejects_incomplete_persisted_retention_metadata() {
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
user_defined.insert(
|
|
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
|
|
ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
|
);
|
|
let obj_info = ObjectInfo {
|
|
user_defined: Arc::new(user_defined),
|
|
..Default::default()
|
|
};
|
|
|
|
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
|
.expect_err("mode without retain-until date must fail closed");
|
|
|
|
assert!(err.to_string().contains("incomplete"));
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_rejects_each_malformed_persisted_retention_shape() {
|
|
let valid_date = (OffsetDateTime::now_utc() + time::Duration::days(30))
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.expect("retain-until date should format");
|
|
let cases = [
|
|
("invalid mode", Some("INVALID"), Some(valid_date.as_str()), "retention mode"),
|
|
(
|
|
"invalid date",
|
|
Some(ObjectLockRetentionMode::COMPLIANCE),
|
|
Some("not-a-date"),
|
|
"retention date",
|
|
),
|
|
("date only", None, Some(valid_date.as_str()), "incomplete"),
|
|
];
|
|
|
|
for (case, mode, retain_until, expected) in cases {
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
if let Some(mode) = mode {
|
|
user_defined.insert(X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), mode.to_string());
|
|
}
|
|
if let Some(retain_until) = retain_until {
|
|
user_defined.insert(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(), retain_until.to_string());
|
|
}
|
|
let obj_info = ObjectInfo {
|
|
user_defined: Arc::new(user_defined),
|
|
..Default::default()
|
|
};
|
|
|
|
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false).expect_err(case);
|
|
assert!(err.to_string().contains(expected), "unexpected {case} error: {err}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_rejects_invalid_persisted_legal_hold_metadata() {
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
user_defined.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "INVALID".to_string());
|
|
let obj_info = ObjectInfo {
|
|
user_defined: Arc::new(user_defined),
|
|
..Default::default()
|
|
};
|
|
|
|
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
|
.expect_err("invalid legal-hold value must fail closed");
|
|
|
|
assert!(err.to_string().contains("legal-hold"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_years_normal() {
|
|
// Normal case: add 1 year to a regular date
|
|
let dt = make_datetime(2024, 3, 15);
|
|
let result = add_years(dt, 1);
|
|
assert_eq!(result.year(), 2025);
|
|
assert_eq!(result.month(), Month::March);
|
|
assert_eq!(result.day(), 15);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_years_multiple() {
|
|
// Add multiple years
|
|
let dt = make_datetime(2024, 6, 1);
|
|
let result = add_years(dt, 5);
|
|
assert_eq!(result.year(), 2029);
|
|
assert_eq!(result.month(), Month::June);
|
|
assert_eq!(result.day(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_years_leap_year_to_leap_year() {
|
|
// Feb 29 in leap year to another leap year (2024 -> 2028)
|
|
let dt = make_datetime(2024, 2, 29);
|
|
let result = add_years(dt, 4);
|
|
assert_eq!(result.year(), 2028);
|
|
assert_eq!(result.month(), Month::February);
|
|
assert_eq!(result.day(), 29);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_years_leap_year_to_non_leap_year() {
|
|
// Feb 29 in leap year to non-leap year should become Feb 28
|
|
let dt = make_datetime(2024, 2, 29);
|
|
let result = add_years(dt, 1);
|
|
assert_eq!(result.year(), 2025);
|
|
assert_eq!(result.month(), Month::February);
|
|
assert_eq!(result.day(), 28);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_years_negative() {
|
|
// Subtract years
|
|
let dt = make_datetime(2024, 3, 15);
|
|
let result = add_years(dt, -2);
|
|
assert_eq!(result.year(), 2022);
|
|
assert_eq!(result.month(), Month::March);
|
|
assert_eq!(result.day(), 15);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_years_zero() {
|
|
// Add zero years (should return same date)
|
|
let dt = make_datetime(2024, 7, 4);
|
|
let result = add_years(dt, 0);
|
|
assert_eq!(result.year(), 2024);
|
|
assert_eq!(result.month(), Month::July);
|
|
assert_eq!(result.day(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_retention_active_invalid_mode() {
|
|
// Invalid mode should return false
|
|
assert!(!is_retention_active("INVALID", None));
|
|
assert!(!is_retention_active("", None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_retention_active_no_date() {
|
|
// Valid mode but no retain_until_date should return false
|
|
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, None));
|
|
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_retention_active_future_date() {
|
|
// Valid mode with future retain_until_date should return true
|
|
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
let s3_date = s3s::dto::Date::from(future_date);
|
|
|
|
assert!(is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
|
|
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
let s3_date = s3s::dto::Date::from(future_date);
|
|
assert!(is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_retention_active_past_date() {
|
|
// Valid mode with past retain_until_date should return false
|
|
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
|
|
let s3_date = s3s::dto::Date::from(past_date);
|
|
|
|
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
|
|
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
|
|
let s3_date = s3s::dto::Date::from(past_date);
|
|
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_no_existing_retention() {
|
|
// No existing retention - modification should be allowed
|
|
let user_defined = std::collections::HashMap::new();
|
|
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(30));
|
|
assert!(check_retention_for_modification(&user_defined, None, new_retain, false).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_compliance_extend() {
|
|
// COMPLIANCE mode - extending retention should be allowed
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
existing_retain
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap(),
|
|
);
|
|
|
|
// Extending by another 30 days should be allowed
|
|
let new_retain = Some(existing_retain + time::Duration::days(30));
|
|
assert!(
|
|
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_compliance_shorten() {
|
|
// COMPLIANCE mode - shortening retention should be blocked
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(60);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
existing_retain
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap(),
|
|
);
|
|
|
|
// Shortening to 30 days should be blocked
|
|
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(30));
|
|
let result =
|
|
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false);
|
|
assert!(result.is_some());
|
|
assert!(matches!(result, Some(ObjectLockBlockReason::Retention { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_compliance_clear() {
|
|
// COMPLIANCE mode - clearing retention should be blocked
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
existing_retain
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap(),
|
|
);
|
|
|
|
// Clearing (None) should be blocked
|
|
let result = check_retention_for_modification(&user_defined, None, None, false);
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_governance_shorten_without_bypass() {
|
|
// GOVERNANCE mode - shortening retention without bypass should be blocked
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
existing_retain
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap(),
|
|
);
|
|
|
|
// Shortening from 30 days to 15 days without bypass should be blocked
|
|
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
|
|
let result =
|
|
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false);
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_governance_extend_without_bypass() {
|
|
// GOVERNANCE mode - extending retention without bypass should be allowed
|
|
// This matches AWS S3 behavior where extending is always allowed
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
existing_retain
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap(),
|
|
);
|
|
|
|
// Extending from 30 days to 60 days without bypass should be allowed
|
|
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(60));
|
|
assert!(
|
|
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_governance_shorten_with_bypass() {
|
|
// GOVERNANCE mode - shortening retention with bypass should be allowed
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
existing_retain
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap(),
|
|
);
|
|
|
|
// Shortening from 30 days to 15 days with bypass should be allowed
|
|
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
|
|
assert!(
|
|
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, true)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_governance_mode_change_without_bypass() {
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
existing_retain
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap(),
|
|
);
|
|
|
|
let result = check_retention_for_modification(
|
|
&user_defined,
|
|
Some(ObjectLockRetentionMode::COMPLIANCE),
|
|
Some(existing_retain),
|
|
false,
|
|
);
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_governance_mode_change_with_bypass() {
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
existing_retain
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap(),
|
|
);
|
|
|
|
assert!(
|
|
check_retention_for_modification(
|
|
&user_defined,
|
|
Some(ObjectLockRetentionMode::COMPLIANCE),
|
|
Some(existing_retain),
|
|
true,
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_check_retention_for_modification_compliance_mode_change() {
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let existing_retain = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
existing_retain
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap(),
|
|
);
|
|
|
|
let result = check_retention_for_modification(
|
|
&user_defined,
|
|
Some(ObjectLockRetentionMode::GOVERNANCE),
|
|
Some(existing_retain),
|
|
true,
|
|
);
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_object_locked_by_metadata_delete_marker() {
|
|
// Delete markers are never locked
|
|
let user_defined = std::collections::HashMap::new();
|
|
assert!(!is_object_locked_by_metadata(&user_defined, true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_object_locked_by_metadata_legal_hold_on() {
|
|
// Legal hold ON should be locked
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
user_defined.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
|
|
assert!(is_object_locked_by_metadata(&user_defined, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_object_locked_by_metadata_legal_hold_off() {
|
|
// Legal hold OFF should not be locked
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
user_defined.insert("x-amz-object-lock-legal-hold".to_string(), "OFF".to_string());
|
|
assert!(!is_object_locked_by_metadata(&user_defined, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_object_locked_by_metadata_retention_active() {
|
|
// Active retention should be locked
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
future_date.format(&time::format_description::well_known::Rfc3339).unwrap(),
|
|
);
|
|
assert!(is_object_locked_by_metadata(&user_defined, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_object_locked_by_metadata_retention_expired() {
|
|
// Expired retention should not be locked
|
|
let mut user_defined = std::collections::HashMap::new();
|
|
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
|
|
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
|
|
user_defined.insert(
|
|
"x-amz-object-lock-retain-until-date".to_string(),
|
|
past_date.format(&time::format_description::well_known::Rfc3339).unwrap(),
|
|
);
|
|
assert!(!is_object_locked_by_metadata(&user_defined, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_object_locked_by_metadata_no_lock() {
|
|
// No lock settings should not be locked
|
|
let user_defined = std::collections::HashMap::new();
|
|
assert!(!is_object_locked_by_metadata(&user_defined, false));
|
|
}
|
|
}
|