Files
rustfs/crates/storage-api/src/object.rs
T
Zhengchao An 7ff578ff20 fix(ilm): notify on batch noncurrent version expiry (#7116)
The batch `NewerNoncurrentVersions` expiry path took a lifecycle event
argument and ignored it: after `delete_objects` committed it only evicted
the cache and scheduled replication deletes, so a successful noncurrent
version expiry was invisible to notification subscribers while the
equivalent current-version path emitted a lifecycle expiration event.

Emit that event from the batch path too, reusing the existing lifecycle
audit sink and event contract. Only entries that actually mutated
something are announced, and cache eviction and replication scheduling
keep their existing order and admission — the event is derived from the
committed result and a send failure never rolls back a delete.

"No error" is not enough to prove a mutation: the disk layer skips an
absent version and reports success, so a batch entry for a version that
was already gone came back indistinguishable from a committed delete.
The delete plan already resolves whether the source exists, so carry that
`source_missing` result on `DeletedObject` and let the lifecycle path
stay silent for versions it did not remove.

backlog#2202
2026-09-04 18:25:10 +08:00

1132 lines
37 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 std::fmt;
use std::sync::Arc;
use std::time::Duration;
use crate::replication::{
ReplicationState, ReplicationStatusType, VersionPurgeStatusType, replication_statuses_map, version_purge_statuses_map,
};
use time::OffsetDateTime;
use uuid::Uuid;
const NULL_VERSION_MARKER: &str = "null";
#[derive(Debug, Default, Clone)]
pub struct HTTPPreconditions {
pub if_match: Option<String>,
pub if_none_match: Option<String>,
pub if_modified_since: Option<OffsetDateTime>,
pub if_unmodified_since: Option<OffsetDateTime>,
}
impl HTTPPreconditions {
pub fn if_match_value(&self) -> Option<&str> {
non_empty_condition_value(self.if_match.as_deref())
}
pub fn if_none_match_value(&self) -> Option<&str> {
non_empty_condition_value(self.if_none_match.as_deref())
}
}
#[derive(Debug, Default, Clone)]
pub struct ObjectLockRetentionOptions {
pub mode: Option<String>,
pub retain_until: Option<OffsetDateTime>,
pub bypass_governance: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ExpirationOptions {
pub expire: bool,
}
#[derive(Debug, Default, Clone)]
pub struct ObjectLockDeleteOptions {
pub bypass_governance: bool,
}
#[derive(Debug, Default, Clone)]
pub struct TransitionedObject {
pub name: String,
pub version_id: String,
pub tier: String,
pub free_version: bool,
pub status: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ObjectPreconditionPart {
pub number: usize,
pub exists: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ObjectPreconditionState<'a> {
pub etag: Option<&'a str>,
pub mod_time: Option<OffsetDateTime>,
pub requested_part: Option<ObjectPreconditionPart>,
}
impl ObjectPreconditionState<'_> {
pub fn check(self, preconditions: Option<&HTTPPreconditions>) -> Result<(), ObjectPreconditionError> {
if let Some(part) = self.requested_part
&& part.number > 1
&& !part.exists
{
return Err(ObjectPreconditionError::InvalidPartNumber(part.number));
}
let Some(preconditions) = preconditions else {
return Ok(());
};
let has_valid_mod_time = self.mod_time.is_some_and(|t| t != OffsetDateTime::UNIX_EPOCH);
let if_none_match = preconditions.if_none_match_value();
let if_match = preconditions.if_match_value();
if let Some(if_none_match) = if_none_match
&& let Some(etag) = self.etag
&& etag_matches(etag, if_none_match)
{
return Err(ObjectPreconditionError::NotModified);
}
if has_valid_mod_time
&& let Some(if_modified_since) = &preconditions.if_modified_since
&& let Some(mod_time) = &self.mod_time
&& !is_modified_since(mod_time, if_modified_since)
{
return Err(ObjectPreconditionError::NotModified);
}
if let Some(if_match) = if_match {
if let Some(etag) = self.etag {
if !etag_matches(etag, if_match) {
return Err(ObjectPreconditionError::PreconditionFailed);
}
} else {
return Err(ObjectPreconditionError::PreconditionFailed);
}
}
if has_valid_mod_time
&& if_match.is_none()
&& let Some(if_unmodified_since) = &preconditions.if_unmodified_since
&& let Some(mod_time) = &self.mod_time
&& is_modified_since(mod_time, if_unmodified_since)
{
return Err(ObjectPreconditionError::PreconditionFailed);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectPreconditionError {
InvalidPartNumber(usize),
NotModified,
PreconditionFailed,
}
impl fmt::Display for ObjectPreconditionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPartNumber(part_number) => write!(f, "invalid part number {part_number}"),
Self::NotModified => f.write_str("object not modified"),
Self::PreconditionFailed => f.write_str("object precondition failed"),
}
}
}
impl std::error::Error for ObjectPreconditionError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionMarker {
Null,
Version(Uuid),
}
impl VersionMarker {
pub fn parse(marker: impl AsRef<str>) -> Result<Self, uuid::Error> {
let marker = marker.as_ref();
if marker == NULL_VERSION_MARKER {
Ok(Self::Null)
} else {
let version = Uuid::parse_str(marker)?;
// Older releases advertised the null version as a nil UUID
// (issue #6745); a stored null version has no UUID, so resuming
// by `Version(nil)` could never match. Fold it into `Null`.
if version.is_nil() {
Ok(Self::Null)
} else {
Ok(Self::Version(version))
}
}
}
}
#[derive(Clone, Default, PartialEq, Eq)]
pub enum WalkVersionsSortOrder {
#[default]
Ascending,
Descending,
}
/// In-memory compare-and-set identity for a queued exact-version delete.
///
/// `version_id` identifies the S3 version while these fields identify the
/// concrete generation observed before the delete was queued. This matters for
/// the reusable null version ID in versioning-suspended buckets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ObjectToDeleteIdentity {
pub data_dir: Option<Uuid>,
pub mod_time: Option<OffsetDateTime>,
pub delete_marker: bool,
}
#[derive(Debug, Default, Clone)]
pub struct ObjectToDelete {
pub object_name: String,
pub version_id: Option<Uuid>,
/// RustFS-only precondition checked under the object write lock.
#[doc(hidden)]
pub expected_identity: Option<ObjectToDeleteIdentity>,
pub synthetic_version_id: bool,
pub delete_marker_replication_status: Option<String>,
pub version_purge_status: Option<VersionPurgeStatusType>,
pub version_purge_statuses: Option<String>,
pub replicate_decision_str: Option<String>,
}
impl ObjectToDelete {
pub fn with_expected_identity(
mut self,
data_dir: Option<Uuid>,
mod_time: Option<OffsetDateTime>,
delete_marker: bool,
) -> Self {
self.expected_identity = Some(ObjectToDeleteIdentity {
data_dir,
mod_time,
delete_marker,
});
self
}
pub fn replication_state(&self) -> ReplicationState {
ReplicationState {
replication_status_internal: self.delete_marker_replication_status.clone(),
version_purge_status_internal: self.version_purge_statuses.clone(),
replicate_decision_str: self.replicate_decision_str.clone().unwrap_or_default(),
targets: replication_statuses_map(self.delete_marker_replication_status.as_deref().unwrap_or_default()),
purge_targets: version_purge_statuses_map(self.version_purge_statuses.as_deref().unwrap_or_default()),
..Default::default()
}
}
}
#[derive(Debug, Default, Clone)]
pub struct DeletedObject {
pub delete_marker: bool,
/// True when the delete plan looked the target up and found no such
/// object or version.
///
/// The lookup only runs when the plan needs the source (Object Lock
/// check, replication decision, tier journal, or an expected identity),
/// so this proves absence and never proves presence: it stays false when
/// no lookup ran. Callers that must not announce a delete that removed
/// nothing need this, because the disk layer treats an absent version as
/// an idempotent success and reports `found` regardless.
pub source_missing: bool,
pub delete_marker_version_id: Option<Uuid>,
pub object_name: String,
pub version_id: Option<Uuid>,
pub delete_marker_mtime: Option<OffsetDateTime>,
pub replication_state: Option<ReplicationState>,
pub found: bool,
pub force_delete: bool,
pub force_delete_id: Option<Uuid>,
pub force_delete_target_arns: Vec<String>,
pub force_delete_generation: Option<i64>,
}
/// Accounting identity returned by the internal commit-time delete path.
///
/// This is carried separately from [`DeletedObject`] so adding quota details
/// does not change the source shape of the public S3 delete result contract.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DeleteAccounting {
pub size: Option<u64>,
pub version_id: Option<Uuid>,
pub removed_current_object: bool,
}
impl DeletedObject {
pub fn version_purge_status(&self) -> VersionPurgeStatusType {
self.replication_state
.as_ref()
.map(|v| v.composite_version_purge_status())
.unwrap_or(VersionPurgeStatusType::Empty)
}
pub fn delete_marker_replication_status(&self) -> ReplicationStatusType {
self.replication_state
.as_ref()
.map(|v| v.composite_replication_status())
.unwrap_or(ReplicationStatusType::Empty)
}
}
#[derive(Clone)]
pub struct WalkOptions<Filter> {
pub filter: Option<Filter>,
pub marker: Option<String>,
pub latest_only: bool,
pub ask_disks: String,
pub versions_sort: WalkVersionsSortOrder,
pub limit: usize,
pub include_free_versions: bool,
pub walkdir_timeout: Option<Duration>,
pub walkdir_stall_timeout: Option<Duration>,
}
impl<Filter> Default for WalkOptions<Filter> {
fn default() -> Self {
Self {
filter: None,
marker: None,
latest_only: false,
ask_disks: String::new(),
versions_sort: WalkVersionsSortOrder::default(),
limit: 0,
include_free_versions: false,
walkdir_timeout: None,
walkdir_stall_timeout: None,
}
}
}
impl<Filter> WalkOptions<Filter> {
pub fn with_walkdir_timeouts(mut self, timeout: Duration) -> Self {
self.walkdir_timeout = Some(timeout);
self.walkdir_stall_timeout = Some(timeout);
self
}
}
#[async_trait::async_trait]
pub trait ObjectIO: Send + Sync + fmt::Debug + 'static {
type Error: std::error::Error + Send + Sync + 'static;
type RangeSpec: Send + 'static;
type HeaderMap: Send + 'static;
type ObjectOptions: Send + Sync + 'static;
type ObjectInfo: Send + 'static;
type GetObjectReader: Send + 'static;
type PutObjectReader: Send + 'static;
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
range: Option<Self::RangeSpec>,
h: Self::HeaderMap,
opts: &Self::ObjectOptions,
) -> Result<Self::GetObjectReader, Self::Error>;
async fn put_object(
&self,
bucket: &str,
object: &str,
data: &mut Self::PutObjectReader,
opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error>;
}
#[async_trait::async_trait]
#[allow(clippy::too_many_arguments)]
pub trait ObjectOperations: Send + Sync + fmt::Debug {
type Error: std::error::Error + Send + Sync + 'static;
type ObjectInfo: Send + 'static;
type ObjectOptions: Send + Sync + 'static;
type FileInfo: Send + Sync + 'static;
type ObjectToDelete: Send + 'static;
type DeletedObject: Send + 'static;
async fn get_object_info(
&self,
bucket: &str,
object: &str,
opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error>;
async fn verify_object_integrity(&self, bucket: &str, object: &str, opts: &Self::ObjectOptions) -> Result<(), Self::Error>;
async fn copy_object(
&self,
src_bucket: &str,
src_object: &str,
dst_bucket: &str,
dst_object: &str,
src_info: &mut Self::ObjectInfo,
src_opts: &Self::ObjectOptions,
dst_opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error>;
async fn delete_object_version(
&self,
bucket: &str,
object: &str,
fi: &Self::FileInfo,
force_del_marker: bool,
) -> Result<(), Self::Error>;
async fn delete_object(&self, bucket: &str, object: &str, opts: Self::ObjectOptions)
-> Result<Self::ObjectInfo, Self::Error>;
async fn delete_objects(
&self,
bucket: &str,
objects: Vec<Self::ObjectToDelete>,
opts: Self::ObjectOptions,
) -> (Vec<Self::DeletedObject>, Vec<Option<Self::Error>>);
/// Delete objects and optionally return commit-time accounting identities.
/// The default preserves the ordinary delete contract for implementations
/// that do not expose storage-level accounting details.
async fn delete_objects_with_accounting(
&self,
bucket: &str,
objects: Vec<Self::ObjectToDelete>,
opts: Self::ObjectOptions,
) -> (Vec<Self::DeletedObject>, Vec<Option<Self::Error>>, Vec<Option<DeleteAccounting>>) {
let object_count = objects.len();
let (deleted, errors) = self.delete_objects(bucket, objects, opts).await;
(deleted, errors, vec![None; object_count])
}
async fn put_object_metadata(
&self,
bucket: &str,
object: &str,
opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error>;
async fn get_object_tags(&self, bucket: &str, object: &str, opts: &Self::ObjectOptions) -> Result<String, Self::Error>;
async fn put_object_tags(
&self,
bucket: &str,
object: &str,
tags: &str,
opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error>;
async fn delete_object_tags(
&self,
bucket: &str,
object: &str,
opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error>;
async fn add_partial(&self, bucket: &str, object: &str, version_id: &str) -> Result<(), Self::Error>;
async fn transition_object(&self, bucket: &str, object: &str, opts: &Self::ObjectOptions) -> Result<(), Self::Error>;
async fn restore_transitioned_object(
self: Arc<Self>,
bucket: &str,
object: &str,
opts: &Self::ObjectOptions,
) -> Result<(), Self::Error>;
}
#[async_trait::async_trait]
#[allow(clippy::too_many_arguments)]
pub trait ListOperations: Send + Sync + fmt::Debug {
type Error: std::error::Error + Send + Sync + 'static;
type ListObjectsV2Info: Send + 'static;
type ListObjectVersionsInfo: Send + 'static;
type ObjectInfoOrErr: Send + 'static;
type WalkOptions: Send + 'static;
type WalkCancellation: Send + 'static;
type WalkResultSender: Send + 'static;
async fn list_objects_v2(
self: Arc<Self>,
bucket: &str,
prefix: &str,
continuation_token: Option<String>,
delimiter: Option<String>,
max_keys: i32,
fetch_owner: bool,
start_after: Option<String>,
incl_deleted: bool,
) -> Result<Self::ListObjectsV2Info, Self::Error>;
async fn list_object_versions(
self: Arc<Self>,
bucket: &str,
prefix: &str,
marker: Option<String>,
version_marker: Option<String>,
delimiter: Option<String>,
max_keys: i32,
) -> Result<Self::ListObjectVersionsInfo, Self::Error>;
async fn walk(
self: Arc<Self>,
rx: Self::WalkCancellation,
bucket: &str,
prefix: &str,
result: Self::WalkResultSender,
opts: Self::WalkOptions,
) -> Result<(), Self::Error>;
}
#[async_trait::async_trait]
#[allow(clippy::too_many_arguments)]
pub trait MultipartOperations: Send + Sync + fmt::Debug {
type Error: std::error::Error + Send + Sync + 'static;
type ObjectInfo: Send + 'static;
type ObjectOptions: Send + Sync + 'static;
type PutObjectReader: Send + 'static;
type CompletePart: Send + 'static;
type ListMultipartsInfo: Send + 'static;
type MultipartUploadResult: Send + 'static;
type PartInfo: Send + 'static;
type MultipartInfo: Send + 'static;
type ListPartsInfo: Send + 'static;
async fn list_multipart_uploads(
&self,
bucket: &str,
prefix: &str,
key_marker: Option<String>,
upload_id_marker: Option<String>,
delimiter: Option<String>,
max_uploads: usize,
) -> Result<Self::ListMultipartsInfo, Self::Error>;
async fn new_multipart_upload(
&self,
bucket: &str,
object: &str,
opts: &Self::ObjectOptions,
) -> Result<Self::MultipartUploadResult, Self::Error>;
async fn copy_object_part(
&self,
src_bucket: &str,
src_object: &str,
dst_bucket: &str,
dst_object: &str,
upload_id: &str,
part_id: usize,
start_offset: i64,
length: i64,
src_info: &Self::ObjectInfo,
src_opts: &Self::ObjectOptions,
dst_opts: &Self::ObjectOptions,
) -> Result<(), Self::Error>;
async fn put_object_part(
&self,
bucket: &str,
object: &str,
upload_id: &str,
part_id: usize,
data: &mut Self::PutObjectReader,
opts: &Self::ObjectOptions,
) -> Result<Self::PartInfo, Self::Error>;
async fn get_multipart_info(
&self,
bucket: &str,
object: &str,
upload_id: &str,
opts: &Self::ObjectOptions,
) -> Result<Self::MultipartInfo, Self::Error>;
async fn list_object_parts(
&self,
bucket: &str,
object: &str,
upload_id: &str,
part_number_marker: Option<usize>,
max_parts: usize,
opts: &Self::ObjectOptions,
) -> Result<Self::ListPartsInfo, Self::Error>;
async fn abort_multipart_upload(
&self,
bucket: &str,
object: &str,
upload_id: &str,
opts: &Self::ObjectOptions,
) -> Result<(), Self::Error>;
async fn complete_multipart_upload(
self: Arc<Self>,
bucket: &str,
object: &str,
upload_id: &str,
uploaded_parts: Vec<Self::CompletePart>,
opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error>;
}
#[async_trait::async_trait]
pub trait HealOperations: Send + Sync + fmt::Debug {
type Error: std::error::Error + Send + Sync + 'static;
type HealResultItem: Send + 'static;
type HealOptions: Send + Sync + 'static;
async fn heal_format(&self, dry_run: bool) -> Result<(Self::HealResultItem, Option<Self::Error>), Self::Error>;
async fn heal_bucket(&self, bucket: &str, opts: &Self::HealOptions) -> Result<Self::HealResultItem, Self::Error>;
async fn heal_object(
&self,
bucket: &str,
object: &str,
version_id: &str,
opts: &Self::HealOptions,
) -> Result<(Self::HealResultItem, Option<Self::Error>), Self::Error>;
async fn get_pool_and_set(&self, id: &str) -> Result<(Option<usize>, Option<usize>, Option<usize>), Self::Error>;
async fn check_abandoned_parts(&self, bucket: &str, object: &str, opts: &Self::HealOptions) -> Result<(), Self::Error>;
}
#[async_trait::async_trait]
pub trait NamespaceLocking: Send + Sync + fmt::Debug + 'static {
type Error: std::error::Error + Send + Sync + 'static;
type NamespaceLock: Send + 'static;
async fn new_ns_lock(&self, bucket: &str, object: &str) -> Result<Self::NamespaceLock, Self::Error>;
}
#[derive(Debug, Default)]
pub struct ListObjectsInfo<ObjectItem> {
pub is_truncated: bool,
pub next_marker: Option<String>,
pub objects: Vec<ObjectItem>,
pub prefixes: Vec<String>,
}
#[derive(Debug, Default)]
pub struct ListObjectsV2Info<ObjectItem> {
pub is_truncated: bool,
pub continuation_token: Option<String>,
pub next_continuation_token: Option<String>,
pub objects: Vec<ObjectItem>,
pub prefixes: Vec<String>,
}
#[derive(Debug, Default, Clone)]
pub struct ListObjectVersionsInfo<ObjectItem> {
pub is_truncated: bool,
pub next_marker: Option<String>,
pub next_version_idmarker: Option<String>,
pub objects: Vec<ObjectItem>,
pub prefixes: Vec<String>,
}
#[derive(Debug)]
pub struct ObjectInfoOrErr<ObjectItem, ListError> {
pub item: Option<ObjectItem>,
pub err: Option<ListError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HTTPRangeError {
InvalidRangeSpec(String),
}
impl fmt::Display for HTTPRangeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRangeSpec(message) => f.write_str(message),
}
}
}
impl std::error::Error for HTTPRangeError {}
#[derive(Debug, Clone)]
pub struct HTTPRangeSpec {
pub is_suffix_length: bool,
pub start: i64,
pub end: i64,
}
impl HTTPRangeSpec {
pub fn from_part_sizes(object_size: i64, part_number: usize, part_sizes: impl IntoIterator<Item = i64>) -> Option<Self> {
if object_size == 0 || part_number == 0 {
return None;
}
let mut start = 0_i64;
let mut end = -1_i64;
let mut parts = part_sizes.into_iter();
for _ in 0..part_number {
let part_size = parts.next()?;
start = end.checked_add(1)?;
end = start.checked_add(part_size)?.checked_sub(1)?;
}
Some(Self {
is_suffix_length: false,
start,
end,
})
}
pub fn get_offset_length(&self, res_size: i64) -> Result<(usize, i64), HTTPRangeError> {
let len = self.get_length(res_size)?;
let mut start = self.start;
if self.is_suffix_length {
let suffix_len = if self.start < 0 {
self.start
.checked_neg()
.ok_or_else(|| HTTPRangeError::InvalidRangeSpec("range value invalid: suffix length overflow".to_string()))?
} else {
self.start
};
start = res_size - suffix_len;
if start < 0 {
start = 0;
}
}
let offset = usize::try_from(start)
.map_err(|_| HTTPRangeError::InvalidRangeSpec("range value invalid: start offset overflow".to_string()))?;
Ok((offset, len))
}
pub fn get_length(&self, res_size: i64) -> Result<i64, HTTPRangeError> {
if res_size < 0 {
return Err(HTTPRangeError::InvalidRangeSpec("The requested range is not satisfiable".to_string()));
}
if self.is_suffix_length {
let specified_len = if self.start < 0 {
self.start
.checked_neg()
.ok_or_else(|| HTTPRangeError::InvalidRangeSpec("range value invalid: suffix length overflow".to_string()))?
} else {
self.start
};
let mut range_length = specified_len;
if specified_len > res_size {
range_length = res_size;
}
return Ok(range_length);
}
if self.start >= res_size {
return Err(HTTPRangeError::InvalidRangeSpec("The requested range is not satisfiable".to_string()));
}
if self.end > -1 {
let mut end = self.end;
if res_size <= end {
end = res_size - 1;
}
let range_length = end - self.start + 1;
return Ok(range_length);
}
if self.end == -1 {
let range_length = res_size - self.start;
return Ok(range_length);
}
Err(HTTPRangeError::InvalidRangeSpec(format!(
"range value invalid: start={}, end={}, expected start <= end and end >= -1",
self.start, self.end
)))
}
}
fn non_empty_condition_value(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn etag_matches(object_etag: &str, condition_etag: &str) -> bool {
let object_etag = object_etag.trim_matches('"');
let condition_etag = condition_etag.trim_matches('"');
condition_etag == "*" || object_etag == condition_etag
}
fn is_modified_since(mod_time: &OffsetDateTime, given_time: &OffsetDateTime) -> bool {
mod_time.unix_timestamp() > given_time.unix_timestamp()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_marker_parse_folds_null_and_nil_uuid_into_null() {
assert_eq!(VersionMarker::parse("null"), Ok(VersionMarker::Null));
// Older releases advertised the null version as a nil UUID
// (issue #6745); it must resume as the null marker, not a UUID no
// stored version carries.
assert_eq!(VersionMarker::parse(Uuid::nil().to_string()), Ok(VersionMarker::Null));
let version = Uuid::from_u128(7);
assert_eq!(VersionMarker::parse(version.to_string()), Ok(VersionMarker::Version(version)));
assert!(VersionMarker::parse("not-a-version").is_err());
}
#[test]
fn http_preconditions_ignore_empty_etag_headers() {
let opts = HTTPPreconditions {
if_match: Some(" ".to_owned()),
if_none_match: Some(" * ".to_owned()),
..Default::default()
};
assert_eq!(opts.if_match_value(), None);
assert_eq!(opts.if_none_match_value(), Some("*"));
}
#[test]
fn object_lock_retention_defaults_preserve_false_bypass() {
let opts = ObjectLockRetentionOptions::default();
assert!(opts.mode.is_none());
assert!(opts.retain_until.is_none());
assert!(!opts.bypass_governance);
}
#[test]
fn object_precondition_state_rejects_missing_requested_part() {
let state = ObjectPreconditionState {
requested_part: Some(ObjectPreconditionPart {
number: 3,
exists: false,
}),
..Default::default()
};
assert_eq!(state.check(None), Err(ObjectPreconditionError::InvalidPartNumber(3)));
}
#[test]
fn object_precondition_state_keeps_existing_etag_priority() {
let preconditions = HTTPPreconditions {
if_match: Some("\"other\"".to_owned()),
if_none_match: Some("\"abc\"".to_owned()),
..Default::default()
};
let state = ObjectPreconditionState {
etag: Some("\"abc\""),
..Default::default()
};
assert_eq!(state.check(Some(&preconditions)), Err(ObjectPreconditionError::NotModified));
}
#[test]
fn object_precondition_state_requires_etag_for_if_match() {
let preconditions = HTTPPreconditions {
if_match: Some("\"abc\"".to_owned()),
..Default::default()
};
assert_eq!(
ObjectPreconditionState::default().check(Some(&preconditions)),
Err(ObjectPreconditionError::PreconditionFailed)
);
}
#[test]
fn object_precondition_state_checks_modification_dates() {
let mod_time = OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(100);
let preconditions = HTTPPreconditions {
if_modified_since: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(100)),
..Default::default()
};
let state = ObjectPreconditionState {
mod_time: Some(mod_time),
..Default::default()
};
assert_eq!(state.check(Some(&preconditions)), Err(ObjectPreconditionError::NotModified));
let preconditions = HTTPPreconditions {
if_unmodified_since: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(99)),
..Default::default()
};
assert_eq!(state.check(Some(&preconditions)), Err(ObjectPreconditionError::PreconditionFailed));
}
#[test]
fn version_marker_parses_null_and_uuid_markers() {
assert_eq!(VersionMarker::parse("null").expect("null marker should parse"), VersionMarker::Null);
let marker = "550e8400-e29b-41d4-a716-446655440000";
assert_eq!(
VersionMarker::parse(marker).expect("uuid marker should parse"),
VersionMarker::Version(Uuid::parse_str(marker).expect("test uuid should parse"))
);
}
#[test]
fn walk_versions_sort_order_defaults_to_ascending() {
assert!(matches!(WalkVersionsSortOrder::default(), WalkVersionsSortOrder::Ascending));
}
#[test]
fn walk_options_defaults_preserve_existing_contract() {
type TestFilter = fn(&str) -> bool;
let opts = WalkOptions::<TestFilter>::default();
assert!(opts.filter.is_none());
assert!(opts.marker.is_none());
assert!(!opts.latest_only);
assert!(opts.ask_disks.is_empty());
assert!(matches!(opts.versions_sort, WalkVersionsSortOrder::Ascending));
assert_eq!(opts.limit, 0);
assert!(!opts.include_free_versions);
}
#[test]
fn object_to_delete_builds_replication_state() {
let object = ObjectToDelete {
object_name: "photo.jpg".to_owned(),
delete_marker_replication_status: Some("PENDING".to_owned()),
version_purge_statuses: Some("arn:minio:replication:::target=PENDING".to_owned()),
replicate_decision_str: Some("arn:minio:replication:::target=PENDING".to_owned()),
..Default::default()
};
let state = object.replication_state();
assert_eq!(state.replication_status_internal.as_deref(), Some("PENDING"));
assert_eq!(
state.version_purge_status_internal.as_deref(),
Some("arn:minio:replication:::target=PENDING")
);
assert_eq!(state.replicate_decision_str, "arn:minio:replication:::target=PENDING");
}
#[test]
fn deleted_object_status_helpers_default_to_empty() {
let object = DeletedObject::default();
assert_eq!(object.version_purge_status(), VersionPurgeStatusType::Empty);
assert_eq!(object.delete_marker_replication_status(), ReplicationStatusType::Empty);
}
#[test]
fn lifecycle_helper_defaults_preserve_existing_contracts() {
let expiration = ExpirationOptions::default();
let transitioned = TransitionedObject::default();
assert!(!expiration.expire);
assert!(transitioned.name.is_empty());
assert!(transitioned.version_id.is_empty());
assert!(transitioned.tier.is_empty());
assert!(!transitioned.free_version);
assert!(transitioned.status.is_empty());
}
#[derive(Debug)]
struct TestListBackend;
#[derive(Debug)]
struct TestListError;
impl fmt::Display for TestListError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("test list error")
}
}
impl std::error::Error for TestListError {}
#[async_trait::async_trait]
impl ListOperations for TestListBackend {
type Error = TestListError;
type ListObjectsV2Info = ListObjectsV2Info<&'static str>;
type ListObjectVersionsInfo = ListObjectVersionsInfo<&'static str>;
type ObjectInfoOrErr = ObjectInfoOrErr<&'static str, TestListError>;
type WalkOptions = WalkOptions<fn(&str) -> bool>;
type WalkCancellation = ();
type WalkResultSender = ();
async fn list_objects_v2(
self: Arc<Self>,
bucket: &str,
prefix: &str,
continuation_token: Option<String>,
delimiter: Option<String>,
max_keys: i32,
fetch_owner: bool,
start_after: Option<String>,
incl_deleted: bool,
) -> Result<Self::ListObjectsV2Info, Self::Error> {
assert_eq!(bucket, "bucket");
assert_eq!(prefix, "photos/");
assert_eq!(continuation_token.as_deref(), Some("token"));
assert_eq!(delimiter.as_deref(), Some("/"));
assert_eq!(max_keys, 10);
assert!(fetch_owner);
assert_eq!(start_after.as_deref(), Some("photos/0001.jpg"));
assert!(!incl_deleted);
Ok(ListObjectsV2Info {
is_truncated: true,
continuation_token,
next_continuation_token: Some("next".to_owned()),
objects: vec!["photos/0002.jpg"],
prefixes: vec!["photos/2024/".to_owned()],
})
}
async fn list_object_versions(
self: Arc<Self>,
bucket: &str,
prefix: &str,
marker: Option<String>,
version_marker: Option<String>,
delimiter: Option<String>,
max_keys: i32,
) -> Result<Self::ListObjectVersionsInfo, Self::Error> {
assert_eq!(bucket, "bucket");
assert_eq!(prefix, "photos/");
assert_eq!(marker.as_deref(), Some("photos/0001.jpg"));
assert_eq!(version_marker.as_deref(), Some("version"));
assert_eq!(delimiter.as_deref(), Some("/"));
assert_eq!(max_keys, 10);
Ok(ListObjectVersionsInfo {
is_truncated: false,
next_marker: None,
next_version_idmarker: None,
objects: vec!["photos/0001.jpg"],
prefixes: Vec::new(),
})
}
async fn walk(
self: Arc<Self>,
_rx: Self::WalkCancellation,
bucket: &str,
prefix: &str,
_result: Self::WalkResultSender,
opts: Self::WalkOptions,
) -> Result<(), Self::Error> {
assert_eq!(bucket, "bucket");
assert_eq!(prefix, "photos/");
assert_eq!(opts.marker.as_deref(), Some("photos/0001.jpg"));
Ok(())
}
}
#[tokio::test]
async fn list_operations_trait_exposes_generic_list_contract() -> Result<(), TestListError> {
let backend = Arc::new(TestListBackend);
let listed = backend
.clone()
.list_objects_v2(
"bucket",
"photos/",
Some("token".to_owned()),
Some("/".to_owned()),
10,
true,
Some("photos/0001.jpg".to_owned()),
false,
)
.await?;
assert!(listed.is_truncated);
assert_eq!(listed.objects, vec!["photos/0002.jpg"]);
assert_eq!(listed.next_continuation_token.as_deref(), Some("next"));
let versions = backend
.clone()
.list_object_versions(
"bucket",
"photos/",
Some("photos/0001.jpg".to_owned()),
Some("version".to_owned()),
Some("/".to_owned()),
10,
)
.await?;
assert_eq!(versions.objects, vec!["photos/0001.jpg"]);
let opts = WalkOptions::<fn(&str) -> bool> {
marker: Some("photos/0001.jpg".to_owned()),
..Default::default()
};
backend.walk((), "bucket", "photos/", (), opts).await?;
Ok(())
}
#[test]
fn object_list_response_contracts_default_to_empty_collections() {
let v1 = ListObjectsInfo::<()>::default();
assert!(!v1.is_truncated);
assert!(v1.next_marker.is_none());
assert!(v1.objects.is_empty());
assert!(v1.prefixes.is_empty());
let v2 = ListObjectsV2Info::<()>::default();
assert!(!v2.is_truncated);
assert!(v2.continuation_token.is_none());
assert!(v2.next_continuation_token.is_none());
assert!(v2.objects.is_empty());
assert!(v2.prefixes.is_empty());
let versions = ListObjectVersionsInfo::<()>::default();
assert!(!versions.is_truncated);
assert!(versions.next_marker.is_none());
assert!(versions.next_version_idmarker.is_none());
assert!(versions.objects.is_empty());
assert!(versions.prefixes.is_empty());
}
#[test]
fn object_info_or_err_keeps_optional_item_and_error_slots() {
let item = ObjectInfoOrErr::<usize, &str> {
item: Some(7),
err: None,
};
assert_eq!(item.item, Some(7));
assert!(item.err.is_none());
}
#[test]
fn http_range_spec_offset_length_handles_suffix_and_bounds() {
let range = HTTPRangeSpec {
is_suffix_length: false,
start: 5,
end: 10,
};
assert_eq!(range.get_offset_length(20).expect("range should fit"), (5, 6));
let suffix = HTTPRangeSpec {
is_suffix_length: true,
start: -5,
end: -1,
};
assert_eq!(suffix.get_offset_length(20).expect("suffix range should fit"), (15, 5));
}
#[test]
fn http_range_spec_from_part_sizes_keeps_part_boundaries() {
let spec = HTTPRangeSpec::from_part_sizes(100, 3, [10, 15, 20]).expect("third part should exist");
assert_eq!(spec.start, 25);
assert_eq!(spec.end, 44);
assert!(HTTPRangeSpec::from_part_sizes(100, 0, [10]).is_none());
assert!(HTTPRangeSpec::from_part_sizes(100, 2, [10]).is_none());
}
}