From a96dd7d28921e5134e13da1ad43727673cc70735 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 21:13:18 +0800 Subject: [PATCH 01/54] refactor: migrate consumers off rustfs-common heal/scanner shims (#6623) * refactor(ecstore): import heal/scanner contracts crates directly (backlog#1843) * refactor(heal): import heal/scanner contracts crates directly (backlog#1843) * refactor(lifecycle): import heal/scanner contracts crates directly (backlog#1843) * refactor(obs): import heal/scanner contracts crates directly (backlog#1843) * refactor(protos): import heal/scanner contracts crates directly (backlog#1843) * refactor(scanner): import heal/scanner contracts crates directly (backlog#1843) * refactor(rustfs): import heal/scanner contracts crates directly (backlog#1843) --- Cargo.lock | 11 ++++ crates/ecstore/Cargo.toml | 2 + .../lifecycle/bucket_lifecycle_audit.rs | 2 +- .../bucket/lifecycle/bucket_lifecycle_ops.rs | 8 +-- .../src/bucket/lifecycle/replication_sink.rs | 4 +- crates/ecstore/src/bucket/metadata_sys.rs | 2 +- crates/ecstore/src/bucket/quota/checker.rs | 10 ++-- .../ecstore/src/cluster/rpc/peer_s3_client.rs | 2 +- crates/ecstore/src/config/com.rs | 4 +- crates/ecstore/src/core/pools.rs | 2 +- crates/ecstore/src/core/sets.rs | 4 +- .../src/diagnostics/admin_server_info.rs | 2 +- .../src/ecstore_validation_blackbox.rs | 4 +- crates/ecstore/src/layout/set_heal.rs | 2 +- crates/ecstore/src/object_api/types.rs | 2 +- .../ecstore/src/services/metrics_realtime.rs | 53 ++++++++++--------- .../ecstore/src/services/notification_sys.rs | 2 +- .../src/set_disk/core/io_primitives.rs | 37 +++++++------ crates/ecstore/src/set_disk/mod.rs | 22 ++++---- crates/ecstore/src/set_disk/ops/heal.rs | 2 +- crates/ecstore/src/set_disk/ops/multipart.rs | 6 +-- crates/ecstore/src/set_disk/ops/object.rs | 16 +++--- crates/ecstore/src/set_disk/read.rs | 14 +++-- crates/ecstore/src/store/heal.rs | 2 +- crates/ecstore/src/store/init.rs | 4 +- crates/ecstore/src/store/mod.rs | 2 +- crates/ecstore/src/store/rebalance.rs | 4 +- .../tests/ecstore_contract_compat_test.rs | 2 +- crates/heal/Cargo.toml | 1 + crates/heal/src/heal/channel.rs | 10 ++-- crates/heal/src/heal/erasure_healer.rs | 6 +-- crates/heal/src/heal/manager.rs | 4 +- crates/heal/src/heal/manager/tests.rs | 2 +- crates/heal/src/heal/mrf_queue.rs | 4 +- crates/heal/src/heal/storage.rs | 2 +- crates/heal/src/heal/task.rs | 2 +- crates/heal/src/lib.rs | 4 +- .../heal_b5_versioned_regression_test.rs | 2 +- .../tests/heal_b920_subquorum_union_test.rs | 2 +- crates/heal/tests/heal_bug_fixes_test.rs | 8 +-- crates/heal/tests/heal_integration_test.rs | 2 +- crates/lifecycle/Cargo.toml | 1 + crates/lifecycle/src/core.rs | 2 +- crates/lifecycle/src/evaluator.rs | 4 +- crates/lifecycle/src/lib.rs | 2 +- crates/obs/Cargo.toml | 2 + crates/obs/src/metrics/collectors/scanner.rs | 2 +- crates/obs/src/metrics/stats_collector.rs | 16 +++--- crates/protos/Cargo.toml | 1 + crates/protos/src/heal_control.rs | 4 +- crates/scanner/Cargo.toml | 2 + crates/scanner/src/data_usage_define.rs | 2 +- crates/scanner/src/lib.rs | 2 +- crates/scanner/src/remote_scanner/stream.rs | 4 +- crates/scanner/src/scanner.rs | 12 ++--- crates/scanner/src/scanner_folder.rs | 16 +++--- crates/scanner/src/scanner_folder/tests.rs | 8 +-- crates/scanner/src/scanner_io.rs | 6 ++- crates/scanner/src/scanner_io/guards.rs | 6 +-- crates/scanner/src/scanner_io/io_disk.rs | 4 +- crates/scanner/src/sleeper.rs | 2 +- rustfs/Cargo.toml | 2 + rustfs/src/admin/handlers/heal.rs | 44 ++++++++------- rustfs/src/admin/handlers/scanner.rs | 4 +- rustfs/src/admin/storage_api.rs | 2 +- rustfs/src/app/capacity_dirty_scope_test.rs | 2 +- rustfs/src/app/storage_api.rs | 4 +- rustfs/src/cluster_snapshot.rs | 2 +- rustfs/src/startup_runtime_sources.rs | 2 +- rustfs/src/storage/rpc/node_service.rs | 25 +++++---- rustfs/src/storage/rpc/node_service/heal.rs | 4 +- rustfs/src/storage/storage_api.rs | 4 +- 72 files changed, 263 insertions(+), 206 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 45fef954d..7b83156ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9370,6 +9370,7 @@ dependencies = [ "rustfs-extension-schema", "rustfs-filemeta", "rustfs-heal", + "rustfs-heal-contracts", "rustfs-iam", "rustfs-io-core", "rustfs-io-metrics", @@ -9393,6 +9394,7 @@ dependencies = [ "rustfs-s3select-api", "rustfs-s3select-query", "rustfs-scanner", + "rustfs-scanner-contracts", "rustfs-security-governance", "rustfs-signer", "rustfs-storage-api", @@ -9632,6 +9634,7 @@ dependencies = [ "rustfs-data-usage", "rustfs-erasure-codec", "rustfs-filemeta", + "rustfs-heal-contracts", "rustfs-io-metrics", "rustfs-lifecycle", "rustfs-lock", @@ -9645,6 +9648,7 @@ dependencies = [ "rustfs-rio-v2", "rustfs-s3-client", "rustfs-s3-types", + "rustfs-scanner-contracts", "rustfs-signer", "rustfs-storage-api", "rustfs-tls-runtime", @@ -9755,6 +9759,7 @@ dependencies = [ "rustfs-concurrency", "rustfs-config", "rustfs-ecstore", + "rustfs-heal-contracts", "rustfs-lock", "rustfs-madmin", "rustfs-storage-api", @@ -9996,6 +10001,7 @@ dependencies = [ "rustfs-common", "rustfs-config", "rustfs-replication", + "rustfs-scanner-contracts", "rustfs-storage-api", "s3s", "serial_test", @@ -10190,9 +10196,11 @@ dependencies = [ "rustfs-common", "rustfs-config", "rustfs-ecstore", + "rustfs-heal-contracts", "rustfs-iam", "rustfs-io-metrics", "rustfs-notify", + "rustfs-scanner-contracts", "rustfs-security-governance", "rustfs-storage-api", "rustfs-utils", @@ -10317,6 +10325,7 @@ dependencies = [ "rmp-serde", "rustfs-common", "rustfs-config", + "rustfs-heal-contracts", "rustfs-io-metrics", "rustfs-tls-runtime", "rustfs-utils", @@ -10544,8 +10553,10 @@ dependencies = [ "rustfs-data-usage", "rustfs-ecstore", "rustfs-filemeta", + "rustfs-heal-contracts", "rustfs-lock", "rustfs-s3-types", + "rustfs-scanner-contracts", "rustfs-storage-api", "rustfs-utils", "s3s", diff --git a/crates/ecstore/Cargo.toml b/crates/ecstore/Cargo.toml index fb223489c..fc488fd77 100644 --- a/crates/ecstore/Cargo.toml +++ b/crates/ecstore/Cargo.toml @@ -143,6 +143,8 @@ rustfs-config = { workspace = true, features = ["notify", "audit", "server-confi rustfs-concurrency.workspace = true rustfs-credentials = { workspace = true } rustfs-common.workspace = true +rustfs-heal-contracts.workspace = true +rustfs-scanner-contracts.workspace = true rustfs-policy.workspace = true rustfs-protos.workspace = true rustfs-replication.workspace = true diff --git a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_audit.rs b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_audit.rs index cfeb644a8..06ea063a7 100644 --- a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_audit.rs +++ b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_audit.rs @@ -16,8 +16,8 @@ use super::runtime_boundary as runtime_sources; use crate::bucket::lifecycle::lifecycle; use crate::object_api::ObjectInfo; use crate::services::event_notification::{EventArgs, send_event}; -use rustfs_common::metrics::IlmAction; use rustfs_s3_types::EventName; +use rustfs_scanner_contracts::metrics::IlmAction; const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]"; const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]"; diff --git a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs index 5e13ba918..c3c1f67f0 100644 --- a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs +++ b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs @@ -72,9 +72,6 @@ use crate::store::ECStore; use async_channel::{Receiver as A_Receiver, Sender as A_Sender, bounded}; use http::HeaderMap; use rand::RngExt as _; -use rustfs_common::metrics::{ - IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics, -}; use rustfs_config::{ DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_QUEUE_SEND_TIMEOUT_MS, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX, DEFAULT_TRANSITION_WORKERS_CAP, ENV_MAX_EXPIRY_WORKERS, ENV_TRANSITION_QUEUE_CAPACITY, ENV_TRANSITION_QUEUE_SEND_TIMEOUT_MS, @@ -84,6 +81,9 @@ use rustfs_data_usage::TierStats; use rustfs_filemeta::{ FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info, is_restored_object_on_disk, }; +use rustfs_scanner_contracts::metrics::{ + IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics, +}; use rustfs_utils::{ get_env_i64, get_env_usize, path::encode_dir_object, @@ -5460,11 +5460,11 @@ mod tests { use futures::FutureExt; #[cfg(feature = "test-util")] use http::HeaderMap; - use rustfs_common::metrics::{IlmAction, global_metrics}; use rustfs_config::ENV_MAX_EXPIRY_WORKERS; use rustfs_config::ENV_TRANSITION_WORKERS_ABSOLUTE_MAX; use rustfs_data_usage::TierStats; use rustfs_filemeta::{FileInfo, FileMeta}; + use rustfs_scanner_contracts::metrics::{IlmAction, global_metrics}; use s3s::dto::{ BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, OutputLocation, RestoreRequest, diff --git a/crates/ecstore/src/bucket/lifecycle/replication_sink.rs b/crates/ecstore/src/bucket/lifecycle/replication_sink.rs index 32cd6b57a..70b700883 100644 --- a/crates/ecstore/src/bucket/lifecycle/replication_sink.rs +++ b/crates/ecstore/src/bucket/lifecycle/replication_sink.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use rustfs_common::metrics::IlmAction; +use rustfs_scanner_contracts::metrics::IlmAction; use crate::bucket::lifecycle::lifecycle::ObjectOpts; use crate::bucket::replication::ReplicationLifecycleBridge; @@ -77,7 +77,7 @@ mod tests { use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge}; use crate::object_api::{ObjectInfo, ObjectOptions}; use crate::storage_api_contracts::object::ObjectToDelete; - use rustfs_common::metrics::IlmAction; + use rustfs_scanner_contracts::metrics::IlmAction; use s3s::dto::{ BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, diff --git a/crates/ecstore/src/bucket/metadata_sys.rs b/crates/ecstore/src/bucket/metadata_sys.rs index 2cbb0eff4..ba166d7f1 100644 --- a/crates/ecstore/src/bucket/metadata_sys.rs +++ b/crates/ecstore/src/bucket/metadata_sys.rs @@ -27,7 +27,7 @@ use crate::storage_api_contracts::heal::HealOperations as _; use crate::storage_api_contracts::namespace::NamespaceLocking as _; use crate::store::{ECStore, await_bucket_namespace_operation}; use futures::future::join_all; -use rustfs_common::heal_channel::HealOpts; +use rustfs_heal_contracts::heal_channel::HealOpts; use rustfs_policy::policy::BucketPolicy; use s3s::dto::ReplicationConfiguration; use s3s::dto::{ diff --git a/crates/ecstore/src/bucket/quota/checker.rs b/crates/ecstore/src/bucket/quota/checker.rs index 0d71095e3..44a113652 100644 --- a/crates/ecstore/src/bucket/quota/checker.rs +++ b/crates/ecstore/src/bucket/quota/checker.rs @@ -15,8 +15,8 @@ use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation}; use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation}; use crate::data_usage::get_bucket_usage_memory; -use rustfs_common::metrics::Metric; use rustfs_config::QUOTA_CONFIG_FILE; +use rustfs_scanner_contracts::metrics::Metric; use std::sync::Arc; use std::time::Instant; use time::OffsetDateTime; @@ -120,9 +120,9 @@ impl QuotaChecker { let duration = start_time.elapsed(); // inc_time is now a plain fn (not async) — no .await needed. - rustfs_common::metrics::Metrics::inc_time(Metric::QuotaCheck, duration); + rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaCheck, duration); if !allowed { - rustfs_common::metrics::Metrics::inc_time(Metric::QuotaViolation, duration); + rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaViolation, duration); } Ok(result) @@ -185,7 +185,7 @@ impl QuotaChecker { .await .map_err(QuotaError::StorageError)?; - rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed()); + rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed()); Ok(updated_at) } @@ -206,7 +206,7 @@ impl QuotaChecker { } .map_err(QuotaError::StorageError)?; - rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed()); + rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed()); Ok(updated_at) } diff --git a/crates/ecstore/src/cluster/rpc/peer_s3_client.rs b/crates/ecstore/src/cluster/rpc/peer_s3_client.rs index cbd944fb0..8e5ebcecc 100644 --- a/crates/ecstore/src/cluster/rpc/peer_s3_client.rs +++ b/crates/ecstore/src/cluster/rpc/peer_s3_client.rs @@ -36,7 +36,7 @@ use crate::{ }; use async_trait::async_trait; use futures::future::join_all; -use rustfs_common::heal_channel::{DriveState, HealItemType, HealOpts, RUSTFS_RESERVED_BUCKET}; +use rustfs_heal_contracts::heal_channel::{DriveState, HealItemType, HealOpts, RUSTFS_RESERVED_BUCKET}; use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem}; use rustfs_protos::proto_gen::node_service::node_service_client::NodeServiceClient; use rustfs_protos::proto_gen::node_service::{ diff --git a/crates/ecstore/src/config/com.rs b/crates/ecstore/src/config/com.rs index 98409f513..16f9f4c20 100644 --- a/crates/ecstore/src/config/com.rs +++ b/crates/ecstore/src/config/com.rs @@ -27,7 +27,6 @@ use crate::storage_api_contracts::{ }; use crate::store::ECStore; use http::HeaderMap; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; use rustfs_config::audit::{ AUDIT_AMQP_KEYS, AUDIT_AMQP_SUB_SYS, AUDIT_KAFKA_KEYS, AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_MYSQL_KEYS, AUDIT_MYSQL_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS, AUDIT_POSTGRES_KEYS, AUDIT_POSTGRES_SUB_SYS, @@ -46,6 +45,7 @@ use rustfs_config::{ SCANNER_SUB_SYS, }; use rustfs_filemeta::FileInfo; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; @@ -4710,7 +4710,7 @@ mod tests { decode_persisted_server_config, fallback_server_config_after_corruption, is_server_config_corrupt_error, read_config_without_migrate_with_recovery, replace_server_config_decrypt_fn_for_test, }; - use rustfs_common::heal_channel::HealOpts; + use rustfs_heal_contracts::heal_channel::HealOpts; use std::sync::Mutex; /// Bytes mirroring issue #4156: a bitrot-corrupted `config.json` whose diff --git a/crates/ecstore/src/core/pools.rs b/crates/ecstore/src/core/pools.rs index 0f188db18..6570db998 100644 --- a/crates/ecstore/src/core/pools.rs +++ b/crates/ecstore/src/core/pools.rs @@ -70,8 +70,8 @@ use http::HeaderMap; #[cfg(test)] use rmp_serde::Deserializer; use rmp_serde::Serializer; -use rustfs_common::heal_channel::HealOpts; use rustfs_filemeta::{FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams}; +use rustfs_heal_contracts::heal_channel::HealOpts; use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum}; use rustfs_utils::path::{encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path}; use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration}; diff --git a/crates/ecstore/src/core/sets.rs b/crates/ecstore/src/core/sets.rs index 648b7cf0d..0a814865f 100644 --- a/crates/ecstore/src/core/sets.rs +++ b/crates/ecstore/src/core/sets.rs @@ -46,9 +46,9 @@ use futures::{ stream::{FuturesUnordered, StreamExt}, }; use http::HeaderMap; -use rustfs_common::heal_channel::HealOpts; -use rustfs_common::heal_channel::{DriveState, HealItemType}; use rustfs_filemeta::FileInfo; +use rustfs_heal_contracts::heal_channel::HealOpts; +use rustfs_heal_contracts::heal_channel::{DriveState, HealItemType}; use rustfs_lock::NamespaceLockWrapper; use rustfs_madmin::heal_commands::HealResultItem; use rustfs_utils::{crc_hash, path::path_join_buf, sip_hash}; diff --git a/crates/ecstore/src/diagnostics/admin_server_info.rs b/crates/ecstore/src/diagnostics/admin_server_info.rs index 017b462a2..3bbf7cf05 100644 --- a/crates/ecstore/src/diagnostics/admin_server_info.rs +++ b/crates/ecstore/src/diagnostics/admin_server_info.rs @@ -26,7 +26,7 @@ use crate::{ use crate::data_usage::load_data_usage_cache; use crate::storage_api_contracts::admin::StorageAdminApi; use crate::storage_api_contracts::bucket::BucketOptions; -use rustfs_common::heal_channel::DriveState; +use rustfs_heal_contracts::heal_channel::DriveState; use rustfs_madmin::{ BackendDisks, Disk, ErasureSetInfo, ITEM_INITIALIZING, ITEM_OFFLINE, ITEM_ONLINE, ITEM_UNKNOWN, InfoMessage, MemStats, ServerProperties, diff --git a/crates/ecstore/src/ecstore_validation_blackbox.rs b/crates/ecstore/src/ecstore_validation_blackbox.rs index 30e309e4f..4db7e5104 100644 --- a/crates/ecstore/src/ecstore_validation_blackbox.rs +++ b/crates/ecstore/src/ecstore_validation_blackbox.rs @@ -208,7 +208,7 @@ async fn blackbox_get_restores_body_after_one_shard_file_is_removed() { // Serialized: forces the reader-setup strategy through a process-global env var. #[serial_test::serial] async fn blackbox_heal_requests_preserve_repair_scope() { - use rustfs_common::heal_channel::{ + use rustfs_heal_contracts::heal_channel::{ HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest, HealRequestSource, }; @@ -238,7 +238,7 @@ async fn blackbox_heal_requests_preserve_repair_scope() { // (failing their submitter, which releases their dedup reservation), and // fail fast once the receiver drops at test end. Tests that must observe a // deterministic channel state serialize under the same serial key. - let mut heal_rx = rustfs_common::heal_channel::init_heal_channel() + let mut heal_rx = rustfs_heal_contracts::heal_channel::init_heal_channel() .expect("this must be the only ecstore test that owns the heal channel receiver"); // Ordinary PUTs use the same admission channel as read repair. A single diff --git a/crates/ecstore/src/layout/set_heal.rs b/crates/ecstore/src/layout/set_heal.rs index 702016f24..e32beb84f 100644 --- a/crates/ecstore/src/layout/set_heal.rs +++ b/crates/ecstore/src/layout/set_heal.rs @@ -14,7 +14,7 @@ use crate::disk::{DiskInfo, error::DiskError}; use crate::layout::{endpoints::Endpoints, format::FormatV3}; -use rustfs_common::heal_channel::DriveState; +use rustfs_heal_contracts::heal_channel::DriveState; use rustfs_madmin::heal_commands::HealDriveInfo; pub(crate) fn formats_to_drives_info( diff --git a/crates/ecstore/src/object_api/types.rs b/crates/ecstore/src/object_api/types.rs index d91d96a0d..280dd10cc 100644 --- a/crates/ecstore/src/object_api/types.rs +++ b/crates/ecstore/src/object_api/types.rs @@ -281,7 +281,7 @@ pub struct QuotaAdmission { pub struct LifecycleDeleteAllRequest { pub(crate) version_id: Option, pub(crate) delete_marker: bool, - pub(crate) action: rustfs_common::metrics::IlmAction, + pub(crate) action: rustfs_scanner_contracts::metrics::IlmAction, pub(crate) rule_id: String, pub(crate) phase: LifecycleDeleteAllPhase, } diff --git a/crates/ecstore/src/services/metrics_realtime.rs b/crates/ecstore/src/services/metrics_realtime.rs index 290504fcb..c6056414d 100644 --- a/crates/ecstore/src/services/metrics_realtime.rs +++ b/crates/ecstore/src/services/metrics_realtime.rs @@ -18,7 +18,7 @@ use crate::storage_api_contracts::admin::StorageAdminApi; #[cfg(test)] use chrono::Utc; use jiff::Timestamp; -use rustfs_common::{heal_channel::DriveState, metrics::global_metrics}; +use rustfs_heal_contracts::heal_channel::DriveState; use rustfs_io_metrics::internode_metrics::global_internode_metrics; use rustfs_madmin::metrics::{ DiskIOStats, DiskMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, RealtimeMetrics, @@ -32,6 +32,7 @@ use rustfs_madmin::metrics::{ ScannerSourceCycleSnapshot as MadminScannerSourceCycleSnapshot, ScannerSourceWorkSnapshot as MadminScannerSourceWorkSnapshot, ScannerUsageFreshnessSnapshot as MadminScannerUsageFreshnessSnapshot, TimedAction as MadminTimedAction, }; +use rustfs_scanner_contracts::metrics::global_metrics; use rustfs_utils::os::get_drive_stats; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -81,7 +82,7 @@ fn unix_millis_to_jiff_timestamp(millis: u64, fallback: Timestamp) -> Timestamp } } -fn to_madmin_scanner_metrics(metrics: rustfs_common::metrics::ScannerMetricsReport) -> MadminScannerMetrics { +fn to_madmin_scanner_metrics(metrics: rustfs_scanner_contracts::metrics::ScannerMetricsReport) -> MadminScannerMetrics { MadminScannerMetrics { collected_at: metrics.collected_at, current_cycle: metrics.current_cycle, @@ -563,8 +564,8 @@ async fn collect_local_disks_metrics(disks: &HashSet) -> HashMap Option { disks.push(rustfs_madmin::Disk { endpoint: ep.to_string(), state: if online { - rustfs_common::heal_channel::DriveState::Ok.to_string() + rustfs_heal_contracts::heal_channel::DriveState::Ok.to_string() } else { ItemState::Offline.to_string().to_owned() }, diff --git a/crates/ecstore/src/set_disk/core/io_primitives.rs b/crates/ecstore/src/set_disk/core/io_primitives.rs index 2cc3935a6..6d2c6cf6c 100644 --- a/crates/ecstore/src/set_disk/core/io_primitives.rs +++ b/crates/ecstore/src/set_disk/core/io_primitives.rs @@ -1364,7 +1364,7 @@ pub(in crate::set_disk) enum ReadRepairAdmissionOutcome { pub(in crate::set_disk) type ReadRepairAdmissionFuture = Pin + Send>>; pub(in crate::set_disk) type ReadRepairAdmissionSubmitter = - fn(rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture; + fn(rustfs_heal_contracts::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture; pub(in crate::set_disk) struct ReadRepairHealSubmission<'a> { pub(in crate::set_disk) bucket: &'a str, @@ -1385,7 +1385,7 @@ pub(in crate::set_disk) struct ReadRepairHealSubmission<'a> { } pub(in crate::set_disk) fn send_read_repair_heal_request( - request: rustfs_common::heal_channel::HealChannelRequest, + request: rustfs_heal_contracts::heal_channel::HealChannelRequest, ) -> ReadRepairAdmissionFuture { Box::pin(async { match send_heal_request_with_admission(request).await { @@ -1453,7 +1453,7 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter( let _ = rustfs_common::mrf_channel::try_send_mrf_intent_typed(kind, bucket, object, version_uuid, Some(scope)); } - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( bucket.to_string(), Some(object.to_string()), false, @@ -3580,7 +3580,7 @@ pub(in crate::set_disk) async fn finish_rename_tail_heal< tail_drain: tokio::task::JoinHandle>, guard_release: tokio::sync::oneshot::Receiver, guards: Guards, - request: rustfs_common::heal_channel::HealChannelRequest, + request: rustfs_heal_contracts::heal_channel::HealChannelRequest, finalize: Finalize, cleanup: Cleanup, submit: Submit, @@ -3590,7 +3590,7 @@ pub(in crate::set_disk) async fn finish_rename_tail_heal< FinalizeFuture: Future + Send, Cleanup: FnOnce(Guards, Vec) -> CleanupFuture + Send, CleanupFuture: Future + Send, - Submit: FnOnce(rustfs_common::heal_channel::HealChannelRequest) -> SubmitFuture + Send, + Submit: FnOnce(rustfs_heal_contracts::heal_channel::HealChannelRequest) -> SubmitFuture + Send, SubmitFuture: Future + Send, { let (needs_heal, tail_cleanup, tail_complete) = match tail_drain.await { @@ -4939,16 +4939,17 @@ impl SetDisks { // reclaim_orphan_data_dirs. Reuses the existing heal channel, which // deduplicates and back-pressures via admission; failures only drop // the return value (same shape as multipart's existing heal enqueue). - let _ = - rustfs_common::heal_channel::send_heal_request(rustfs_common::heal_channel::create_heal_request_with_options( + let _ = rustfs_heal_contracts::heal_channel::send_heal_request( + rustfs_heal_contracts::heal_channel::create_heal_request_with_options( bucket.to_string(), Some(object.to_string()), false, - Some(rustfs_common::heal_channel::HealChannelPriority::Normal), + Some(rustfs_heal_contracts::heal_channel::HealChannelPriority::Normal), Some(self.pool_index), Some(self.set_index), - )) - .await; + ), + ) + .await; } } @@ -6800,18 +6801,24 @@ mod tests { write_raw_file_meta_unchecked(disk, bucket, object, metadata).await; } - fn failed_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn failed_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { Box::pin(async { ReadRepairAdmissionOutcome::Failed("injected submit failure".to_string()) }) } - fn accepted_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn accepted_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Accepted) }) } - fn dropped_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn dropped_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Dropped( - rustfs_common::heal_channel::HealAdmissionDropReason::PolicyDropped, + rustfs_heal_contracts::heal_channel::HealAdmissionDropReason::PolicyDropped, )) }) } @@ -8853,7 +8860,7 @@ mod tests { tail_drain, released, (), - rustfs_common::heal_channel::HealChannelRequest::default(), + rustfs_heal_contracts::heal_channel::HealChannelRequest::default(), move || async move { *finalize_captured.lock().expect("finalize recorder should not poison") = true; }, diff --git a/crates/ecstore/src/set_disk/mod.rs b/crates/ecstore/src/set_disk/mod.rs index e26fcdd40..0d0f1fb57 100644 --- a/crates/ecstore/src/set_disk/mod.rs +++ b/crates/ecstore/src/set_disk/mod.rs @@ -130,15 +130,15 @@ use http::HeaderMap; use md5::{Digest as Md5Digest, Md5}; use rand::{Rng, seq::SliceRandom}; use regex::Regex; -use rustfs_common::heal_channel::{ - DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode, - send_heal_disk, send_heal_request_with_admission, -}; use rustfs_config::MI_B; use rustfs_filemeta::{ FileInfo, FileMeta, FileMetaShallowVersion, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams, ObjectPartInfo, RawFileInfo, file_info_from_raw, merge_file_meta_versions, }; +use rustfs_heal_contracts::heal_channel::{ + DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode, + send_heal_disk, send_heal_request_with_admission, +}; use rustfs_io_metrics::{ record_object_lock_diag_acquire_duration, record_object_lock_diag_enabled, record_object_lock_diag_hold_duration, record_object_lock_diag_slow_acquire, record_object_lock_diag_slow_hold, @@ -3111,8 +3111,9 @@ pub struct SetDisks { #[cfg(test)] storage_class_config_override: Arc>>>, #[cfg(test)] - rename_tail_heal_capture: - Arc>>>, + rename_tail_heal_capture: Arc< + std::sync::Mutex>>, + >, } // DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones @@ -3388,7 +3389,10 @@ impl DiskHealthEntry { } impl SetDisks { - pub(in crate::set_disk) async fn submit_rename_tail_heal(&self, request: rustfs_common::heal_channel::HealChannelRequest) { + pub(in crate::set_disk) async fn submit_rename_tail_heal( + &self, + request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) { #[cfg(test)] { let capture = self @@ -3402,13 +3406,13 @@ impl SetDisks { } } - let _ = rustfs_common::heal_channel::send_heal_request(request).await; + let _ = rustfs_heal_contracts::heal_channel::send_heal_request(request).await; } #[cfg(test)] pub(in crate::set_disk) fn capture_test_rename_tail_heals( &self, - ) -> tokio::sync::mpsc::UnboundedReceiver { + ) -> tokio::sync::mpsc::UnboundedReceiver { let (capture, requests) = tokio::sync::mpsc::unbounded_channel(); let mut slot = self .rename_tail_heal_capture diff --git a/crates/ecstore/src/set_disk/ops/heal.rs b/crates/ecstore/src/set_disk/ops/heal.rs index adcea3a60..d1a5c9281 100644 --- a/crates/ecstore/src/set_disk/ops/heal.rs +++ b/crates/ecstore/src/set_disk/ops/heal.rs @@ -2386,8 +2386,8 @@ mod heal_result_report_tests { store::init_format::{load_format_erasure, save_format_file}, }; use bytes::Bytes; - use rustfs_common::heal_channel::{DriveState, HealOpts, HealScanMode}; use rustfs_filemeta::{BLOCK_SIZE_V2, FileInfo, ObjectPartInfo, TRANSITION_COMPLETE}; + use rustfs_heal_contracts::heal_channel::{DriveState, HealOpts, HealScanMode}; use std::sync::{Arc, Mutex}; use tempfile::TempDir; use time::OffsetDateTime; diff --git a/crates/ecstore/src/set_disk/ops/multipart.rs b/crates/ecstore/src/set_disk/ops/multipart.rs index cd9b83623..7aafc24d2 100644 --- a/crates/ecstore/src/set_disk/ops/multipart.rs +++ b/crates/ecstore/src/set_disk/ops/multipart.rs @@ -2749,7 +2749,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks { needs_immediate_heal = rename_commit.needs_immediate_heal(); if let Some(rename_tail_drain) = rename_commit.tail_drain.take() { tail_owns_staging_cleanup = true; - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( commit_bucket.clone(), Some(commit_object.clone()), false, @@ -2846,7 +2846,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks { let committed_file_info = rename_commit.committed_file_info; if needs_immediate_heal { - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( commit_bucket.clone(), Some(commit_object.clone()), false, @@ -2859,7 +2859,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks { .or_else(|| commit_version_suspended.then(Uuid::nil)) .map(|version_id| version_id.to_string()); tokio::spawn(async move { - let _ = rustfs_common::heal_channel::send_heal_request(request).await; + let _ = rustfs_heal_contracts::heal_channel::send_heal_request(request).await; }); } diff --git a/crates/ecstore/src/set_disk/ops/object.rs b/crates/ecstore/src/set_disk/ops/object.rs index 87f46e868..8f550be82 100644 --- a/crates/ecstore/src/set_disk/ops/object.rs +++ b/crates/ecstore/src/set_disk/ops/object.rs @@ -352,7 +352,7 @@ mod lifecycle_delete_all_plan_tests { crate::object_api::LifecycleDeleteAllRequest { version_id: Some(version_id), delete_marker: true, - action: rustfs_common::metrics::IlmAction::DelMarkerDeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DelMarkerDeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, } @@ -545,7 +545,7 @@ mod lifecycle_delete_all_plan_tests { let request = crate::object_api::LifecycleDeleteAllRequest { version_id: None, delete_marker: false, - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, }; @@ -3242,7 +3242,7 @@ impl SetDisks { needs_immediate_heal = rename_commit.needs_immediate_heal(); if let Some(rename_tail_drain) = rename_commit.tail_drain.take() { tail_owns_tmp_cleanup = true; - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( commit_bucket.clone(), Some(commit_object.clone()), false, @@ -3350,7 +3350,7 @@ impl SetDisks { let mut fi = rename_commit.committed_file_info; if needs_immediate_heal { - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( commit_bucket.clone(), Some(commit_object.clone()), false, @@ -3362,7 +3362,7 @@ impl SetDisks { .or_else(|| commit_version_suspended.then(Uuid::nil)) .map(|version_id| version_id.to_string()); tokio::spawn(async move { - let _ = rustfs_common::heal_channel::send_heal_request(request).await; + let _ = rustfs_heal_contracts::heal_channel::send_heal_request(request).await; }); } @@ -7076,7 +7076,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks { Some(scope), ); } - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( bucket.to_string(), Some(object.to_string()), false, @@ -7085,7 +7085,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks { Some(self.set_index), ); request.object_version_id = (!version_id.is_empty()).then(|| version_id.to_string()); - if let Err(e) = rustfs_common::heal_channel::send_heal_request(request).await { + if let Err(e) = rustfs_heal_contracts::heal_channel::send_heal_request(request).await { warn!( bucket, object, @@ -16546,7 +16546,7 @@ mod delete_objects_lock_gating_tests { lifecycle_delete_all: Some(crate::object_api::LifecycleDeleteAllRequest { version_id: Some(trigger_version_id), delete_marker: false, - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::History, }), diff --git a/crates/ecstore/src/set_disk/read.rs b/crates/ecstore/src/set_disk/read.rs index 15e7e4e2a..569ba57c0 100644 --- a/crates/ecstore/src/set_disk/read.rs +++ b/crates/ecstore/src/set_disk/read.rs @@ -1896,7 +1896,7 @@ fn is_get_object_metadata_cache_request_eligible(bucket: &str, opts: &ObjectOpti #[cfg(test)] mod metadata_cache_tests { use super::*; - use rustfs_common::heal_channel::HealAdmissionDropReason; + use rustfs_heal_contracts::heal_channel::HealAdmissionDropReason; use serial_test::serial; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -1974,7 +1974,9 @@ mod metadata_cache_tests { } } - fn slow_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn slow_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { SLOW_READ_REPAIR_SUBMITTER_CALLS.fetch_add(1, Ordering::Relaxed); Box::pin(async { tokio::time::sleep(Duration::from_millis(250)).await; @@ -1982,14 +1984,18 @@ mod metadata_cache_tests { }) } - fn dropped_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn dropped_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { DROPPED_READ_REPAIR_SUBMITTER_CALLS.fetch_add(1, Ordering::Relaxed); Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped)) }) } - fn capture_read_repair_submitter(request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn capture_read_repair_submitter( + request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { CAPTURED_READ_REPAIR_CALLS.fetch_add(1, Ordering::Relaxed); *CAPTURED_READ_REPAIR_PRIORITY.lock().expect("capture mutex poisoned") = Some(request.priority); Box::pin(async { diff --git a/crates/ecstore/src/store/heal.rs b/crates/ecstore/src/store/heal.rs index 206218492..9159d38b3 100644 --- a/crates/ecstore/src/store/heal.rs +++ b/crates/ecstore/src/store/heal.rs @@ -608,7 +608,7 @@ mod tests { use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations}; use crate::store::init_format::{load_format_erasure, save_format_file}; use crate::store::init_local_disks_with_instance_ctx; - use rustfs_common::heal_channel::DriveState; + use rustfs_heal_contracts::heal_channel::DriveState; use tokio_util::sync::CancellationToken; #[derive(Debug)] diff --git a/crates/ecstore/src/store/init.rs b/crates/ecstore/src/store/init.rs index 65292980a..21a9bb454 100644 --- a/crates/ecstore/src/store/init.rs +++ b/crates/ecstore/src/store/init.rs @@ -9537,7 +9537,7 @@ mod tests { .expect("unknown transition metadata should be written"); } let lifecycle_event = crate::bucket::lifecycle::lifecycle::Event { - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "delete-all-versions".to_string(), ..Default::default() }; @@ -9709,7 +9709,7 @@ mod tests { lifecycle_delete_all: Some(crate::object_api::LifecycleDeleteAllRequest { version_id: original.version_id, delete_marker: false, - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, }), diff --git a/crates/ecstore/src/store/mod.rs b/crates/ecstore/src/store/mod.rs index 6e8ba2e9b..2f3d87c7a 100644 --- a/crates/ecstore/src/store/mod.rs +++ b/crates/ecstore/src/store/mod.rs @@ -64,9 +64,9 @@ use futures::future::join_all; use http::HeaderMap; use lazy_static::lazy_static; use rand::RngExt as _; -use rustfs_common::heal_channel::{HealItemType, HealOpts}; use rustfs_config::server_config::Config; use rustfs_filemeta::FileInfo; +use rustfs_heal_contracts::heal_channel::{HealItemType, HealOpts}; use rustfs_lock::{LocalClient, LockClient, NamespaceLockWrapper}; use rustfs_madmin::heal_commands::HealResultItem; use rustfs_utils::path::{decode_dir_object, encode_dir_object, path_join_buf}; diff --git a/crates/ecstore/src/store/rebalance.rs b/crates/ecstore/src/store/rebalance.rs index 4521e087f..ddcd918ac 100644 --- a/crates/ecstore/src/store/rebalance.rs +++ b/crates/ecstore/src/store/rebalance.rs @@ -1049,7 +1049,7 @@ mod tests { lifecycle_delete_all: Some(crate::object_api::LifecycleDeleteAllRequest { version_id: Some(trigger_id), delete_marker: false, - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, }), @@ -1219,7 +1219,7 @@ mod tests { lifecycle_delete_all: Some(crate::object_api::LifecycleDeleteAllRequest { version_id: Some(marker_id), delete_marker: true, - action: rustfs_common::metrics::IlmAction::DelMarkerDeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DelMarkerDeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, }), diff --git a/crates/ecstore/tests/ecstore_contract_compat_test.rs b/crates/ecstore/tests/ecstore_contract_compat_test.rs index d1d303f7a..ca20d42e5 100644 --- a/crates/ecstore/tests/ecstore_contract_compat_test.rs +++ b/crates/ecstore/tests/ecstore_contract_compat_test.rs @@ -14,8 +14,8 @@ mod storage_api; -use rustfs_common::heal_channel::HealOpts; use rustfs_filemeta::FileInfo; +use rustfs_heal_contracts::heal_channel::HealOpts; use rustfs_lock::NamespaceLockWrapper; use rustfs_madmin::heal_commands::HealResultItem; use storage_api::contract_compat::{ diff --git a/crates/heal/Cargo.toml b/crates/heal/Cargo.toml index ec2336fd7..ddedc83d4 100644 --- a/crates/heal/Cargo.toml +++ b/crates/heal/Cargo.toml @@ -77,6 +77,7 @@ rustfs-ecstore = { workspace = true } rustfs-lock = { workspace = true } rustfs-storage-api = { workspace = true } rustfs-common = { workspace = true } +rustfs-heal-contracts = { workspace = true } rustfs-madmin = { workspace = true } rustfs-utils = { workspace = true } tokio = { workspace = true, features = ["sync", "io-util", "time", "macros", "fs", "rt-multi-thread"] } diff --git a/crates/heal/src/heal/channel.rs b/crates/heal/src/heal/channel.rs index e00435100..7e2d38de0 100644 --- a/crates/heal/src/heal/channel.rs +++ b/crates/heal/src/heal/channel.rs @@ -19,7 +19,7 @@ use crate::heal::{ utils, }; use crate::{Error, Result}; -use rustfs_common::heal_channel::{ +use rustfs_heal_contracts::heal_channel::{ HealAdmissionReceipt, HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest, HealChannelResponse, HealReceiptCommand, HealReceiptReceiver, HealRequestSource, HealScanMode, publish_heal_response, }; @@ -763,7 +763,7 @@ mod tests { use super::*; use crate::heal::manager::HealConfig; use crate::heal::storage::{HealObjectInfo, HealStorageAPI}; - use rustfs_common::heal_channel::{ + use rustfs_heal_contracts::heal_channel::{ HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest, HealRequestSource, HealScanMode, }; use std::sync::Arc; @@ -793,14 +793,14 @@ mod tests { _bucket: &str, _object: &str, _version_id: Option<&str>, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> crate::Result<(rustfs_madmin::heal_commands::HealResultItem, Option)> { Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None)) } async fn heal_bucket( &self, _bucket: &str, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> crate::Result { Ok(rustfs_madmin::heal_commands::HealResultItem::default()) } @@ -1368,7 +1368,7 @@ mod tests { source: HealRequestSource::Admin, ..Default::default() }; - let mut responses = rustfs_common::heal_channel::subscribe_heal_responses(); + let mut responses = rustfs_heal_contracts::heal_channel::subscribe_heal_responses(); let (tx, rx) = oneshot::channel(); processor diff --git a/crates/heal/src/heal/erasure_healer.rs b/crates/heal/src/heal/erasure_healer.rs index 92a245c92..4d1e0b7c5 100644 --- a/crates/heal/src/heal/erasure_healer.rs +++ b/crates/heal/src/heal/erasure_healer.rs @@ -24,7 +24,7 @@ use crate::heal::{ use crate::{Error, Result}; use futures::{StreamExt, stream::FuturesUnordered}; use metrics::{counter, gauge}; -use rustfs_common::heal_channel::{HealOpts, HealRequestSource, HealScanMode}; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource, HealScanMode}; use rustfs_madmin::heal_commands::HealResultItem; use std::sync::{ Arc, @@ -1346,7 +1346,7 @@ impl ErasureSetHealer { #[cfg(test)] mod tests { use super::{ErasureSetHealer, PageConcurrencyGuard}; - use rustfs_common::heal_channel::{HealRequestSource, HealScanMode}; + use rustfs_heal_contracts::heal_channel::{HealRequestSource, HealScanMode}; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -1523,7 +1523,7 @@ mod resume_loop_tests { BUCKET_META_PREFIX, DiskOption, DiskStore, EcstoreError, Endpoint, HealDiskExt as _, RUSTFS_META_BUCKET, new_disk, }; use crate::{Error, Result}; - use rustfs_common::heal_channel::{HealOpts, HealRequestSource}; + use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource}; use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem, Infos}; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/heal/src/heal/manager.rs b/crates/heal/src/heal/manager.rs index b6016c9e4..cb159772a 100644 --- a/crates/heal/src/heal/manager.rs +++ b/crates/heal/src/heal/manager.rs @@ -20,8 +20,10 @@ use crate::heal::{ }; use crate::{Error, Result}; use metrics::{counter, gauge}; -use rustfs_common::heal_channel::{HealAdmissionDropReason, HealAdmissionReceipt, HealAdmissionResult, HealRequestSource}; use rustfs_concurrency::{AdmissionState, WorkloadAdmissionSnapshotProvider, WorkloadClass}; +use rustfs_heal_contracts::heal_channel::{ + HealAdmissionDropReason, HealAdmissionReceipt, HealAdmissionResult, HealRequestSource, +}; use rustfs_madmin::heal_commands::HealResultItem; #[cfg(test)] use std::sync::LazyLock; diff --git a/crates/heal/src/heal/manager/tests.rs b/crates/heal/src/heal/manager/tests.rs index 585b7a4e7..37a100f4b 100644 --- a/crates/heal/src/heal/manager/tests.rs +++ b/crates/heal/src/heal/manager/tests.rs @@ -17,8 +17,8 @@ use crate::heal::EcstoreError; use crate::heal::resume::{CheckpointManager, ReplacementTargetIdentity}; use crate::heal::storage::{HealObjectInfo, HealStorageAPI}; use crate::heal::task::{BatchHealFailure, HealOptions, HealPriority, HealRequest, HealTask, HealType}; -use rustfs_common::heal_channel::{HealOpts, HealRequestSource}; use rustfs_concurrency::{WorkloadAdmissionRegistrySnapshot, WorkloadAdmissionSnapshot}; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource}; use rustfs_madmin::heal_commands::HealResultItem; use std::sync::Mutex as StdMutex; use tempfile::TempDir; diff --git a/crates/heal/src/heal/mrf_queue.rs b/crates/heal/src/heal/mrf_queue.rs index 5cbe8e79b..026653d03 100644 --- a/crates/heal/src/heal/mrf_queue.rs +++ b/crates/heal/src/heal/mrf_queue.rs @@ -35,8 +35,8 @@ use super::{DiskStore, HealDiskExt as _, local_disk_map_read}; use crate::heal::manager::{HealManager, MrfRepairNoticeTarget}; use metrics::{counter, gauge}; -use rustfs_common::heal_channel::{HealAdmissionDropReason, HealAdmissionResult}; use rustfs_common::mrf_channel::{MRF_MAX_ATTEMPTS, MrfIntent}; +use rustfs_heal_contracts::heal_channel::{HealAdmissionDropReason, HealAdmissionResult}; use std::collections::{HashSet, VecDeque}; use std::sync::Arc; use std::time::Duration; @@ -483,7 +483,7 @@ pub(crate) fn build_heal_request(intent: &MrfIntent) -> HealRequest { options.set_index = usize::try_from(scope.set_index).ok(); } let mut request = HealRequest::new(heal_type, options, priority); - request.source = rustfs_common::heal_channel::HealRequestSource::Mrf; + request.source = rustfs_heal_contracts::heal_channel::HealRequestSource::Mrf; request } diff --git a/crates/heal/src/heal/storage.rs b/crates/heal/src/heal/storage.rs index 8c2c5fc2f..f84a75a60 100644 --- a/crates/heal/src/heal/storage.rs +++ b/crates/heal/src/heal/storage.rs @@ -16,7 +16,7 @@ use crate::{Error, Result}; use async_trait::async_trait; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use rustfs_madmin::heal_commands::HealResultItem; use serde::{Deserialize, Serialize}; use std::sync::Arc; diff --git a/crates/heal/src/heal/task.rs b/crates/heal/src/heal/task.rs index c825869fe..ffd2ac618 100644 --- a/crates/heal/src/heal/task.rs +++ b/crates/heal/src/heal/task.rs @@ -23,8 +23,8 @@ use crate::heal::{ }; use crate::{Error, Result}; use metrics::{counter, histogram}; -use rustfs_common::heal_channel::{HealOpts, HealRequestSource, HealScanMode}; use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit}; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource, HealScanMode}; use rustfs_madmin::heal_commands::HealResultItem; use rustfs_utils::path::SLASH_SEPARATOR; use serde::{Deserialize, Serialize}; diff --git a/crates/heal/src/lib.rs b/crates/heal/src/lib.rs index 7b91df47a..762c6c0c6 100644 --- a/crates/heal/src/lib.rs +++ b/crates/heal/src/lib.rs @@ -176,7 +176,7 @@ pub async fn init_heal_manager_with_workload_provider( let channel_receiver = if force_channel_failure { Err("forced heal channel initialization failure") } else { - rustfs_common::heal_channel::init_heal_channels() + rustfs_heal_contracts::heal_channel::init_heal_channels() }; let (receiver, receipt_receiver) = match channel_receiver { Ok(receivers) => receivers, @@ -358,7 +358,7 @@ mod tests { heal::storage::HealStorageAPI, init_heal_manager, run_owned_initialization, }; use crate::heal::storage_api::status::BucketInfo; - use rustfs_common::heal_channel::HealOpts; + use rustfs_heal_contracts::heal_channel::HealOpts; use rustfs_madmin::heal_commands::HealResultItem; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/heal/tests/heal_b5_versioned_regression_test.rs b/crates/heal/tests/heal_b5_versioned_regression_test.rs index 05d789d4f..81cfdc79a 100644 --- a/crates/heal/tests/heal_b5_versioned_regression_test.rs +++ b/crates/heal/tests/heal_b5_versioned_regression_test.rs @@ -25,7 +25,6 @@ #![recursion_limit = "256"] use http::HeaderMap; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; use rustfs_heal::heal::{ manager::{HealConfig, HealManager}, storage::{ @@ -33,6 +32,7 @@ use rustfs_heal::heal::{ }, task::{HealOptions, HealPriority, HealRequest, HealTaskStatus, HealType}, }; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use serial_test::serial; use std::{ path::{Path, PathBuf}, diff --git a/crates/heal/tests/heal_b920_subquorum_union_test.rs b/crates/heal/tests/heal_b920_subquorum_union_test.rs index eabda8e11..6be2d0595 100644 --- a/crates/heal/tests/heal_b920_subquorum_union_test.rs +++ b/crates/heal/tests/heal_b920_subquorum_union_test.rs @@ -24,10 +24,10 @@ #![recursion_limit = "256"] use http::HeaderMap; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; use rustfs_heal::heal::storage::{ ECStoreHealStorage, HealListItem, HealObjectOptions as ObjectOptions, HealPutObjReader as PutObjReader, HealStorageAPI, }; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use serial_test::serial; use std::{ future::Future, diff --git a/crates/heal/tests/heal_bug_fixes_test.rs b/crates/heal/tests/heal_bug_fixes_test.rs index 59ff1bb3f..d2690b6be 100644 --- a/crates/heal/tests/heal_bug_fixes_test.rs +++ b/crates/heal/tests/heal_bug_fixes_test.rs @@ -121,14 +121,14 @@ fn test_heal_task_status_atomic_update() { _bucket: &str, _object: &str, _version_id: Option<&str>, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result<(rustfs_madmin::heal_commands::HealResultItem, Option)> { Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None)) } async fn heal_bucket( &self, _bucket: &str, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result { Ok(rustfs_madmin::heal_commands::HealResultItem::default()) } @@ -221,7 +221,7 @@ async fn test_heal_task_transient_object_exists_skip_avoids_recreate() { _bucket: &str, _object: &str, _version_id: Option<&str>, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result<(rustfs_madmin::heal_commands::HealResultItem, Option)> { self.heal_object_calls.fetch_add(1, Ordering::SeqCst); Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None)) @@ -230,7 +230,7 @@ async fn test_heal_task_transient_object_exists_skip_avoids_recreate() { async fn heal_bucket( &self, _bucket: &str, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result { Ok(rustfs_madmin::heal_commands::HealResultItem::default()) } diff --git a/crates/heal/tests/heal_integration_test.rs b/crates/heal/tests/heal_integration_test.rs index 940a69f73..213b43e86 100644 --- a/crates/heal/tests/heal_integration_test.rs +++ b/crates/heal/tests/heal_integration_test.rs @@ -15,12 +15,12 @@ #![recursion_limit = "256"] use http::HeaderMap; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; use rustfs_heal::heal::{ manager::{HealConfig, HealManager}, storage::{ECStoreHealStorage, HealObjectOptions as ObjectOptions, HealPutObjReader as PutObjReader, HealStorageAPI}, task::{HealOptions, HealPriority, HealRequest, HealTaskStatus, HealType}, }; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use serial_test::serial; use std::{ path::{Path, PathBuf}, diff --git a/crates/lifecycle/Cargo.toml b/crates/lifecycle/Cargo.toml index 483b0e7d7..76f9d198c 100644 --- a/crates/lifecycle/Cargo.toml +++ b/crates/lifecycle/Cargo.toml @@ -57,6 +57,7 @@ hotpath.workspace = true async-trait.workspace = true metrics.workspace = true rustfs-common.workspace = true +rustfs-scanner-contracts.workspace = true rustfs-config = { workspace = true, features = ["constants"] } rustfs-replication.workspace = true rustfs-storage-api.workspace = true diff --git a/crates/lifecycle/src/core.rs b/crates/lifecycle/src/core.rs index 7dea47b71..2eadd6064 100644 --- a/crates/lifecycle/src/core.rs +++ b/crates/lifecycle/src/core.rs @@ -62,7 +62,7 @@ const ERR_LIFECYCLE_EXPIRED_OBJECT_DELETE_MARKER_WITH_TAGS: &str = const ERR_LIFECYCLE_RULE_MUST_HAVE_ACTION: &str = "Rule must have at least one of Expiration, Transition, NoncurrentVersionExpiration, NoncurrentVersionTransition, or DelMarkerExpiration"; const ERR_LIFECYCLE_PREFIX_FILTER_CONFLICT: &str = "Legacy Prefix and Filter cannot both be present in a lifecycle rule. Use Filter.Prefix instead of the top-level Prefix element."; -pub use rustfs_common::metrics::IlmAction; +pub use rustfs_scanner_contracts::metrics::IlmAction; #[async_trait::async_trait] pub trait RuleValidate { diff --git a/crates/lifecycle/src/evaluator.rs b/crates/lifecycle/src/evaluator.rs index 9ccc5b862..3b4bca449 100644 --- a/crates/lifecycle/src/evaluator.rs +++ b/crates/lifecycle/src/evaluator.rs @@ -18,8 +18,8 @@ use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLock use time::OffsetDateTime; use tracing::info; -use rustfs_common::metrics::IlmAction; use rustfs_replication::ReplicationStatusType; +use rustfs_scanner_contracts::metrics::IlmAction; use crate::object_lock; use crate::{Event, Lifecycle, ObjectOpts}; @@ -197,7 +197,7 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; - use rustfs_common::metrics::IlmAction; + use rustfs_scanner_contracts::metrics::IlmAction; use s3s::dto::{ BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, NoncurrentVersionExpiration, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, diff --git a/crates/lifecycle/src/lib.rs b/crates/lifecycle/src/lib.rs index 8986c9523..234dd9d7c 100644 --- a/crates/lifecycle/src/lib.rs +++ b/crates/lifecycle/src/lib.rs @@ -20,5 +20,5 @@ mod tagging; pub use core::*; pub use evaluator::Evaluator; -pub use rustfs_common::metrics::IlmAction; pub use rustfs_replication::{ReplicationStatusType, VersionPurgeStatusType}; +pub use rustfs_scanner_contracts::metrics::IlmAction; diff --git a/crates/obs/Cargo.toml b/crates/obs/Cargo.toml index 17ba113ed..338128814 100644 --- a/crates/obs/Cargo.toml +++ b/crates/obs/Cargo.toml @@ -105,6 +105,8 @@ workspace = true hotpath.workspace = true rustfs-audit = { workspace = true } rustfs-common = { workspace = true } +rustfs-heal-contracts = { workspace = true } +rustfs-scanner-contracts = { workspace = true } rustfs-config = { workspace = true, features = ["observability"] } # NOTE: This dependency on rustfs-ecstore is a known architectural limitation. # The obs crate imports types from ecstore for metrics collection. diff --git a/crates/obs/src/metrics/collectors/scanner.rs b/crates/obs/src/metrics/collectors/scanner.rs index 1e0221908..ed0b42d2c 100644 --- a/crates/obs/src/metrics/collectors/scanner.rs +++ b/crates/obs/src/metrics/collectors/scanner.rs @@ -493,7 +493,7 @@ mod tests { use super::*; use crate::metrics::report::report_metrics; use metrics_util::debugging::DebuggingRecorder; - use rustfs_common::metrics::{Metric, Metrics}; + use rustfs_scanner_contracts::metrics::{Metric, Metrics}; fn prometheus_counter_name(name: &str) -> String { if name.ends_with("_total") { diff --git a/crates/obs/src/metrics/stats_collector.rs b/crates/obs/src/metrics/stats_collector.rs index 0d3863762..8ee95ed47 100644 --- a/crates/obs/src/metrics/stats_collector.rs +++ b/crates/obs/src/metrics/stats_collector.rs @@ -37,16 +37,16 @@ use crate::metrics::{ }; use crate::node_identity::current_local_node_identity; use jiff::Timestamp; -use rustfs_common::heal_channel::HealScanMode; -use rustfs_common::metrics::{ - ScannerActiveBucketDriveSnapshot, ScannerBucketDriveResultSnapshot, ScannerMetricsReport, ScannerSourceWorkSnapshot, - global_metrics, -}; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_io_metrics::internode_metrics::global_internode_metrics; use rustfs_io_metrics::{ ProcessResourceSnapshot, ProcessSampler, ProcessStatusSnapshot, ProcessSystemSnapshot, s3_op_metrics_snapshot, snapshot_process_resource_and_system, snapshot_process_resource_and_system_with, }; +use rustfs_scanner_contracts::metrics::{ + ScannerActiveBucketDriveSnapshot, ScannerBucketDriveResultSnapshot, ScannerMetricsReport, ScannerSourceWorkSnapshot, + global_metrics, +}; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -1667,7 +1667,7 @@ pub async fn collect_compression_cluster_stats() -> Option HealChannelRequest { diff --git a/crates/scanner/Cargo.toml b/crates/scanner/Cargo.toml index d941dce48..91f35715f 100644 --- a/crates/scanner/Cargo.toml +++ b/crates/scanner/Cargo.toml @@ -73,6 +73,8 @@ hotpath-cpu = [ hotpath.workspace = true rustfs-config = { workspace = true, features = ["server-config-model"] } rustfs-common = { workspace = true } +rustfs-heal-contracts = { workspace = true } +rustfs-scanner-contracts = { workspace = true } rustfs-credentials = { workspace = true } rustfs-utils = { workspace = true } tokio = { workspace = true, features = ["fs", "sync", "time", "macros", "rt-multi-thread"] } diff --git a/crates/scanner/src/data_usage_define.rs b/crates/scanner/src/data_usage_define.rs index 084965989..8447c4917 100644 --- a/crates/scanner/src/data_usage_define.rs +++ b/crates/scanner/src/data_usage_define.rs @@ -23,7 +23,6 @@ use std::{ use http::HeaderMap; use metrics::{counter, describe_counter, describe_histogram, histogram}; -use rustfs_common::heal_channel::HealScanMode; #[cfg(test)] use rustfs_config::ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS; pub use rustfs_data_usage::{ @@ -33,6 +32,7 @@ pub use rustfs_data_usage::{ SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER, UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP, UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache, }; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf}; use tokio::time::{Duration, Instant, sleep, timeout}; use tracing::{debug, warn}; diff --git a/crates/scanner/src/lib.rs b/crates/scanner/src/lib.rs index 7f9f985da..2c78928b5 100644 --- a/crates/scanner/src/lib.rs +++ b/crates/scanner/src/lib.rs @@ -79,7 +79,7 @@ pub use remote_scanner::{ remote_scanner_request_matches_envelope, serve_remote_scanner_request, validate_remote_scanner_request_fence, }; pub use runtime_config::{apply_scanner_runtime_config, scanner_runtime_config_status, validate_scanner_runtime_config}; -pub use rustfs_common::last_minute; +pub use rustfs_scanner_contracts::last_minute; pub use scanner::{ ScannerCycleRecoveryMarker, ScannerCycleRecoveryStatus, ScannerCycleScheduleStatus, init_data_scanner, reset_scanner_cycle_recovery, scanner_cycle_recovery_status, scanner_cycle_schedule_status, scanner_topology_digest, diff --git a/crates/scanner/src/remote_scanner/stream.rs b/crates/scanner/src/remote_scanner/stream.rs index 594473196..7617f8fd0 100644 --- a/crates/scanner/src/remote_scanner/stream.rs +++ b/crates/scanner/src/remote_scanner/stream.rs @@ -26,9 +26,9 @@ use crate::{ scanner_publication_admission_for_epoch, scanner_publication_epoch, }; use hmac::{Hmac, KeyInit, Mac}; -use rustfs_common::heal_channel::HealScanMode; -use rustfs_common::metrics::{Metric, Metrics}; use rustfs_credentials::try_get_rpc_token; +use rustfs_heal_contracts::heal_channel::HealScanMode; +use rustfs_scanner_contracts::metrics::{Metric, Metrics}; use rustfs_utils::path::path_join_buf; use serde::{Deserialize, Serialize}; use sha2::Sha256; diff --git a/crates/scanner/src/scanner.rs b/crates/scanner/src/scanner.rs index e31cad163..d985931ad 100644 --- a/crates/scanner/src/scanner.rs +++ b/crates/scanner/src/scanner.rs @@ -40,12 +40,6 @@ use crate::{DataUsageInfo, ScannerActivityGuard, ScannerError, ScannerRuntimeGua use crate::{ScannerConfigObjectDelete, ScannerObjectIO, ScannerObjectOptions}; use bytes::Bytes; use chrono::{DateTime, Utc}; -use rustfs_common::heal_channel::HealScanMode; -use rustfs_common::metrics::{ - CurrentCycle, Metric, Metrics, ScanCyclePartialReason, ScanCycleWorkSnapshot, ScannerUsageSaveResult, ScannerWorkSource, - emit_scan_cycle_complete, emit_scan_cycle_deferred, emit_scan_cycle_partial_with_source, emit_scan_cycle_superseded, - global_metrics, -}; use rustfs_config::ScannerSpeed; #[cfg(test)] use rustfs_config::{ @@ -54,7 +48,13 @@ use rustfs_config::{ }; use rustfs_config::{ENV_SCANNER_CYCLE, ENV_SCANNER_SPEED, ENV_SCANNER_START_DELAY_SECS}; use rustfs_data_usage::observed_data_usage_is_newer; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_lock::{NamespaceLockGuard, error::LockError}; +use rustfs_scanner_contracts::metrics::{ + CurrentCycle, Metric, Metrics, ScanCyclePartialReason, ScanCycleWorkSnapshot, ScannerUsageSaveResult, ScannerWorkSource, + emit_scan_cycle_complete, emit_scan_cycle_deferred, emit_scan_cycle_partial_with_source, emit_scan_cycle_superseded, + global_metrics, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use tokio::sync::{Notify, mpsc}; diff --git a/crates/scanner/src/scanner_folder.rs b/crates/scanner/src/scanner_folder.rs index efc0ca26e..231bd147c 100644 --- a/crates/scanner/src/scanner_folder.rs +++ b/crates/scanner/src/scanner_folder.rs @@ -41,19 +41,19 @@ use crate::storage_api::owner::{ #[cfg(test)] use crate::storage_api::owner::{EcstoreExpirationStatus as ExpirationStatus, EcstoreLifecycleRule as LifecycleRule}; use metrics::{counter, describe_counter}; -use rustfs_common::heal_channel::{ - HEAL_DELETE_DANGLING, HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest, - HealRequestSource, HealScanMode, send_heal_request_with_admission, -}; -use rustfs_common::metrics::{ - CloseDiskGuard, IlmAction, Metric, Metrics, ScannerReplicationRepairKind, ScannerSourceWorkUpdate, ScannerWorkSource, - UpdateCurrentPathFn, current_path_updater, global_metrics, -}; use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit, trace_subscriber_count}; use rustfs_filemeta::{ MAX_META_CACHE_HEAL_CANDIDATES, MAX_META_CACHE_HEAL_TRUNCATED_OBJECTS, MetaCacheEntries, MetaCacheEntry, MetaCacheHealCandidateKind, }; +use rustfs_heal_contracts::heal_channel::{ + HEAL_DELETE_DANGLING, HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest, + HealRequestSource, HealScanMode, send_heal_request_with_admission, +}; +use rustfs_scanner_contracts::metrics::{ + CloseDiskGuard, IlmAction, Metric, Metrics, ScannerReplicationRepairKind, ScannerSourceWorkUpdate, ScannerWorkSource, + UpdateCurrentPathFn, current_path_updater, global_metrics, +}; use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf}; use time::OffsetDateTime; use tokio::select; diff --git a/crates/scanner/src/scanner_folder/tests.rs b/crates/scanner/src/scanner_folder/tests.rs index 3da33b3b5..3708db8af 100644 --- a/crates/scanner/src/scanner_folder/tests.rs +++ b/crates/scanner/src/scanner_folder/tests.rs @@ -745,7 +745,7 @@ async fn test_scanner_heal_admission_accounting_maps_normal_scan_to_heal() { &metrics, HealScanMode::Normal, Ok(HealAdmissionResult::Dropped( - rustfs_common::heal_channel::HealAdmissionDropReason::QueueFull, + rustfs_heal_contracts::heal_channel::HealAdmissionDropReason::QueueFull, )), ); record_scanner_heal_admission(&metrics, HealScanMode::Normal, Err(())); @@ -1687,7 +1687,7 @@ fn test_describe_heal_admission_formats_unadmitted_results() { assert_eq!(describe_heal_admission(HealAdmissionResult::Full), "queue_full"); assert_eq!( describe_heal_admission(HealAdmissionResult::Dropped( - rustfs_common::heal_channel::HealAdmissionDropReason::QueueFull + rustfs_heal_contracts::heal_channel::HealAdmissionDropReason::QueueFull )), "dropped:queue_full" ); @@ -1879,10 +1879,10 @@ async fn test_scan_folder_exits_when_abandoned_child_listing_finishes() { let healed_versions = Arc::new(Mutex::new(Vec::>::new())); let healed_versions_clone = healed_versions.clone(); let mut heal_rx = - rustfs_common::heal_channel::init_heal_channel().expect("heal channel should initialize once for scanner tests"); + rustfs_heal_contracts::heal_channel::init_heal_channel().expect("heal channel should initialize once for scanner tests"); let _heal_responder = tokio::spawn(async move { while let Some(command) = heal_rx.recv().await { - if let rustfs_common::heal_channel::HealChannelCommand::Start { + if let rustfs_heal_contracts::heal_channel::HealChannelCommand::Start { request, response_tx, .. } = command { diff --git a/crates/scanner/src/scanner_io.rs b/crates/scanner/src/scanner_io.rs index 584dec769..8753b2139 100644 --- a/crates/scanner/src/scanner_io.rs +++ b/crates/scanner/src/scanner_io.rs @@ -24,13 +24,15 @@ use crate::{ use futures::future::join_all; use metrics::counter; use rand::seq::SliceRandom as _; -use rustfs_common::heal_channel::HealScanMode; -use rustfs_common::metrics::{Metric, Metrics, emit_scan_bucket_drive_complete, emit_scan_bucket_drive_partial, global_metrics}; #[cfg(test)] use rustfs_config::{ENV_SCANNER_MAX_CONCURRENT_DISK_SCANS, ENV_SCANNER_MAX_CONCURRENT_SET_SCANS}; use rustfs_data_usage::{BucketTargetUsageInfo, BucketUsageInfo}; use rustfs_filemeta::FileMeta; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_lock::{LockError, NamespaceLockGuard}; +use rustfs_scanner_contracts::metrics::{ + Metric, Metrics, emit_scan_bucket_drive_complete, emit_scan_bucket_drive_partial, global_metrics, +}; use rustfs_utils::path::path_join_buf; use s3s::dto::{ BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ReplicationConfiguration, VersioningConfiguration, diff --git a/crates/scanner/src/scanner_io/guards.rs b/crates/scanner/src/scanner_io/guards.rs index 3c93f759e..8e638a3c0 100644 --- a/crates/scanner/src/scanner_io/guards.rs +++ b/crates/scanner/src/scanner_io/guards.rs @@ -147,13 +147,13 @@ impl Drop for DiskBucketScanActiveGuard { pub(super) struct BucketDriveFailureGuard { failed: bool, - source: rustfs_common::metrics::ScannerWorkSource, + source: rustfs_scanner_contracts::metrics::ScannerWorkSource, bucket: String, drive: String, } impl BucketDriveFailureGuard { - pub(super) fn new(source: rustfs_common::metrics::ScannerWorkSource, bucket: &str, drive: &str) -> Self { + pub(super) fn new(source: rustfs_scanner_contracts::metrics::ScannerWorkSource, bucket: &str, drive: &str) -> Self { Self { failed: true, source, @@ -285,7 +285,7 @@ pub(super) fn scanner_task_join_error(stage: &str, err: tokio::task::JoinError) #[cfg(test)] mod tests { use super::*; - use rustfs_common::metrics::{ScannerWorkSource, global_metrics}; + use rustfs_scanner_contracts::metrics::{ScannerWorkSource, global_metrics}; #[test] fn bucket_drive_failure_guard_retires_active_scan_on_drop() { diff --git a/crates/scanner/src/scanner_io/io_disk.rs b/crates/scanner/src/scanner_io/io_disk.rs index cf209206e..1034d929e 100644 --- a/crates/scanner/src/scanner_io/io_disk.rs +++ b/crates/scanner/src/scanner_io/io_disk.rs @@ -161,8 +161,8 @@ impl ScannerIODisk for Disk { let bucket = cache.info.name.clone(); let disk_path = self.path().to_string_lossy().to_string(); let source = match scan_mode { - HealScanMode::Deep => rustfs_common::metrics::ScannerWorkSource::Bitrot, - HealScanMode::Normal | HealScanMode::Unknown => rustfs_common::metrics::ScannerWorkSource::Usage, + HealScanMode::Deep => rustfs_scanner_contracts::metrics::ScannerWorkSource::Bitrot, + HealScanMode::Normal | HealScanMode::Unknown => rustfs_scanner_contracts::metrics::ScannerWorkSource::Usage, }; global_metrics().record_scan_bucket_drive_start(source, &bucket, &disk_path); let mut failure_guard = BucketDriveFailureGuard::new(source, &bucket, &disk_path); diff --git a/crates/scanner/src/sleeper.rs b/crates/scanner/src/sleeper.rs index de46d53ef..1a554dcdd 100644 --- a/crates/scanner/src/sleeper.rs +++ b/crates/scanner/src/sleeper.rs @@ -16,11 +16,11 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, LazyLock, RwLock}; use std::time::Instant; -use rustfs_common::metrics::global_metrics; use rustfs_config::{ DEFAULT_SCANNER_IDLE_MODE, DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, ENV_SCANNER_IDLE_MODE, ENV_SCANNER_SPEED, ENV_SCANNER_YIELD_EVERY_N_OBJECTS, ScannerSpeed, }; +use rustfs_scanner_contracts::metrics::global_metrics; use tokio::time::Duration; const MIN_SLEEP: Duration = Duration::from_millis(1); diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index c392d5629..a37ebfef8 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -213,6 +213,8 @@ hotpath.workspace = true rustfs-heal = { workspace = true } rustfs-audit = { workspace = true } rustfs-common = { workspace = true } +rustfs-heal-contracts = { workspace = true } +rustfs-scanner-contracts = { workspace = true } rustfs-config = { workspace = true, features = ["notify", "server-config-model"] } rustfs-crypto = { workspace = true } rustfs-credentials = { workspace = true } diff --git a/rustfs/src/admin/handlers/heal.rs b/rustfs/src/admin/handlers/heal.rs index 28999fa94..57cdc2c6c 100644 --- a/rustfs/src/admin/handlers/heal.rs +++ b/rustfs/src/admin/handlers/heal.rs @@ -28,11 +28,11 @@ use futures_util::future::join_all; use http::{HeaderMap, HeaderValue, Uri}; use hyper::{Method, StatusCode}; use matchit::Params; -use rustfs_common::heal_channel::{ - HealAdmissionReceipt, HealChannelPriority, HealChannelRequest, HealOpts, HealRequestSource, HealScanMode, -}; use rustfs_config::MAX_HEAL_REQUEST_SIZE; use rustfs_heal::heal::utils::format_set_disk_id; +use rustfs_heal_contracts::heal_channel::{ + HealAdmissionReceipt, HealChannelPriority, HealChannelRequest, HealOpts, HealRequestSource, HealScanMode, +}; use rustfs_policy::policy::action::{Action, AdminAction}; use rustfs_scanner::scanner::{BackgroundHealInfo, read_background_heal_info}; use rustfs_utils::path::path_join; @@ -960,8 +960,8 @@ async fn submit_cluster_heal_start( } } -fn reject_heal_admission(result: rustfs_common::heal_channel::HealAdmissionResult) -> s3s::S3Error { - use rustfs_common::heal_channel::{HealAdmissionDropReason, HealAdmissionResult}; +fn reject_heal_admission(result: rustfs_heal_contracts::heal_channel::HealAdmissionResult) -> s3s::S3Error { + use rustfs_heal_contracts::heal_channel::{HealAdmissionDropReason, HealAdmissionResult}; match result { HealAdmissionResult::Full | HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull) => s3_error!( @@ -996,10 +996,10 @@ async fn submit_cluster_heal_channel_command( envelope: rustfs_protos::heal_control::Envelope, request_id: &str, response_id: String, -) -> S3Result { +) -> S3Result { match route_cluster_heal_control(&context, &route, envelope, request_id, false).await? { rustfs_protos::heal_control::Outcome::Channel { success, data, error } => { - Ok(rustfs_common::heal_channel::HealChannelResponse { + Ok(rustfs_heal_contracts::heal_channel::HealChannelResponse { request_id: response_id, success, data, @@ -1087,7 +1087,7 @@ fn build_heal_channel_request(hip: &HealInitParams) -> HealChannelRequest { } else { hip.hs.recursive }; - let mut heal_request = rustfs_common::heal_channel::create_heal_request( + let mut heal_request = rustfs_heal_contracts::heal_channel::create_heal_request( hip.bucket.clone(), if hip.obj_prefix.is_empty() { None @@ -1115,7 +1115,7 @@ fn build_heal_channel_request(hip: &HealInitParams) -> HealChannelRequest { } fn heal_channel_response_status( - response: &rustfs_common::heal_channel::HealChannelResponse, + response: &rustfs_heal_contracts::heal_channel::HealChannelResponse, ) -> (String, Vec, bool, Option) { let Some(data) = response.data.as_deref() else { return ("running".to_string(), Vec::new(), false, None); @@ -1136,19 +1136,21 @@ fn heal_channel_response_status( } #[cfg(test)] -fn heal_channel_response_summary(response: &rustfs_common::heal_channel::HealChannelResponse) -> String { +fn heal_channel_response_summary(response: &rustfs_heal_contracts::heal_channel::HealChannelResponse) -> String { heal_channel_response_status(response).0 } #[cfg(test)] fn heal_channel_response_items( - response: &rustfs_common::heal_channel::HealChannelResponse, + response: &rustfs_heal_contracts::heal_channel::HealChannelResponse, ) -> Vec { heal_channel_response_status(response).1 } #[cfg(test)] -fn heal_channel_response_progress(response: &rustfs_common::heal_channel::HealChannelResponse) -> Option { +fn heal_channel_response_progress( + response: &rustfs_heal_contracts::heal_channel::HealChannelResponse, +) -> Option { heal_channel_response_status(response).3 } @@ -1524,7 +1526,7 @@ mod tests { use http::StatusCode; use http::Uri; use matchit::Router; - use rustfs_common::heal_channel::{ + use rustfs_heal_contracts::heal_channel::{ HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealOpts, HealRequestSource, HealScanMode, }; use rustfs_scanner::scanner::BackgroundHealInfo; @@ -1718,7 +1720,7 @@ mod tests { dry_run: false, remove: true, recreate: false, - scan_mode: rustfs_common::heal_channel::HealScanMode::Normal, + scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Normal, update_parity: false, no_lock: true, pool: Some(1), @@ -2640,11 +2642,15 @@ mod tests { #[test] fn test_heal_channel_response_summary_defaults_to_running() { - let response = rustfs_common::heal_channel::create_heal_response("token".to_string(), true, None, None); + let response = rustfs_heal_contracts::heal_channel::create_heal_response("token".to_string(), true, None, None); assert_eq!(heal_channel_response_summary(&response), "running"); - let response = - rustfs_common::heal_channel::create_heal_response("token".to_string(), true, Some(b"finished".to_vec()), None); + let response = rustfs_heal_contracts::heal_channel::create_heal_response( + "token".to_string(), + true, + Some(b"finished".to_vec()), + None, + ); assert_eq!(heal_channel_response_summary(&response), "finished"); } @@ -2668,7 +2674,7 @@ mod tests { "objectSize": 1024 }] }); - let response = rustfs_common::heal_channel::create_heal_response( + let response = rustfs_heal_contracts::heal_channel::create_heal_response( "token".to_string(), true, Some(serde_json::to_vec(&payload).expect("payload should serialize")), @@ -2695,7 +2701,7 @@ mod tests { "items": [], "progress": progress }); - let response = rustfs_common::heal_channel::create_heal_response( + let response = rustfs_heal_contracts::heal_channel::create_heal_response( "token".to_string(), true, Some(serde_json::to_vec(&payload).expect("payload should serialize")), diff --git a/rustfs/src/admin/handlers/scanner.rs b/rustfs/src/admin/handlers/scanner.rs index f932c3832..52fdfc777 100644 --- a/rustfs/src/admin/handlers/scanner.rs +++ b/rustfs/src/admin/handlers/scanner.rs @@ -24,10 +24,12 @@ use chrono::Utc; use http::{HeaderMap, HeaderValue}; use hyper::{Method, StatusCode}; use matchit::Params; -use rustfs_common::metrics::{ScannerLifecycleExpirySnapshot, ScannerMaintenanceControlSnapshot, ScannerMetricsReport}; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; use rustfs_credentials::Credentials; use rustfs_policy::policy::action::{Action, AdminAction}; +use rustfs_scanner_contracts::metrics::{ + ScannerLifecycleExpirySnapshot, ScannerMaintenanceControlSnapshot, ScannerMetricsReport, +}; use s3s::header::CONTENT_TYPE; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; use serde::{Deserialize, Serialize}; diff --git a/rustfs/src/admin/storage_api.rs b/rustfs/src/admin/storage_api.rs index c4a597b08..3e2421f93 100644 --- a/rustfs/src/admin/storage_api.rs +++ b/rustfs/src/admin/storage_api.rs @@ -124,7 +124,7 @@ pub(crate) mod runtime_sources { pub(crate) type DailyAllTierStats = super::DailyAllTierStats; pub(crate) type ECStore = super::ECStore; pub(crate) type NotificationSys = super::NotificationSys; - pub(crate) type ScannerMetricsReport = rustfs_common::metrics::ScannerMetricsReport; + pub(crate) type ScannerMetricsReport = rustfs_scanner_contracts::metrics::ScannerMetricsReport; pub(crate) type StorageClassConfig = crate::storage::storage_api::ecstore_config::storageclass::Config; pub(crate) type TierConfigMgr = crate::storage::storage_api::TierConfigMgr; } diff --git a/rustfs/src/app/capacity_dirty_scope_test.rs b/rustfs/src/app/capacity_dirty_scope_test.rs index e00c21b73..3c84341a1 100644 --- a/rustfs/src/app/capacity_dirty_scope_test.rs +++ b/rustfs/src/app/capacity_dirty_scope_test.rs @@ -17,7 +17,7 @@ use super::storage_api::test::contract::bucket::{BucketOperations, BucketOptions use super::storage_api::test::contract::heal::HealOperations as _; use super::storage_api::test::contract::object::ObjectIO as _; use super::storage_api::test::{ECStore, Endpoint, EndpointServerPools, Endpoints, PoolEndpoints}; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use rustfs_object_capacity::capacity_manager::{HybridStrategyConfig, create_isolated_manager}; use serial_test::serial; use std::{ diff --git a/rustfs/src/app/storage_api.rs b/rustfs/src/app/storage_api.rs index f11b10c38..c9b3438af 100644 --- a/rustfs/src/app/storage_api.rs +++ b/rustfs/src/app/storage_api.rs @@ -137,7 +137,7 @@ pub(crate) mod runtime { pub(crate) type NotificationSys = crate::storage::storage_api::NotificationSys; pub(crate) type ObjectStoreResolver = crate::storage::storage_api::ObjectStoreResolver; pub(crate) type ReplicationStats = crate::storage::storage_api::ReplicationStats; - pub(crate) type ScannerMetricsReport = rustfs_common::metrics::ScannerMetricsReport; + pub(crate) type ScannerMetricsReport = rustfs_scanner_contracts::metrics::ScannerMetricsReport; pub(crate) type StorageClassConfig = crate::storage::storage_api::ecstore_config::storageclass::Config; pub(crate) type TierConfigMgr = crate::storage::storage_api::TierConfigMgr; pub(crate) type TransitionState = crate::storage::storage_api::TransitionState; @@ -213,7 +213,7 @@ pub(crate) mod runtime { } pub(crate) async fn collect_scanner_metrics_report() -> ScannerMetricsReport { - rustfs_common::metrics::global_metrics().report().await + rustfs_scanner_contracts::metrics::global_metrics().report().await } #[cfg(test)] diff --git a/rustfs/src/cluster_snapshot.rs b/rustfs/src/cluster_snapshot.rs index e7535eb5d..f061fa981 100644 --- a/rustfs/src/cluster_snapshot.rs +++ b/rustfs/src/cluster_snapshot.rs @@ -23,9 +23,9 @@ use crate::storage_api::cluster::control_plane::{ ClusterPeerHealthSnapshot, ClusterPoolStateSnapshot, ClusterRpcBoundarySnapshot, }; use crate::workload_admission::workload_admission_registry_snapshot; -use rustfs_common::metrics::{ScannerMetricsReport, global_metrics}; use rustfs_concurrency::{AdmissionState, WorkloadAdmissionRegistrySnapshot}; use rustfs_io_metrics::internode_metrics::{InternodeMetricsSnapshot, global_internode_metrics}; +use rustfs_scanner_contracts::metrics::{ScannerMetricsReport, global_metrics}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClusterReadOnlySnapshot { diff --git a/rustfs/src/startup_runtime_sources.rs b/rustfs/src/startup_runtime_sources.rs index 8b6e33115..ab63777b3 100644 --- a/rustfs/src/startup_runtime_sources.rs +++ b/rustfs/src/startup_runtime_sources.rs @@ -55,7 +55,7 @@ pub(crate) async fn publish_server_addr(addr: &str) { } pub(crate) async fn publish_init_time_now() { - rustfs_common::set_global_init_time_now().await; + rustfs_scanner_contracts::set_global_init_time_now().await; } pub(crate) fn init_kms_service_manager() -> Arc { diff --git a/rustfs/src/storage/rpc/node_service.rs b/rustfs/src/storage/rpc/node_service.rs index c73cf0f32..3f28c39ce 100644 --- a/rustfs/src/storage/rpc/node_service.rs +++ b/rustfs/src/storage/rpc/node_service.rs @@ -195,8 +195,8 @@ fn heal_control_remaining(expires_at_unix_ms: i64, now_unix_ms: i64) -> Result Result<(), Status> { - if request.source != rustfs_common::heal_channel::HealRequestSource::Admin { +fn validate_admin_heal_control_start(request: &rustfs_heal_contracts::heal_channel::HealChannelRequest) -> Result<(), Status> { + if request.source != rustfs_heal_contracts::heal_channel::HealRequestSource::Admin { return Err(Status::permission_denied("heal control start source must be admin")); } if request.pool_index.is_some() != request.set_index.is_some() { @@ -2542,7 +2542,7 @@ mod tests { _bucket: &str, _object: &str, _version_id: Option<&str>, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result<(rustfs_madmin::heal_commands::HealResultItem, Option)> { Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None)) } @@ -2550,7 +2550,7 @@ mod tests { async fn heal_bucket( &self, _bucket: &str, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result { Ok(rustfs_madmin::heal_commands::HealResultItem::default()) } @@ -2618,10 +2618,14 @@ mod tests { let now = i64::try_from(now).expect("test clock should fit in i64"); let metadata = || rustfs_protos::heal_control::RequestMetadata::new(rand::random(), now, now + 30_000, coordinator_epoch); let start = |request_id: String| { - let mut request = - rustfs_common::heal_channel::create_heal_request("bucket".to_string(), Some("prefix".to_string()), false, None); + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request( + "bucket".to_string(), + Some("prefix".to_string()), + false, + None, + ); request.id = request_id; - request.source = rustfs_common::heal_channel::HealRequestSource::Admin; + request.source = rustfs_heal_contracts::heal_channel::HealRequestSource::Admin; request }; @@ -3618,7 +3622,7 @@ mod tests { request } - let expired_request = rustfs_common::heal_channel::create_heal_request("bucket".to_string(), None, false, None); + let expired_request = rustfs_heal_contracts::heal_channel::create_heal_request("bucket".to_string(), None, false, None); let expired = rustfs_protos::heal_control::Envelope::start( expired_request, rustfs_protos::heal_control::RequestMetadata::new([1; 16], 1, 2, coordinator_epoch), @@ -3631,8 +3635,9 @@ mod tests { .expect_err("expired commands must fail before admission"); assert_eq!(expired.code(), tonic::Code::FailedPrecondition); - let mut non_admin_request = rustfs_common::heal_channel::create_heal_request("bucket".to_string(), None, false, None); - non_admin_request.source = rustfs_common::heal_channel::HealRequestSource::Scanner; + let mut non_admin_request = + rustfs_heal_contracts::heal_channel::create_heal_request("bucket".to_string(), None, false, None); + non_admin_request.source = rustfs_heal_contracts::heal_channel::HealRequestSource::Scanner; let now = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000; let now = i64::try_from(now).expect("test clock should fit in i64"); let non_admin = rustfs_protos::heal_control::Envelope::start( diff --git a/rustfs/src/storage/rpc/node_service/heal.rs b/rustfs/src/storage/rpc/node_service/heal.rs index 8bbd27572..e5aaec78b 100644 --- a/rustfs/src/storage/rpc/node_service/heal.rs +++ b/rustfs/src/storage/rpc/node_service/heal.rs @@ -16,8 +16,8 @@ use crate::module_switches::{heal_enabled_from_env, scanner_enabled_from_env}; use crate::storage::storage_api::runtime_sources_consumer::EndpointServerPools; use jiff::Timestamp; use rmp_serde::Deserializer; -use rustfs_common::heal_channel::HealScanMode; use rustfs_heal::HealOperationsSnapshot; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_scanner::scanner::BackgroundHealInfo; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -770,7 +770,7 @@ mod tests { BackgroundHealInfo { bitrot_start_time: Some(started_at), bitrot_start_cycle: 9, - current_scan_mode: rustfs_common::heal_channel::HealScanMode::Deep, + current_scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep, }, HealOperationsSnapshot::default(), None, diff --git a/rustfs/src/storage/storage_api.rs b/rustfs/src/storage/storage_api.rs index 5e85dc080..b9a8a5bfe 100644 --- a/rustfs/src/storage/storage_api.rs +++ b/rustfs/src/storage/storage_api.rs @@ -1545,7 +1545,7 @@ pub(crate) trait StoragePeerS3ClientExt { async fn heal_bucket_with_fence( &self, bucket: &str, - opts: &rustfs_common::heal_channel::HealOpts, + opts: &rustfs_heal_contracts::heal_channel::HealOpts, fenced_pools: &[usize], ) -> DiskResult; async fn make_bucket(&self, bucket: &str, opts: &contract::bucket::MakeBucketOptions) -> DiskResult<()>; @@ -1562,7 +1562,7 @@ impl StoragePeerS3ClientExt for LocalPeerS3Client { async fn heal_bucket_with_fence( &self, bucket: &str, - opts: &rustfs_common::heal_channel::HealOpts, + opts: &rustfs_heal_contracts::heal_channel::HealOpts, fenced_pools: &[usize], ) -> DiskResult { ecstore_rpc::PeerS3Client::heal_bucket_with_fence(self, bucket, opts, fenced_pools).await From 7c2361757e8fae1cb631516317a8f02495abd69d Mon Sep 17 00:00:00 2001 From: cxymds Date: Wed, 26 Aug 2026 21:24:37 +0800 Subject: [PATCH 02/54] fix(ecstore): bound copy-source shard read-ahead (#6663) --- crates/ecstore/src/cluster/rpc/remote_disk.rs | 580 +++++++- crates/ecstore/src/disk/error.rs | 112 ++ crates/ecstore/src/erasure/coding/decode.rs | 1258 +++++++++++++++-- crates/ecstore/src/io_support/bitrot.rs | 15 +- .../src/set_disk/core/io_primitives.rs | 94 ++ crates/ecstore/src/set_disk/mod.rs | 95 ++ crates/ecstore/src/set_disk/ops/object.rs | 122 +- crates/ecstore/src/set_disk/read.rs | 80 +- crates/ecstore/src/store/object.rs | 27 + rustfs/src/app/multipart_usecase.rs | 8 +- rustfs/src/app/object_usecase.rs | 11 +- rustfs/src/app/storage_api.rs | 4 +- scripts/run_get_codec_streaming_smoke.sh | 1 + 13 files changed, 2262 insertions(+), 145 deletions(-) diff --git a/crates/ecstore/src/cluster/rpc/remote_disk.rs b/crates/ecstore/src/cluster/rpc/remote_disk.rs index 6410cf6d6..2116f7fb5 100644 --- a/crates/ecstore/src/cluster/rpc/remote_disk.rs +++ b/crates/ecstore/src/cluster/rpc/remote_disk.rs @@ -35,7 +35,11 @@ use crate::disk::{ health_state::{RuntimeDriveHealthState, get_drive_returning_probe_interval, record_drive_runtime_state}, validate_batch_read_version_item_count, }; -use crate::disk::{disk_store::DiskHealthTracker, error::DiskError, local::ScanGuard}; +use crate::disk::{ + disk_store::DiskHealthTracker, + error::{DiskError, is_terminal_read_error, terminal_read_error_to_io}, + local::ScanGuard, +}; use crate::set_disk::DEFAULT_READ_BUFFER_SIZE; use bytes::Bytes; use futures::lock::Mutex; @@ -324,6 +328,39 @@ where } } +/// Mark a terminal fresh-shard recovery failure for adaptive retirement while +/// retaining its typed `DiskError` and original I/O kind. The decoder checks +/// the marker independently of the kind because not-found and transport +/// failures are terminal too, but must not be reported as timeouts. +fn remote_read_error_to_io(error: DiskError) -> io::Error { + terminal_read_error_to_io(error) +} + +/// Retire a remote shard after its stream can no longer be trusted. A body +/// error that arrives after the one permitted resume is terminal: retaining +/// the reader would let the next stripe poll an already misaligned stream. +fn remote_terminal_io_error(error: io::Error) -> io::Error { + if is_terminal_read_error(&error) { + return error; + } + terminal_read_error_to_io(DiskError::from(error)) +} + +fn remote_terminal_message_to_io(message: &'static str) -> io::Error { + terminal_read_error_to_io(DiskError::Io(io::Error::other(message))) +} + +fn remote_terminal_eof_to_io() -> io::Error { + terminal_read_error_to_io(DiskError::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + "remote read ended before requested length", + ))) +} + +fn remote_terminal_task_error_to_io(error: JoinError) -> io::Error { + terminal_read_error_to_io(DiskError::other(error)) +} + struct AbortOnDropTask(JoinHandle); impl AbortOnDropTask { @@ -418,6 +455,9 @@ impl RetryingRemoteReader { impl AsyncRead for RetryingRemoteReader { fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if buf.remaining() == 0 { + return Poll::Ready(Ok(())); + } loop { // After the absolute cutoff, let initial progress win over a stale fresh-open. let resume_pending = if let Some(resume) = self.resume.as_mut() { @@ -431,14 +471,14 @@ impl AsyncRead for RetryingRemoteReader { Poll::Ready(Ok(Err(error))) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other(error))); + return Poll::Ready(Err(remote_read_error_to_io(error))); } continue; } Poll::Ready(Err(error)) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other(error))); + return Poll::Ready(Err(remote_terminal_task_error_to_io(error))); } continue; } @@ -479,6 +519,9 @@ impl AsyncRead for RetryingRemoteReader { } else { self.resume = None; } + } else if produced == 0 && self.request.length != 0 && self.emitted < self.request.length { + self.reader = None; + return Poll::Ready(Err(remote_terminal_eof_to_io())); } return Poll::Ready(Ok(())); } @@ -494,7 +537,10 @@ impl AsyncRead for RetryingRemoteReader { self.reader = None; continue; } - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Err(error)) => { + self.reader = None; + return Poll::Ready(Err(remote_terminal_io_error(error))); + } } } } @@ -585,21 +631,23 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader { Poll::Ready(Ok(Ok(None))) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other("remote resume transport did not provide a chunk reader"))); + return Poll::Ready(Err(remote_terminal_message_to_io( + "remote resume transport did not provide a chunk reader", + ))); } continue; } Poll::Ready(Ok(Err(error))) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other(error))); + return Poll::Ready(Err(remote_read_error_to_io(error))); } continue; } Poll::Ready(Err(error)) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other(error))); + return Poll::Ready(Err(remote_terminal_task_error_to_io(error))); } continue; } @@ -635,10 +683,26 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader { return Poll::Ready(Ok(Some(chunk))); } Poll::Ready(Ok(None)) if resume_pending => { + // A clean EOF from the original stream wins when the + // request is unbounded (or has already emitted its full + // bounded length). Waiting for a speculative fresh open + // in that case can turn a successful read into a recovery + // timeout, especially on the read_file/unbounded path. + if self.request.length == 0 || self.emitted >= self.request.length { + self.reader = None; + self.resume = None; + return Poll::Ready(Ok(None)); + } self.reader = None; continue; } - Poll::Ready(Ok(None)) => return Poll::Ready(Ok(None)), + Poll::Ready(Ok(None)) => { + if self.request.length != 0 && self.emitted < self.request.length { + self.reader = None; + return Poll::Ready(Err(remote_terminal_eof_to_io())); + } + return Poll::Ready(Ok(None)); + } Poll::Ready(Err(error)) if !self.retried && is_retryable_remote_body_error(&error) => { self.retried = true; self.reader = None; @@ -651,7 +715,10 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader { self.reader = None; continue; } - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Err(error)) => { + self.reader = None; + return Poll::Ready(Err(remote_terminal_io_error(error))); + } } } } @@ -5154,6 +5221,7 @@ mod tests { enum ResumeReadStep { PartialThenReset(Vec), Data(Vec), + Eof, } #[derive(Debug, Default)] @@ -5412,6 +5480,7 @@ mod tests { struct PendingFreshOpenTransport { fresh_read_drops: Arc, fresh_chunk_drops: Arc, + initial_chunk_eof: bool, } #[async_trait::async_trait] @@ -5429,6 +5498,9 @@ mod tests { } async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result> { + if self.initial_chunk_eof { + return Ok(Some(resume_step_chunk_reader(ResumeReadStep::Eof))); + } Ok(Some(Box::new(ChunkPartialThenErrorReader { data: None, error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")), @@ -5457,6 +5529,70 @@ mod tests { } } + #[derive(Debug)] + struct TerminalFreshOpenTransport { + fresh_read_opens: Arc, + fresh_chunk_opens: Arc, + chunk_returns_none: bool, + } + + impl TerminalFreshOpenTransport { + fn new(chunk_returns_none: bool) -> Self { + Self { + fresh_read_opens: Arc::new(AtomicUsize::new(0)), + fresh_chunk_opens: Arc::new(AtomicUsize::new(0)), + chunk_returns_none, + } + } + } + + #[async_trait::async_trait] + impl InternodeDataTransport for TerminalFreshOpenTransport { + async fn open_read(&self, _request: ReadStreamRequest) -> Result { + Ok(Box::new(PartialThenErrorReader { + cursor: Cursor::new(Vec::new()), + error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")), + })) + } + + async fn open_read_fresh(&self, _request: ReadStreamRequest) -> Result { + self.fresh_read_opens.fetch_add(1, Ordering::Relaxed); + Err(DiskError::FileNotFound) + } + + async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result> { + Ok(Some(Box::new(ChunkPartialThenErrorReader { + data: Some(Bytes::from_static(b"x")), + error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")), + }))) + } + + async fn open_read_chunks_fresh(&self, _request: ReadStreamRequest) -> Result> { + self.fresh_chunk_opens.fetch_add(1, Ordering::Relaxed); + if self.chunk_returns_none { + Ok(None) + } else { + Err(DiskError::FileNotFound) + } + } + + async fn open_write(&self, _request: WriteStreamRequest) -> Result { + panic!("open_write should not be used in terminal fresh-open tests"); + } + + async fn open_walk_dir(&self, _request: WalkDirStreamRequest) -> Result { + panic!("open_walk_dir should not be used in terminal fresh-open tests"); + } + + fn name(&self) -> &'static str { + "terminal-fresh-open-test" + } + + fn capabilities(&self) -> InternodeDataTransportCapabilities { + InternodeDataTransportCapabilities::tcp_http() + } + } + fn resume_step_reader(step: ResumeReadStep) -> FileReader { match step { ResumeReadStep::PartialThenReset(data) => Box::new(PartialThenErrorReader { @@ -5464,6 +5600,7 @@ mod tests { error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")), }), ResumeReadStep::Data(data) => Box::new(Cursor::new(data)), + ResumeReadStep::Eof => Box::new(Cursor::new(Vec::new())), } } @@ -5477,6 +5614,7 @@ mod tests { data: Some(Bytes::from(data)), error: None, }), + ResumeReadStep::Eof => Box::new(ChunkPartialThenErrorReader { data: None, error: None }), } } @@ -5553,6 +5691,430 @@ mod tests { } } + fn partial_hashed_shard(shard_size: usize) -> (rustfs_utils::HashAlgorithm, Vec, usize) { + let checksum = rustfs_utils::HashAlgorithm::HighwayHash256S; + let data = vec![0x5a; shard_size]; + let hash_bytes = { + let hash = checksum.hash_encode(&data); + hash.as_ref().to_vec() + }; + let hash_len = hash_bytes.len(); + let encoded_length = hash_len + data.len(); + let mut prefix = Vec::with_capacity(hash_len + shard_size / 2); + prefix.extend_from_slice(&hash_bytes); + prefix.extend_from_slice(&data[..shard_size / 2]); + (checksum, prefix, encoded_length) + } + + #[test] + fn remote_read_error_conversion_preserves_recovery_classification() { + for disk_error in [DiskError::Timeout, DiskError::SourceStalled] { + let error = remote_read_error_to_io(disk_error); + assert_eq!(error.kind(), std_io::ErrorKind::TimedOut); + } + + let error = remote_read_error_to_io(DiskError::Timeout); + assert!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .is_some() + ); + assert!(matches!(DiskError::from(error), DiskError::Timeout)); + + let error = remote_read_error_to_io(DiskError::SourceStalled); + assert!(matches!(DiskError::from(error), DiskError::SourceStalled)); + + let error = + remote_read_error_to_io(DiskError::Io(io::Error::new(std_io::ErrorKind::ConnectionReset, "connection reset"))); + assert_eq!(error.kind(), std_io::ErrorKind::ConnectionReset); + assert!(crate::disk::error::is_terminal_read_error(&error)); + assert!(matches!(DiskError::from(error), DiskError::Io(inner) if inner.kind() == std_io::ErrorKind::ConnectionReset)); + } + + #[test] + fn remote_reader_zero_capacity_poll_is_a_noop() { + let transport: Arc = Arc::new(PendingFreshOpenTransport::default()); + let mut reader = RetryingRemoteReader::new_with_timeouts( + Box::new(Cursor::new(b"x".to_vec())), + transport, + resume_request(1), + None, + None, + ); + let mut empty = []; + let mut read_buf = ReadBuf::new(&mut empty); + let mut cx = Context::from_waker(std::task::Waker::noop()); + + assert!(matches!(Pin::new(&mut reader).poll_read(&mut cx, &mut read_buf), Poll::Ready(Ok(())))); + assert!(reader.reader.is_some(), "zero-capacity polls must not retire the remote reader"); + + let mut output = Vec::new(); + futures::executor::block_on(reader.read_to_end(&mut output)).expect("the reader should remain usable"); + assert_eq!(output, b"x"); + } + + #[tokio::test(start_paused = true)] + async fn remote_reader_fresh_open_timeout_preserves_timed_out_kind() { + let transport = Arc::new(PendingFreshOpenTransport::default()); + let transport_for_reader: Arc = transport.clone(); + let mut reader = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(Vec::new())), + transport_for_reader, + resume_request(1), + None, + Some(Duration::from_secs(1)), + ); + + let error = reader + .read_to_end(&mut Vec::new()) + .await + .expect_err("a hung fresh open must surface its recovery timeout"); + + assert_eq!(error.kind(), std_io::ErrorKind::TimedOut); + assert!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .is_some() + ); + assert!(matches!(DiskError::from(error), DiskError::Timeout)); + assert_eq!(transport.fresh_read_drops.load(Ordering::Relaxed), 1); + } + + #[tokio::test(start_paused = true)] + async fn remote_chunk_reader_fresh_open_timeout_preserves_timed_out_kind() { + let transport = Arc::new(PendingFreshOpenTransport::default()); + let transport_for_reader: Arc = transport.clone(); + let mut reader = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"x".to_vec())), + transport_for_reader, + resume_request(2), + None, + Some(Duration::from_secs(1)), + ); + + let error = reader + .read_to_end(&mut Vec::new()) + .await + .expect_err("a hung fresh chunk open must surface its recovery timeout"); + + assert_eq!(error.kind(), std_io::ErrorKind::TimedOut); + assert!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .is_some() + ); + assert!(matches!(DiskError::from(error), DiskError::Timeout)); + assert_eq!(transport.fresh_chunk_drops.load(Ordering::Relaxed), 1); + } + + #[tokio::test(start_paused = true)] + async fn remote_chunk_reader_unbounded_clean_eof_wins_over_speculative_resume() { + let transport = Arc::new(PendingFreshOpenTransport { + initial_chunk_eof: true, + ..PendingFreshOpenTransport::default() + }); + let transport_for_reader: Arc = transport.clone(); + let mut reader = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::Eof), + transport_for_reader, + resume_request(0), + Some(Duration::ZERO), + Some(Duration::from_secs(1)), + ); + + let mut output = Vec::new(); + reader + .read_to_end(&mut output) + .await + .expect("clean EOF from an unbounded original stream should finish the read"); + assert!(output.is_empty()); + // The executor may abort the speculative task before it is first + // polled, in which case the pending-open future never constructs its + // drop probe. The dedicated drop-cancellation tests cover the + // already-polled case; this regression only needs to establish that a + // clean unbounded EOF is not converted into a recovery timeout. + } + + #[tokio::test(start_paused = true)] + async fn remote_reader_fresh_open_non_timeout_error_is_retired_from_adaptive_decode() { + let transport = Arc::new(TerminalFreshOpenTransport::new(false)); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(Vec::new())), + transport_for_reader, + resume_request(8), + None, + Some(Duration::from_secs(1)), + ); + let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 8, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 8), 0, 16); + + let (_, first_errors) = parallel.read().await; + assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!(transport.fresh_read_opens.load(Ordering::Relaxed), 1); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!(transport.fresh_read_opens.load(Ordering::Relaxed), 1); + } + + #[tokio::test(start_paused = true)] + async fn remote_chunk_reader_missing_fresh_reader_is_retired_from_adaptive_decode() { + let transport = Arc::new(TerminalFreshOpenTransport::new(true)); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"x".to_vec())), + transport_for_reader, + resume_request(8), + None, + Some(Duration::from_secs(1)), + ); + let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 8, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 8), 0, 16); + + let (_, first_errors) = parallel.read().await; + assert!( + matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::Other), + "unexpected first errors: {first_errors:?}" + ); + assert_eq!(transport.fresh_chunk_opens.load(Ordering::Relaxed), 1); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!(transport.fresh_chunk_opens.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn remote_reader_fresh_open_short_eof_is_retired_from_adaptive_decode() { + // A successful fresh open can still end before the bounded request. + // Treat that as terminal immediately so the next stripe does not poll + // an already exhausted reader and defer the failure to BitrotReader. + let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Eof])); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())), + transport_for_reader, + resume_request(7), + None, + None, + ); + let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14); + + let (_, first_errors) = parallel.read().await; + assert!(matches!( + first_errors.first().and_then(Option::as_ref), + Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::UnexpectedEof + )); + assert_eq!( + transport + .fresh_read_requests + .lock() + .expect("fresh read request lock should not be poisoned") + .len(), + 1 + ); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!( + transport + .fresh_read_requests + .lock() + .expect("fresh read request lock should not be poisoned") + .len(), + 1 + ); + } + + #[tokio::test] + async fn remote_chunk_reader_fresh_open_short_eof_is_retired_from_adaptive_decode() { + let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::Eof])); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())), + transport_for_reader, + resume_request(7), + None, + None, + ); + let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14); + + let (_, first_errors) = parallel.read().await; + assert!(matches!( + first_errors.first().and_then(Option::as_ref), + Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::UnexpectedEof + )); + assert_eq!( + transport + .fresh_chunk_requests + .lock() + .expect("fresh chunk request lock should not be poisoned") + .len(), + 1 + ); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!( + transport + .fresh_chunk_requests + .lock() + .expect("fresh chunk request lock should not be poisoned") + .len(), + 1 + ); + } + + #[tokio::test] + async fn remote_reader_second_body_reset_is_retired_from_adaptive_decode() { + // The first connection emits a prefix, the one permitted fresh + // connection emits another prefix, and then resets again. The second + // reset must retire the reader so the next stripe cannot consume a + // misaligned stream. + let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::PartialThenReset(b"23".to_vec())])); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())), + transport_for_reader, + resume_request(7), + None, + None, + ); + let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14); + + let (_, first_errors) = parallel.read().await; + assert!(matches!( + first_errors.first().and_then(Option::as_ref), + Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::ConnectionReset + )); + assert_eq!( + transport + .fresh_read_requests + .lock() + .expect("fresh read request lock should not be poisoned") + .len(), + 1 + ); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!( + transport + .fresh_read_requests + .lock() + .expect("fresh read request lock should not be poisoned") + .len(), + 1 + ); + } + + #[tokio::test] + async fn remote_chunk_reader_second_body_reset_is_retired_from_adaptive_decode() { + let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::PartialThenReset(b"23".to_vec())])); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())), + transport_for_reader, + resume_request(7), + None, + None, + ); + let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14); + + let (_, first_errors) = parallel.read().await; + assert!(matches!( + first_errors.first().and_then(Option::as_ref), + Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::ConnectionReset + )); + assert_eq!( + transport + .fresh_chunk_requests + .lock() + .expect("fresh chunk request lock should not be poisoned") + .len(), + 1 + ); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!( + transport + .fresh_chunk_requests + .lock() + .expect("fresh chunk request lock should not be poisoned") + .len(), + 1 + ); + } + + #[tokio::test(start_paused = true)] + #[serial] + async fn remote_reader_hashed_timeout_is_retired_from_adaptive_decode() { + temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, Some("5"))], async { + const SHARD_SIZE: usize = 64; + let (checksum, encoded_prefix, encoded_length) = partial_hashed_shard(SHARD_SIZE); + let transport = Arc::new(PendingFreshOpenTransport::default()); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(encoded_prefix)), + transport_for_reader, + resume_request(encoded_length), + None, + Some(Duration::from_secs(1)), + ); + let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), SHARD_SIZE, checksum, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, SHARD_SIZE), 0, SHARD_SIZE * 2); + + let (first_buffers, first_errors) = parallel.read().await; + assert!(first_buffers.first().and_then(Option::as_ref).is_none()); + assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Timeout))); + assert_eq!(transport.fresh_read_drops.load(Ordering::Relaxed), 1); + + // A TimedOut error retires the dead slot. The next stripe therefore + // reports the slot as unavailable instead of polling a reader whose + // fresh connection already timed out. + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + }) + .await; + } + + #[tokio::test(start_paused = true)] + #[serial] + async fn remote_chunk_reader_hashed_timeout_is_retired_from_adaptive_decode() { + temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, Some("5"))], async { + const SHARD_SIZE: usize = 64; + let (checksum, encoded_prefix, encoded_length) = partial_hashed_shard(SHARD_SIZE); + let transport = Arc::new(PendingFreshOpenTransport::default()); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(encoded_prefix)), + transport_for_reader, + resume_request(encoded_length), + None, + Some(Duration::from_secs(1)), + ); + let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), SHARD_SIZE, checksum, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, SHARD_SIZE), 0, SHARD_SIZE * 2); + + let (first_buffers, first_errors) = parallel.read().await; + assert!(first_buffers.first().and_then(Option::as_ref).is_none()); + assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Timeout))); + assert_eq!(transport.fresh_chunk_drops.load(Ordering::Relaxed), 1); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + }) + .await; + } + #[tokio::test] async fn remote_reader_resumes_from_emitted_bytes_without_duplicates() { let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Data(b"456789".to_vec())])); diff --git a/crates/ecstore/src/disk/error.rs b/crates/ecstore/src/disk/error.rs index 6f771f790..dff44d78b 100644 --- a/crates/ecstore/src/disk/error.rs +++ b/crates/ecstore/src/disk/error.rs @@ -23,6 +23,16 @@ pub type Result = core::result::Result; const METACACHE_OUTPUT_STREAM_CLOSED: &str = "metacache output stream closed"; +/// Marker carried by a shard-read `io::Error` when the underlying reader can +/// no longer be realigned after a fresh remote open failed. The marker is +/// deliberately separate from the `ErrorKind`: a terminal read must retire +/// its reader, while its original typed disk error and I/O kind still need to +/// survive quorum/error mapping. +#[derive(Debug)] +pub(crate) struct TerminalReadError { + source: DiskError, +} + // DiskError == StorageErr #[derive(Debug, thiserror::Error)] pub enum DiskError { @@ -168,6 +178,67 @@ pub enum DiskError { RemoteClientUnavailable(String), } +impl TerminalReadError { + pub(crate) fn new(source: DiskError) -> Self { + Self { source } + } + + fn into_source(self) -> DiskError { + self.source + } +} + +impl std::fmt::Display for TerminalReadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.fmt(f) + } +} + +impl StdError for TerminalReadError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&self.source) + } +} + +fn classify_internode_missing_error(error: &InternodeHttpError) -> Option { + if error.is_remote_file_not_found() { + return Some(DiskError::FileNotFound); + } + if error.is_remote_volume_not_found() { + return Some(DiskError::VolumeNotFound); + } + None +} + +/// Wrap a terminal shard-read failure without changing its typed +/// classification. Timeout-like disk errors retain `TimedOut`; other errors +/// retain their inner I/O kind or use `Other` when no more specific kind exists. +pub(crate) fn terminal_read_error_to_io(error: DiskError) -> io::Error { + let kind = match &error { + DiskError::Io(inner) => inner.kind(), + DiskError::SourceStalled | DiskError::Timeout => io::ErrorKind::TimedOut, + DiskError::DiskNotFound + | DiskError::FileNotFound + | DiskError::FileVersionNotFound + | DiskError::PathNotFound + | DiskError::VolumeNotFound => io::ErrorKind::NotFound, + DiskError::DiskAccessDenied | DiskError::FileAccessDenied | DiskError::VolumeAccessDenied => { + io::ErrorKind::PermissionDenied + } + DiskError::DiskFull => io::ErrorKind::StorageFull, + DiskError::FileCorrupt | DiskError::PartMissingOrCorrupt | DiskError::BitrotHashAlgoInvalid => io::ErrorKind::InvalidData, + _ => io::ErrorKind::Other, + }; + io::Error::new(kind, TerminalReadError::new(error)) +} + +/// Whether an I/O error marks a shard reader as terminal for adaptive decode. +pub(crate) fn is_terminal_read_error(error: &io::Error) -> bool { + error + .get_ref() + .is_some_and(|source| source.downcast_ref::().is_some()) +} + impl From for DiskError { fn from(error: crate::erasure::coding::ErasureConstructionError) -> Self { Self::Io(error.into_io_error()) @@ -344,6 +415,21 @@ impl From for DiskError { return DiskError::VolumeNotFound; } } + let e = match e.downcast::() { + Ok(terminal_error) => { + let source = terminal_error.into_source(); + if let DiskError::Io(io_error) = &source + && let Some(internode_error) = io_error + .get_ref() + .and_then(|source| source.downcast_ref::()) + && let Some(classified) = classify_internode_missing_error(internode_error) + { + return classified; + } + return source; + } + Err(e) => e, + }; match e.downcast::() { Ok(disk_error) => disk_error, // Mirror `From for StorageError`: a StorageError boxed @@ -679,6 +765,32 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn terminal_read_error_preserves_kind_and_disk_classification() { + let timeout = terminal_read_error_to_io(DiskError::Timeout); + assert_eq!(timeout.kind(), io::ErrorKind::TimedOut); + assert!(is_terminal_read_error(&timeout)); + assert!(matches!(DiskError::from(timeout), DiskError::Timeout)); + + let missing = terminal_read_error_to_io(DiskError::FileNotFound); + assert_eq!(missing.kind(), io::ErrorKind::NotFound); + assert!(is_terminal_read_error(&missing)); + assert!(matches!(DiskError::from(missing), DiskError::FileNotFound)); + + let reset = terminal_read_error_to_io(DiskError::Io(io::Error::new(io::ErrorKind::ConnectionReset, "connection reset"))); + assert_eq!(reset.kind(), io::ErrorKind::ConnectionReset); + assert!(is_terminal_read_error(&reset)); + assert!(matches!(DiskError::from(reset), DiskError::Io(error) if error.kind() == io::ErrorKind::ConnectionReset)); + + for (remote_error, expected) in [ + (rustfs_rio::new_test_remote_file_not_found_http_io_error(), DiskError::FileNotFound), + (rustfs_rio::new_test_remote_volume_not_found_http_io_error(), DiskError::VolumeNotFound), + ] { + let wrapped = terminal_read_error_to_io(DiskError::Io(remote_error)); + assert_eq!(DiskError::from(wrapped), expected); + } + } + #[test] fn other_preserves_erasure_construction_source_chain() { use crate::erasure::coding::ErasureConstructionError; diff --git a/crates/ecstore/src/erasure/coding/decode.rs b/crates/ecstore/src/erasure/coding/decode.rs index 8b616c058..7c8b38b54 100644 --- a/crates/ecstore/src/erasure/coding/decode.rs +++ b/crates/ecstore/src/erasure/coding/decode.rs @@ -20,7 +20,7 @@ use crate::diagnostics::get::{ record_get_object_pipeline_failure, record_get_stage_duration_if_enabled, }; use crate::disk::disk_store::get_object_disk_read_timeout; -use crate::disk::error::Error; +use crate::disk::error::{Error, is_terminal_read_error}; use crate::disk::error_reduce::reduce_errs; use crate::erasure::codec::workspace::ShardBufferPool; use crate::erasure::coding::{BitrotReader, Erasure}; @@ -36,12 +36,16 @@ use std::future::Future; use std::io; use std::io::ErrorKind; use std::pin::Pin; +use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::io::AsyncWrite; use tokio::io::AsyncWriteExt; use tracing::{debug, error, warn}; type ShardReadFuture<'a> = Pin, Error>, bool)> + Send + 'a>>; +type OwnedShardReadFuture<'a, R> = + Pin, Error>, Option>, bool)> + Send + 'a>>; +pub(crate) type DeferredReaderReopener = Arc Option> + Send + Sync>; type ShardIndexes = SmallVec<[usize; INLINE_SHARD_SLOTS]>; type ActiveReaders = SmallVec<[bool; INLINE_SHARD_SLOTS]>; @@ -56,6 +60,41 @@ const SHARD_LOCALITY_SCHEDULING_OFF: &str = "off"; const SHARD_LOCALITY_SCHEDULING_OBSERVE: &str = "observe"; const SHARD_LOCALITY_SCHEDULING_ON: &str = "on"; +/// Read-ahead contract selected by the caller of the erasure decoder. +/// +/// Ordinary GETs retain the configured overlap and all-shard lockstep +/// behavior. A server-side copy holds its source while the destination can +/// apply backpressure, so it uses the demand-bound variant: no speculative +/// stripe read and data shards only until reconstruction needs parity. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum DecodeReadPolicy { + #[default] + Default, + DemandBound, +} + +/// Maximum number of deferred parity reads that a demand-bound stripe may have +/// in flight at once. The window is deliberately small: a 16+16 layout must +/// not turn one slow data shard into 16 simultaneous HTTP/H2 opens. The +/// window is refilled as results arrive, so larger erasure sets still make +/// progress without an unbounded fan-out. +const MAX_DEMAND_BOUND_PARITY_IN_FLIGHT: usize = 4; + +tokio::task_local! { + static DECODE_READ_POLICY: DecodeReadPolicy; +} + +pub(crate) fn decode_read_policy() -> DecodeReadPolicy { + DECODE_READ_POLICY.try_with(|policy| *policy).unwrap_or_default() +} + +pub(crate) async fn with_decode_read_policy(policy: DecodeReadPolicy, future: F) -> F::Output +where + F: Future, +{ + DECODE_READ_POLICY.scope(policy, future).await +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShardLocalitySchedulingMode { Off, @@ -136,10 +175,11 @@ const DEFAULT_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE: bool = false; /// Whether the data-shards-only lockstep GET read is enabled (backlog#923). pub(crate) fn get_lockstep_data_shards_only_enabled() -> bool { - rustfs_utils::get_env_bool( - ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, - DEFAULT_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, - ) + matches!(decode_read_policy(), DecodeReadPolicy::DemandBound) + || rustfs_utils::get_env_bool( + ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, + DEFAULT_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, + ) } /// Get whether bitrot-decode overlap is enabled. @@ -170,7 +210,8 @@ fn is_bitrot_decode_overlap_enabled() -> bool { /// pre-existing strictly-serial read → reconstruct → emit behaviour, byte for /// byte. fn legacy_stripe_prefetch_enabled() -> bool { - get_decode_stripe_prefetch_count() > 1 || is_bitrot_decode_overlap_enabled() + !matches!(decode_read_policy(), DecodeReadPolicy::DemandBound) + && (get_decode_stripe_prefetch_count() > 1 || is_bitrot_decode_overlap_enabled()) } /// Outcome of reconstructing and emitting a single already-read stripe in the @@ -268,6 +309,92 @@ fn shard_role(index: usize, data_shards: usize) -> &'static str { } } +#[allow(clippy::too_many_arguments)] +async fn read_shard_result( + index: usize, + read_cost: ShardReadCost, + reader: &mut BitrotReader, + recycled_buf: Option>, + shard_size: usize, + data_shards: usize, + read_timeout: Duration, + metrics_path: Option<&'static str>, +) -> (Result, Error>, bool) +where + R: crate::erasure::coding::ShardSource, +{ + let role = shard_role(index, data_shards); + // Capacity, not length: `read_appending` writes every byte it returns, so + // the buffer never needs zeroing first (rustfs/backlog#1159). + let mut buf = recycled_buf.unwrap_or_else(|| Vec::with_capacity(shard_size)); + buf.clear(); + let read_start = metrics_path.map(|_| Instant::now()); + let read_result = if read_timeout.is_zero() { + reader.read_appending(&mut buf, shard_size).await + } else { + match tokio::time::timeout(read_timeout, reader.read_appending(&mut buf, shard_size)).await { + Ok(result) => result, + Err(_) => { + let timeout_error = io::Error::new(ErrorKind::TimedOut, "shard read timed out"); + let error_class = classify_io_error(&timeout_error).as_str(); + if let Some(path) = metrics_path { + rustfs_io_metrics::record_get_object_shard_read_observation( + path, + index, + role, + read_cost.as_str(), + GET_SHARD_READ_OUTCOME_ERROR, + error_class, + 0, + read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), + reader.last_verify_duration().as_secs_f64(), + ); + } + return (Err(Error::from(timeout_error)), true); + } + } + }; + + match read_result { + Ok(n) => { + debug_assert_eq!(buf.len(), n, "read_appending must grow the buffer by exactly n"); + if let Some(path) = metrics_path { + rustfs_io_metrics::record_get_object_shard_read_observation( + path, + index, + role, + read_cost.as_str(), + GET_SHARD_READ_OUTCOME_SUCCESS, + GET_SHARD_READ_ERROR_NONE, + n, + read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), + reader.last_verify_duration().as_secs_f64(), + ); + } + (Ok(buf), false) + } + Err(e) => { + let verify_duration_secs = reader.last_verify_duration().as_secs_f64(); + let error_class = classify_io_error(&e).as_str(); + let should_retire = e.kind() == ErrorKind::TimedOut || is_terminal_read_error(&e); + if let Some(path) = metrics_path { + rustfs_io_metrics::record_get_object_shard_read_observation( + path, + index, + role, + read_cost.as_str(), + GET_SHARD_READ_OUTCOME_ERROR, + error_class, + 0, + read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), + verify_duration_secs, + ); + } + (Err(Error::from(e)), should_retire) + } + } +} + #[allow(clippy::too_many_arguments)] fn read_shard<'a, R>( index: usize, @@ -285,75 +412,18 @@ where let role = shard_role(index, data_shards); if let Some(reader) = reader { Box::pin(async move { - // Capacity, not length: `read_appending` writes every byte it returns, so - // the buffer never needs zeroing first (rustfs/backlog#1159). - let mut buf = recycled_buf.unwrap_or_else(|| Vec::with_capacity(shard_size)); - buf.clear(); - let read_start = metrics_path.map(|_| Instant::now()); - let read_result = if read_timeout.is_zero() { - reader.read_appending(&mut buf, shard_size).await - } else { - match tokio::time::timeout(read_timeout, reader.read_appending(&mut buf, shard_size)).await { - Ok(result) => result, - Err(_) => { - let timeout_error = io::Error::new(ErrorKind::TimedOut, "shard read timed out"); - let error_class = classify_io_error(&timeout_error).as_str(); - if let Some(path) = metrics_path { - rustfs_io_metrics::record_get_object_shard_read_observation( - path, - index, - role, - read_cost.as_str(), - GET_SHARD_READ_OUTCOME_ERROR, - error_class, - 0, - read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), - reader.last_verify_duration().as_secs_f64(), - ); - } - return (index, read_cost, Err(Error::from(timeout_error)), true); - } - } - }; - - match read_result { - Ok(n) => { - debug_assert_eq!(buf.len(), n, "read_appending must grow the buffer by exactly n"); - if let Some(path) = metrics_path { - rustfs_io_metrics::record_get_object_shard_read_observation( - path, - index, - role, - read_cost.as_str(), - GET_SHARD_READ_OUTCOME_SUCCESS, - GET_SHARD_READ_ERROR_NONE, - n, - read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), - reader.last_verify_duration().as_secs_f64(), - ); - } - (index, read_cost, Ok(buf), false) - } - Err(e) => { - let verify_duration_secs = reader.last_verify_duration().as_secs_f64(); - let error_class = classify_io_error(&e).as_str(); - let should_retire = e.kind() == ErrorKind::TimedOut; - if let Some(path) = metrics_path { - rustfs_io_metrics::record_get_object_shard_read_observation( - path, - index, - role, - read_cost.as_str(), - GET_SHARD_READ_OUTCOME_ERROR, - error_class, - 0, - read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), - verify_duration_secs, - ); - } - (index, read_cost, Err(Error::from(e)), should_retire) - } - } + let (result, should_retire) = read_shard_result( + index, + read_cost, + reader, + recycled_buf, + shard_size, + data_shards, + read_timeout, + metrics_path, + ) + .await; + (index, read_cost, result, should_retire) }) } else { Box::pin(async move { @@ -375,6 +445,121 @@ where } } +#[allow(clippy::too_many_arguments)] +fn read_shard_owned<'a, R>( + index: usize, + read_cost: ShardReadCost, + reader: Option>, + recycled_buf: Option>, + shard_size: usize, + data_shards: usize, + read_timeout: Duration, + metrics_path: Option<&'static str>, +) -> OwnedShardReadFuture<'a, R> +where + R: crate::erasure::coding::ShardSource + 'a, +{ + let role = shard_role(index, data_shards); + Box::pin(async move { + let Some(mut reader) = reader else { + if let Some(path) = metrics_path { + rustfs_io_metrics::record_get_object_shard_read_observation( + path, + index, + role, + read_cost.as_str(), + GET_SHARD_READ_OUTCOME_MISSING, + GET_SHARD_READ_ERROR_MISSING, + 0, + 0.0, + 0.0, + ); + } + return (index, read_cost, Err(Error::FileNotFound), None, false); + }; + let (result, should_retire) = read_shard_result( + index, + read_cost, + &mut reader, + recycled_buf, + shard_size, + data_shards, + read_timeout, + metrics_path, + ) + .await; + (index, read_cost, result, Some(reader), should_retire) + }) +} + +#[allow(clippy::too_many_arguments)] +fn launch_owned_shard<'a, R>( + sets: &mut FuturesUnordered>, + readers: &mut [Option>], + buffers: &mut ShardBufferPool, + active: &mut [bool], + scheduled: &mut usize, + index: usize, + shard_size: usize, + data_shards: usize, + read_cost: ShardReadCost, + read_timeout: Duration, + metrics_path: Option<&'static str>, +) -> bool +where + R: crate::erasure::coding::ShardSource + 'a, +{ + let Some(reader) = readers.get_mut(index).and_then(Option::take) else { + return false; + }; + launch_owned_reader( + sets, + buffers, + active, + scheduled, + index, + reader, + shard_size, + data_shards, + read_cost, + read_timeout, + metrics_path, + ) +} + +#[allow(clippy::too_many_arguments)] +fn launch_owned_reader<'a, R>( + sets: &mut FuturesUnordered>, + buffers: &mut ShardBufferPool, + active: &mut [bool], + scheduled: &mut usize, + index: usize, + reader: BitrotReader, + shard_size: usize, + data_shards: usize, + read_cost: ShardReadCost, + read_timeout: Duration, + metrics_path: Option<&'static str>, +) -> bool +where + R: crate::erasure::coding::ShardSource + 'a, +{ + let recycled_buf = Some(buffers.take(index, shard_size)); + *scheduled += 1; + active[index] = true; + sets.push(read_shard_owned( + index, + read_cost, + Some(reader), + recycled_buf, + shard_size, + data_shards, + read_timeout, + metrics_path, + )); + true +} + pin_project! { pub(crate) struct ParallelReader { #[pin] @@ -400,6 +585,11 @@ pub(crate) struct ParallelReader { // it to the current stripe when it is engaged mid-object (backlog#923). engaged: SmallVec<[bool; INLINE_SHARD_SLOTS]>, deferred_handles: Vec>, + // Copy-source hedges use a fresh deferred reader so cancelling a hedge + // never consumes the unopened reader reserved for a later stripe. The + // vector is empty for callers that do not provide a reopen factory (tests + // and the ordinary GET path retain the handle-based behavior). + deferred_reopeners: Vec>>, stripe_index: usize, } } @@ -607,6 +797,7 @@ where stripe_state: None, engaged, deferred_handles: Vec::new(), + deferred_reopeners: Vec::new(), stripe_index: 0, } } @@ -622,6 +813,17 @@ where self.deferred_handles = handles; self } + + /// Attach factories for unopened parity readers. A factory must return a + /// reader already aligned to the requested stripe. Keeping the original + /// deferred reader in `self.readers` lets a cancelled hedge be discarded + /// without poisoning the next-stripe reserve. + pub(crate) fn with_deferred_parity_reopeners(mut self, mut reopeners: Vec>>) -> Self { + reopeners.resize_with(self.readers.len(), || None); + reopeners.truncate(self.readers.len()); + self.deferred_reopeners = reopeners; + self + } } #[allow(clippy::too_many_arguments)] @@ -675,6 +877,34 @@ fn shard_read_hedge_delay(read_timeout: Duration) -> Option { } } +/// Return the number of new deferred parity readers that may be admitted for +/// the current demand-bound stripe. The window is based on concrete state, +/// not on setup candidates: at most `missing_data + 1` parity reads are useful +/// for a verification quorum, and the global in-flight cap keeps a wide EC +/// layout from opening every remaining shard at once. As a read completes the +/// caller invokes this again, which refills one slot after a failure or a +/// successful-but-insufficient parity result. +fn demand_bound_parity_admission_limit(shards: &[Option>], active: &[bool], data_shards: usize) -> usize { + let missing_data = shards.iter().take(data_shards).filter(|shard| shard.is_none()).count(); + if missing_data == 0 { + return 0; + } + + let successes = shards.iter().filter(|shard| shard.is_some()).count(); + let needed_for_verification = (data_shards + 1).saturating_sub(successes); + let desired = missing_data + .saturating_add(1) + .min(needed_for_verification) + .min(MAX_DEMAND_BOUND_PARITY_IN_FLIGHT); + let active_parity = active + .iter() + .enumerate() + .skip(data_shards) + .filter(|(_, is_active)| **is_active) + .count(); + desired.saturating_sub(active_parity) +} + fn shard_locality_remote_avoid_potential(remote_scheduled: usize, low_cost_available: usize, data_shards: usize) -> usize { let theoretical_remote_needed = data_shards.saturating_sub(low_cost_available); remote_scheduled.saturating_sub(theoretical_remote_needed) @@ -1045,6 +1275,11 @@ where /// realigned (no pending deferred handle) is likewise retired instead of /// being read out of position. async fn read_lockstep(&mut self, state: &mut StripeReadState) { + if matches!(decode_read_policy(), DecodeReadPolicy::DemandBound) { + self.read_lockstep_demand_bound(state).await; + return; + } + let num_readers = self.readers.len(); state.reset(num_readers, self.data_shards); let shard_size = if self.offset + self.shard_size > self.shard_file_size { @@ -1102,6 +1337,7 @@ where } let data_shards = self.data_shards; + let read_timeout = self.read_timeout; let metrics_path = self.metrics_path; let locality_preference_enabled = self.locality_preference_enabled; @@ -1295,17 +1531,431 @@ where } } - /// Attempt to bring an as-yet-unread parity reader into the lockstep read - /// set at `stripe_index`. + /// Demand-bound lockstep stripe read used by server-side copy sources. /// - /// At stripe 0 every reader is still positioned at the stream start, so - /// engagement is trivially aligned. Past stripe 0 the parity reader must - /// still be an unopened deferred reader: its pending open offset is - /// advanced by `stripe_index` bitrot blocks (the `bitrot_encoded_range` - /// geometry) so its first read returns the current stripe. A parity reader - /// that cannot be realigned is retired for the rest of the object, - /// mirroring the retire-on-error rule: reading it would return an earlier - /// stripe and reintroduce the backlog#832 desync. + /// The ordinary lockstep path can cancel every in-flight reader once it + /// has a quorum because all of its parity readers are already engaged. + /// Copy sources keep parity unopened until a data reader is missing. A + /// hedge therefore has to race the deferred parity reads against the + /// original data reads and may retire the latter only after the parity has + /// produced an actual decode-plus-verification quorum. The futures own + /// their readers so disjoint data/parity slots can be admitted while the + /// other group is still pending; dropping an abandoned future retires its + /// stream without leaving a borrowed slot behind. + async fn read_lockstep_demand_bound(&mut self, state: &mut StripeReadState) { + let num_readers = self.readers.len(); + state.reset(num_readers, self.data_shards); + let shard_size = if self.offset + self.shard_size > self.shard_file_size { + self.shard_file_size - self.offset + } else { + self.shard_size + }; + + let (shards, errs) = state.parts_mut(); + if shard_size == 0 { + return; + } + + self.offset += shard_size; + let stripe_index = self.stripe_index; + self.stripe_index += 1; + self.buffers.ensure_slots(num_readers); + + // A data slot retired on an earlier stripe is already missing. The + // bounded parity launcher below admits enough substitutes before the + // first data future is polled, preserving the lockstep alignment. + let missing_data_readers = self.readers.iter().take(self.data_shards).filter(|r| r.is_none()).count(); + + let data_shards = self.data_shards; + let read_timeout = self.read_timeout; + let metrics_path = self.metrics_path; + let stripe_read_start = metrics_path.map(|_| Instant::now()); + let mut retire_readers = ShardIndexes::new(); + let mut scheduled = 0usize; + let mut success = 0usize; + let mut completed = 0usize; + let mut failed = 0usize; + let mut first_shard_recorded = false; + let mut active = vec![false; num_readers]; + let mut temporary_parity = vec![false; num_readers]; + // A deferred parity slot is attempted at most once per stripe. A + // failed disposable hedge keeps its unopened reserve for the next + // stripe, but must not be relaunched in a tight same-stripe retry + // loop (which would defeat the bounded fan-out and amplify a remote + // outage). + let mut attempted_parity = vec![false; num_readers]; + // Once a data reader has returned an error (or was already missing at + // setup), the loss is permanent for lockstep alignment. Use the + // deferred handle and keep parity engaged across subsequent stripes; + // disposable reopeners are reserved for an as-yet unresolved slow + // data reader. + let mut data_failure_seen = missing_data_readers > 0; + let mut sets: FuturesUnordered> = FuturesUnordered::new(); + // Once a deferred parity reader has been admitted, a concrete + // `data_shards + 1` result is enough to finish a degraded stripe and + // abandon only the still-pending readers. Setup counts never set this + // flag: they are candidates, not successful shards. + let mut fallback_admitted = false; + + // Move engaged readers into owned futures. This leaves the slots free + // so a deferred parity reader can be admitted while these reads wait. + for i in 0..num_readers { + if !self.engaged[i] || self.readers[i].is_none() { + continue; + } + let read_cost = self.read_costs.get(i).copied().unwrap_or(ShardReadCost::Unknown); + let _ = launch_owned_shard( + &mut sets, + &mut self.readers, + &mut self.buffers, + &mut active, + &mut scheduled, + i, + shard_size, + data_shards, + read_cost, + read_timeout, + metrics_path, + ); + } + + if missing_data_readers > 0 { + let want = (missing_data_readers + 1).min(MAX_DEMAND_BOUND_PARITY_IN_FLIGHT); + if self.launch_demand_bound_parity( + stripe_index, + want, + &mut sets, + &mut active, + &mut temporary_parity, + &mut attempted_parity, + false, + &mut scheduled, + shard_size, + data_shards, + read_timeout, + metrics_path, + ) > 0 + { + fallback_admitted = true; + } + } + + let hedge_delay = shard_read_hedge_delay(read_timeout); + let hedge_sleep = hedge_delay.map(tokio::time::sleep); + tokio::pin!(hedge_sleep); + let mut hedged = false; + + loop { + let item = if !hedged { + match hedge_sleep.as_mut().as_pin_mut() { + Some(sleep) => tokio::select! { + biased; + item = sets.next() => item, + _ = sleep => { + hedged = true; + // Do not cancel a pending data read based on setup + // counts. Admit every still-unengaged parity + // reader as a bounded hedge batch; only concrete + // successful results below can satisfy the quorum. + let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none()); + if data_missing { + let launched = self.launch_demand_bound_parity( + stripe_index, + demand_bound_parity_admission_limit(shards, &active, data_shards), + &mut sets, + &mut active, + &mut temporary_parity, + &mut attempted_parity, + true, + &mut scheduled, + shard_size, + data_shards, + read_timeout, + metrics_path, + ); + if launched > 0 { + fallback_admitted = true; + } + } + continue; + } + }, + None => sets.next().await, + } + } else { + sets.next().await + }; + + let Some((i, _read_cost, result, reader, should_retire)) = item else { + // A fast failure can drain the initial data futures before the + // hedge timer fires (and a zero timeout intentionally has no + // timer). Do not return a false quorum just because the + // FuturesUnordered is momentarily empty: admit the deferred + // parity candidates and race them now. + let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none()); + data_failure_seen |= errs.iter().take(data_shards).any(Option::is_some); + if data_missing && success <= data_shards { + let launched = self.launch_demand_bound_parity( + stripe_index, + demand_bound_parity_admission_limit(shards, &active, data_shards), + &mut sets, + &mut active, + &mut temporary_parity, + &mut attempted_parity, + !data_failure_seen, + &mut scheduled, + shard_size, + data_shards, + read_timeout, + metrics_path, + ); + if launched > 0 { + fallback_admitted = true; + continue; + } + } + break; + }; + let result_failed = result.is_err(); + active[i] = false; + completed += 1; + if !first_shard_recorded { + if let Some(path) = metrics_path { + record_get_stage_duration_if_enabled(path, GET_STAGE_STRIPE_READ_FIRST_SHARD, stripe_read_start); + } + first_shard_recorded = true; + } + + match result { + Ok(v) => { + shards[i] = Some(v); + success += 1; + // A successful reader consumed exactly one aligned stripe + // and remains usable on the following stripe. + if temporary_parity[i] { + // A reopener hedge is disposable. Keep the unopened + // reserve untouched even when the hedge wins: promoting + // the one-stripe reader would make every later healthy + // stripe read parity and would leave a reset/timeout + // without a way to reopen it at the next stripe. + drop(reader); + self.engaged[i] = false; + } else if !should_retire { + self.readers[i] = reader; + } + } + Err(e) => { + failed += 1; + if i < data_shards { + data_failure_seen = true; + } + if temporary_parity[i] { + // A disposable hedge reader is independent of the + // unopened deferred reserve. Its timeout/reset may be + // transient, so discard only the hedge and keep the + // reserve available for a later stripe. The factory + // already removed a slot when it could not produce an + // aligned reader at all; an error after launch must + // not turn that setup failure policy into permanent + // disk retirement. Do not publish this disposable + // error into `errs`: `emit_decoded_stripe` uses that + // vector for terminal FileNotFound/FileCorrupt + // attribution, and a speculative failure must not + // fail a stripe that later reaches a real quorum. + self.engaged[i] = false; + } else { + errs[i] = Some(e); + // Lockstep cannot safely reuse a reader after any + // error, even when the low-level classifier called it + // nonfatal. + self.readers[i] = None; + retire_readers.push(i); + } + } + } + + // A degraded stripe should keep a small parity window full. Admit + // it immediately after any result (especially a fast data error, + // or a zero-timeout read where no hedge timer exists). Refill one + // slot after a parity failure/success rather than opening every + // candidate at once. Pending data futures stay in `sets` and can + // still win the race if the source recovers. + let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none()); + if data_missing && success <= data_shards && (result_failed || fallback_admitted) { + let launched = self.launch_demand_bound_parity( + stripe_index, + demand_bound_parity_admission_limit(shards, &active, data_shards), + &mut sets, + &mut active, + &mut temporary_parity, + &mut attempted_parity, + !data_failure_seen, + &mut scheduled, + shard_size, + data_shards, + read_timeout, + metrics_path, + ); + if launched > 0 { + fallback_admitted = true; + } + } + + // Once a real quorum is present, abandon only the still-pending + // futures. If a parity read failed, the pending original data + // reader remains in the race and can still rescue the stripe; no + // optimistic setup count may retire it early. A healthy stripe + // can also finish as soon as every data shard has returned, even + // when the hedge timer has not fired. + let succeeded = shards.iter().filter(|shard| shard.is_some()).count(); + let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none()); + if !data_missing { + break; + } + if fallback_admitted && succeeded > data_shards { + break; + } + } + + // Dropping `sets` cancels all remaining owned reads. A temporary + // parity hedge has an untouched deferred reserve in `self.readers`, so + // it can be abandoned without poisoning the next stripe. All other + // active readers were consumed in this stripe and must be retired. + for i in 0..num_readers { + if active[i] { + if temporary_parity[i] { + // The factory reader is disposable; leave the original + // deferred reader unengaged and available for a later + // stripe. + self.engaged[i] = false; + continue; + } + if shards[i].is_none() && errs[i].is_none() { + errs[i] = Some(Error::from(io::Error::new(ErrorKind::TimedOut, "shard read hedged after a slow shard"))); + retire_readers.push(i); + } + self.readers[i] = None; + } + } + drop(sets); + + if let Some(path) = metrics_path { + record_get_stage_duration_if_enabled(path, GET_STAGE_STRIPE_READ_QUORUM, stripe_read_start); + rustfs_io_metrics::record_get_object_shard_read_fanout(path, scheduled, completed, success, failed); + } + + for i in retire_readers { + self.readers[i] = None; + } + } + + /// Launch a bounded demand admission for deferred parity. `disposable` + /// selects a speculative hedge (a fresh reopener whose reserve remains + /// unopened) versus a confirmed loss (the deferred handle is engaged and + /// retained across stripes). Tests and legacy callers without a reopener + /// use the handle-based reader as a persistent fallback. + #[allow(clippy::too_many_arguments)] + fn launch_demand_bound_parity<'a>( + &mut self, + stripe_index: usize, + max_new: usize, + sets: &mut FuturesUnordered>, + active: &mut [bool], + temporary_parity: &mut [bool], + attempted_parity: &mut [bool], + disposable: bool, + scheduled: &mut usize, + shard_size: usize, + data_shards: usize, + read_timeout: Duration, + metrics_path: Option<&'static str>, + ) -> usize + where + R: 'a, + { + let mut launched = 0; + for idx in self.data_shards..self.readers.len() { + if launched >= max_new || self.engaged[idx] || self.readers[idx].is_none() || active[idx] || attempted_parity[idx] { + continue; + } + + // Mark before invoking the factory/handle so a setup failure is + // also bounded to one attempt for this stripe. + attempted_parity[idx] = true; + + let reopener = self.deferred_reopeners.get(idx).and_then(Option::clone); + let (reader, temporary) = if disposable { + if let Some(reopener) = reopener { + let Some(reader) = reopener(stripe_index) else { + // A factory failure is terminal for this parity slot. + // Do not leave an apparently available reader that + // cannot be aligned to the current stripe. + self.readers[idx] = None; + continue; + }; + (reader, true) + } else { + // Tests and legacy callers without a factory retain the + // handle-based fallback. It is a persistent admission, + // because consuming that reserve is the only safe way to + // keep the stream aligned for the next stripe. + if !self.try_engage_parity(idx, stripe_index) { + continue; + } + let Some(reader) = self.readers[idx].take() else { + self.engaged[idx] = false; + continue; + }; + (reader, false) + } + } else if self.try_engage_parity(idx, stripe_index) { + let Some(reader) = self.readers[idx].take() else { + self.engaged[idx] = false; + continue; + }; + (reader, false) + } else if let Some(reopener) = reopener { + // A setup without a stripe handle can still make a known + // missing slot persistent by promoting the factory reader. + // This is a compatibility fallback; production CopySource + // setup supplies both a reserve and a handle. + let Some(reader) = reopener(stripe_index) else { + self.readers[idx] = None; + continue; + }; + // A non-disposable reopener is the persistent reserve when a + // setup did not retain a stripe handle. Mark it engaged just + // like the handle path so subsequent stripes reuse the + // aligned reader instead of reopening the remote shard. + self.engaged[idx] = true; + (reader, false) + } else { + continue; + }; + + let read_cost = self.read_costs.get(idx).copied().unwrap_or(ShardReadCost::Unknown); + if launch_owned_reader( + sets, + &mut self.buffers, + active, + scheduled, + idx, + reader, + shard_size, + data_shards, + read_cost, + read_timeout, + metrics_path, + ) { + temporary_parity[idx] = temporary; + launched += 1; + } else if !temporary { + self.engaged[idx] = false; + } + } + launched + } + fn try_engage_parity(&mut self, idx: usize, stripe_index: usize) -> bool { if stripe_index == 0 { self.engaged[idx] = true; @@ -1539,7 +2189,7 @@ impl Erasure { W: AsyncWrite + Send + Sync + Unpin, R: crate::erasure::coding::ShardSource, { - self.decode_inner(writer, readers, offset, length, total_length, None, Vec::new()) + self.decode_inner(writer, readers, offset, length, total_length, None, Vec::new(), Vec::new()) .await } @@ -1557,13 +2207,17 @@ impl Erasure { W: AsyncWrite + Send + Sync + Unpin, R: crate::erasure::coding::ShardSource, { - self.decode_inner(writer, readers, offset, length, total_length, Some(read_costs), Vec::new()) + self.decode_inner(writer, readers, offset, length, total_length, Some(read_costs), Vec::new(), Vec::new()) .await } /// GET decode entry point that also carries the deferred-parity stripe /// handles from bitrot reader setup, so unengaged parity readers can be /// opened aligned to the stripe where a data shard fails (backlog#923). + #[allow( + dead_code, + reason = "kept as the compatibility wrapper for existing decode callers and tests" + )] #[allow(clippy::too_many_arguments)] pub(crate) async fn decode_with_stripe_handles( &self, @@ -1579,8 +2233,49 @@ impl Erasure { W: AsyncWrite + Send + Sync + Unpin, R: crate::erasure::coding::ShardSource, { - self.decode_inner(writer, readers, offset, length, total_length, read_costs, deferred_handles) - .await + self.decode_with_stripe_handles_and_reopeners( + writer, + readers, + offset, + length, + total_length, + read_costs, + deferred_handles, + Vec::new(), + ) + .await + } + + /// Decode entry point with disposable, stripe-aligned parity reopeners. + /// CopySource uses these to hedge a slow data read without consuming the + /// unopened parity reserve when the data stream recovers first. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn decode_with_stripe_handles_and_reopeners( + &self, + writer: &mut W, + readers: Vec>>, + offset: usize, + length: usize, + total_length: usize, + read_costs: Option>, + deferred_handles: Vec>, + deferred_reopeners: Vec>>, + ) -> (usize, Option) + where + W: AsyncWrite + Send + Sync + Unpin, + R: crate::erasure::coding::ShardSource, + { + self.decode_inner( + writer, + readers, + offset, + length, + total_length, + read_costs, + deferred_handles, + deferred_reopeners, + ) + .await } /// Reconstruct and emit one already-read stripe. @@ -1708,6 +2403,7 @@ impl Erasure { total_length: usize, read_costs: Option>, deferred_handles: Vec>, + deferred_reopeners: Vec>>, ) -> (usize, Option) where W: AsyncWrite + Send + Sync + Unpin, @@ -1756,7 +2452,8 @@ impl Erasure { } else { ParallelReader::new_for_decode(readers, self.clone(), offset, total_length, Some(GET_OBJECT_PATH_LEGACY_DUPLEX)) } - .with_deferred_parity_handles(deferred_handles); + .with_deferred_parity_handles(deferred_handles) + .with_deferred_parity_reopeners(deferred_reopeners); let start = offset / self.block_size; let end = end_offset.saturating_sub(1) / self.block_size; @@ -1992,12 +2689,6 @@ mod tests { #[test] fn parallel_reader_keeps_stripe_scratch_out_of_line() { - eprintln!( - "parallel_reader={} stripe_state={} cached_state={}", - std::mem::size_of::>>>(), - std::mem::size_of::(), - std::mem::size_of::>>() - ); assert_eq!( std::mem::size_of::>>(), std::mem::size_of::(), @@ -2156,11 +2847,16 @@ mod tests { sleep: Option>>, }, Pending, + /// Parks without self-waking so a surrounding timer can make a + /// deterministic cancellation decision (unlike `Pending`, which is + /// intentionally a busy-waking fixture for timeout tests). + Parked, PartialThenPending { data: Vec, emitted: bool, }, TimedOut, + TerminalFileNotFound, /// Serves `cursor` (typically the first stripe's bytes) normally, then /// once it is exhausted parks on a long `sleep` instead of returning EOF — /// modelling a shard whose *next*-stripe read never completes (a wedged or @@ -2192,6 +2888,7 @@ mod tests { cx.waker().wake_by_ref(); Poll::Pending } + TestShardReader::Parked => Poll::Pending, TestShardReader::PartialThenPending { data, emitted } => { if *emitted { cx.waker().wake_by_ref(); @@ -2204,6 +2901,9 @@ mod tests { Poll::Ready(Ok(())) } TestShardReader::TimedOut => Poll::Ready(Err(io::Error::new(ErrorKind::TimedOut, "test shard read timed out"))), + TestShardReader::TerminalFileNotFound => { + Poll::Ready(Err(crate::disk::error::terminal_read_error_to_io(Error::FileNotFound))) + } TestShardReader::PrefixThenSlow { cursor, stall, sleep } => { let before = buf.filled().len(); match Pin::new(cursor).poll_read(cx, buf) { @@ -3110,6 +3810,36 @@ mod tests { }); } + /// A copy source must remain demand-bound even when an operator has opted + /// into the ordinary GET overlap switches. The policy also enables the + /// deferred-parity lockstep mode so healthy copies do not consume parity + /// streams until reconstruction needs them. + #[tokio::test] + #[serial_test::serial] + async fn demand_bound_policy_disables_stripe_read_ahead_and_uses_data_only_lockstep() { + temp_env::async_with_vars( + [ + (ENV_RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT, Some("8")), + (ENV_RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE, Some("true")), + (ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, Some("false")), + ], + async { + assert!(legacy_stripe_prefetch_enabled()); + assert!(!get_lockstep_data_shards_only_enabled()); + + with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + assert!(!legacy_stripe_prefetch_enabled()); + assert!(get_lockstep_data_shards_only_enabled()); + }) + .await; + + assert!(legacy_stripe_prefetch_enabled()); + assert!(!get_lockstep_data_shards_only_enabled()); + }, + ) + .await; + } + /// Cancel-safety (https://github.com/rustfs/backlog/issues/1310): when the /// current stripe's emit fails (client disconnect / broken pipe), the /// speculatively prefetched next-stripe read must be *cancelled*, not waited @@ -4174,6 +4904,342 @@ mod tests { assert_eq!(DATA_SHARDS + 1, bufs.iter().filter(|buf| buf.is_some()).count()); } + /// Demand-bound lockstep regression: a slow data shard must be hedged as + /// soon as deferred parity can provide the decode-plus-verification quorum. + /// Before this guard, the hedge timer only looked at already-completed + /// readers, so a 2+2 stripe with one ready data shard waited out the full + /// read timeout even though both parity readers were available to engage. + #[tokio::test] + async fn test_demand_bound_lockstep_hedges_to_deferred_parity_quorum() { + const NUM_SHARDS: usize = 1; + const BLOCK_SIZE: usize = 64; + const DATA_SHARDS: usize = 2; + const PARITY_SHARDS: usize = 2; + const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS; + + let hash_algo = HashAlgorithm::None; + let slow_until = TokioInstant::now() + Duration::from_secs(60); + let readers = vec![ + Some(BitrotReader::new( + TestShardReader::ReadyAt { + cursor: Cursor::new(vec![0_u8; SHARD_SIZE * NUM_SHARDS]), + ready_at: slow_until, + sleep: None, + }, + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![3_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo, + false, + )), + ]; + + let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE); + let (bufs, errs, engaged, readers_remaining) = with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + let mut parallel_reader = ParallelReader::new_with_metrics_path_read_costs_timeout_and_reconstruction_verification( + readers, + erasure, + 0, + NUM_SHARDS * BLOCK_SIZE, + None, + vec![ShardReadCost::Unknown; DATA_SHARDS + PARITY_SHARDS], + Duration::from_secs(60), + true, + ); + let (bufs, errs) = tokio::time::timeout(Duration::from_secs(2), parallel_reader.read()) + .await + .expect("deferred parity must cover a hedged data shard without waiting for read_timeout"); + ( + bufs, + errs, + parallel_reader.engaged.clone(), + parallel_reader.readers.iter().map(Option::is_some).collect::>(), + ) + }) + .await; + + assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut)); + assert_eq!(bufs.iter().filter(|buf| buf.is_some()).count(), DATA_SHARDS + 1); + assert_eq!(engaged.as_slice(), &[true, true, true, true]); + assert_eq!(readers_remaining, vec![false, true, true, true]); + } + + /// A fast data failure must admit deferred parity immediately. There is + /// intentionally no hedge timer when `read_timeout == 0`, so relying on + /// the timer would drain the initial futures and return a false quorum + /// before the healthy parity readers are ever opened. + #[tokio::test] + async fn test_demand_bound_admits_parity_after_fast_data_failure_without_timer() { + const NUM_SHARDS: usize = 1; + const BLOCK_SIZE: usize = 64; + const DATA_SHARDS: usize = 2; + const PARITY_SHARDS: usize = 2; + const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS; + + let hash_algo = HashAlgorithm::None; + let readers = vec![ + Some(BitrotReader::new(TestShardReader::TimedOut, SHARD_SIZE, hash_algo.clone(), false)), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![3_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo, + false, + )), + ]; + + let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE); + let (bufs, errs, engaged, readers_remaining) = with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + let mut parallel_reader = ParallelReader::new_with_metrics_path_read_timeout_and_reconstruction_verification( + readers, + erasure, + 0, + NUM_SHARDS * BLOCK_SIZE, + None, + Duration::ZERO, + true, + ); + let (bufs, errs) = tokio::time::timeout(Duration::from_millis(500), parallel_reader.read()) + .await + .expect("fast data failure must immediately race healthy parity without a hedge timer"); + ( + bufs, + errs, + parallel_reader.engaged.clone(), + parallel_reader.readers.iter().map(Option::is_some).collect::>(), + ) + }) + .await; + + assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut)); + assert_eq!(bufs.iter().filter(|buf| buf.is_some()).count(), DATA_SHARDS + 1); + assert_eq!(engaged.as_slice(), &[true, true, true, true]); + assert_eq!(readers_remaining, vec![false, true, true, true]); + } + + #[tokio::test] + async fn test_demand_bound_canceled_hedge_preserves_deferred_parity_for_next_stripe() { + const BLOCK_SIZE: usize = 64; + const DATA_SHARDS: usize = 2; + const PARITY_SHARDS: usize = 2; + const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS; + + let hash_algo = HashAlgorithm::None; + let parity_calls = Arc::new(AtomicUsize::new(0)); + let mut reopeners: Vec>> = vec![None; DATA_SHARDS + PARITY_SHARDS]; + for (idx, slot) in reopeners.iter_mut().enumerate().skip(DATA_SHARDS).take(PARITY_SHARDS) { + let parity_calls = Arc::clone(&parity_calls); + *slot = Some(Arc::new(move |_stripe_index| { + let call = parity_calls.fetch_add(1, Ordering::SeqCst); + let reader = if call == 0 { + // One hedge succeeds before the slow data reader returns; + // it must still remain disposable rather than being + // promoted into the next stripe. + TestShardReader::Ready(Cursor::new(vec![idx as u8; SHARD_SIZE])) + } else if call < PARITY_SHARDS { + TestShardReader::Parked + } else { + TestShardReader::Ready(Cursor::new(vec![idx as u8; SHARD_SIZE * 2])) + }; + Some(BitrotReader::new(reader, SHARD_SIZE, HashAlgorithm::None, false)) + })); + } + + let slow_until = TokioInstant::now() + Duration::from_millis(150); + let readers = vec![ + Some(BitrotReader::new( + TestShardReader::ReadyAt { + cursor: Cursor::new(vec![0_u8; SHARD_SIZE * 4]), + ready_at: slow_until, + sleep: None, + }, + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * 4])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, hash_algo.clone(), false)), + Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, hash_algo, false)), + ]; + + let (first_parity_reserved, second_result) = with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE); + let mut parallel_reader = ParallelReader::new_with_metrics_path_read_timeout_and_reconstruction_verification( + readers, + erasure, + 0, + BLOCK_SIZE * 4, + None, + Duration::from_secs(1), + true, + ) + .with_deferred_parity_reopeners(reopeners); + + let (first_buffers, first_errors) = tokio::time::timeout(Duration::from_secs(1), parallel_reader.read()) + .await + .expect("healthy data recovery must not wait for canceled parity hedges"); + assert_eq!(first_buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS + 1); + assert!(first_errors.iter().take(DATA_SHARDS).all(Option::is_none)); + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS); + assert!(parallel_reader.readers[2].is_some()); + assert!(parallel_reader.readers[3].is_some()); + assert!(!parallel_reader.engaged[2]); + assert!(!parallel_reader.engaged[3]); + + // A healthy following stripe must not inherit the one-stripe hedge + // reader. If a successful temporary hedge were promoted, this + // read would schedule parity again and violate demand-bound + // read-ahead. + let (healthy_buffers, healthy_errors) = tokio::time::timeout(Duration::from_secs(1), parallel_reader.read()) + .await + .expect("a healthy following stripe must complete without parity fan-out"); + assert!(healthy_errors.iter().take(DATA_SHARDS).all(Option::is_none)); + assert_eq!(healthy_buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS); + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS); + assert!(!parallel_reader.engaged[2]); + assert!(!parallel_reader.engaged[3]); + + // A later stripe loses data shard 0. The deferred reserves must be + // available again; the second pair of factory calls returns the + // aligned parity bytes for this stripe. + parallel_reader.readers[0] = + Some(BitrotReader::new(TestShardReader::TimedOut, SHARD_SIZE, HashAlgorithm::None, false)); + let (second_buffers, second_errors) = tokio::time::timeout(Duration::from_secs(1), parallel_reader.read()) + .await + .expect("the next degraded stripe must reuse the preserved parity reserve"); + assert!(matches!(&second_errors[0], Some(DiskError::Io(error)) if error.kind() == ErrorKind::TimedOut)); + assert_eq!(second_buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS + 1); + + // Once the parity readers have been persistently engaged, another + // degraded stripe reuses their aligned streams; no new factory + // calls (and therefore no new remote opens) are permitted. + let (third_buffers, third_errors) = tokio::time::timeout(Duration::from_secs(1), parallel_reader.read()) + .await + .expect("persistent parity readers must cover a subsequent degraded stripe"); + assert!(third_errors[0].is_none(), "an already-retired data slot has no new read error"); + assert_eq!(third_buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS + 1); + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS * 2); + ( + parallel_reader.readers[2].is_some() && parallel_reader.readers[3].is_some(), + (third_buffers, third_errors), + ) + }) + .await; + + assert!(first_parity_reserved); + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS * 2); + // `second_result` carries the third (persistently degraded) stripe; + // the data slot was already retired by the preceding stripe, so it + // must not emit a fresh timeout or trigger another remote open. + let (buffers, errors) = second_result; + assert!(errors[0].is_none()); + assert_eq!(buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS + 1); + } + + /// A disposable parity hedge is advisory. Its terminal error must not be + /// copied into the stripe error vector, because the emitter treats + /// FileNotFound/FileCorrupt there as an object-level failure even after a + /// healthy data reader has recovered and supplied a complete stripe. + #[tokio::test] + async fn test_demand_bound_disposable_parity_error_does_not_poison_recovered_stripe() { + const BLOCK_SIZE: usize = 64; + const DATA_SHARDS: usize = 2; + const PARITY_SHARDS: usize = 2; + const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS; + + let parity_calls = Arc::new(AtomicUsize::new(0)); + let mut reopeners: Vec>> = vec![None; DATA_SHARDS + PARITY_SHARDS]; + for (idx, slot) in reopeners.iter_mut().enumerate().skip(DATA_SHARDS).take(PARITY_SHARDS) { + let parity_calls = Arc::clone(&parity_calls); + *slot = Some(Arc::new(move |_stripe_index| { + let call = parity_calls.fetch_add(1, Ordering::SeqCst); + let reader = if call == 0 { + TestShardReader::TerminalFileNotFound + } else { + TestShardReader::Ready(Cursor::new(vec![idx as u8; SHARD_SIZE])) + }; + Some(BitrotReader::new(reader, SHARD_SIZE, HashAlgorithm::None, false)) + })); + } + + let slow_until = TokioInstant::now() + Duration::from_millis(150); + let readers = vec![ + Some(BitrotReader::new( + TestShardReader::ReadyAt { + cursor: Cursor::new(vec![0_u8; SHARD_SIZE]), + ready_at: slow_until, + sleep: None, + }, + SHARD_SIZE, + HashAlgorithm::None, + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE])), + SHARD_SIZE, + HashAlgorithm::None, + false, + )), + Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, HashAlgorithm::None, false)), + Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, HashAlgorithm::None, false)), + ]; + + let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE); + let mut output = Vec::new(); + let (written, error) = with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + erasure + .decode_with_stripe_handles_and_reopeners( + &mut output, + readers, + 0, + BLOCK_SIZE, + BLOCK_SIZE, + None, + Vec::new(), + reopeners, + ) + .await + }) + .await; + + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS); + assert_eq!(written, BLOCK_SIZE); + assert_eq!(output.len(), BLOCK_SIZE); + assert!(error.is_none(), "a failed disposable hedge must not fail a recovered stripe: {error:?}"); + } + /// Lockstep verification-quorum regression (backlog#1156). When a data shard is /// missing, the hedge must settle only at `data_shards + 1` (decode quorum plus /// a reconstruction-verification source), never at exactly `data_shards` — that diff --git a/crates/ecstore/src/io_support/bitrot.rs b/crates/ecstore/src/io_support/bitrot.rs index 88a3da910..371a93560 100644 --- a/crates/ecstore/src/io_support/bitrot.rs +++ b/crates/ecstore/src/io_support/bitrot.rs @@ -346,16 +346,11 @@ impl AsyncRead for DeferredObjectReader { } fn disk_error_to_io_error(err: DiskError) -> io::Error { - let kind = match err { - DiskError::Timeout | DiskError::SourceStalled => io::ErrorKind::TimedOut, - DiskError::DiskNotFound | DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::PathNotFound => { - io::ErrorKind::NotFound - } - DiskError::FileCorrupt | DiskError::PartMissingOrCorrupt | DiskError::BitrotHashAlgoInvalid => io::ErrorKind::InvalidData, - DiskError::Io(io_err) => return io_err, - _ => io::ErrorKind::Other, - }; - io::Error::new(kind, err.to_string()) + // Keep the typed disk error attached to deferred-reader failures. The + // decoder uses the marker to retire a stream that can no longer be + // realigned, while quorum reduction still sees Timeout/NotFound instead + // of an opaque `DiskError::Io` wrapper. + crate::disk::error::terminal_read_error_to_io(err) } async fn open_disk_reader( diff --git a/crates/ecstore/src/set_disk/core/io_primitives.rs b/crates/ecstore/src/set_disk/core/io_primitives.rs index 6d2c6cf6c..a05884f72 100644 --- a/crates/ecstore/src/set_disk/core/io_primitives.rs +++ b/crates/ecstore/src/set_disk/core/io_primitives.rs @@ -1517,6 +1517,7 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter( } pub(in crate::set_disk) type ObjectBitrotReader = BitrotReader; +pub(in crate::set_disk) type DeferredReaderReopener = crate::erasure::coding::decode::DeferredReaderReopener; pub(in crate::set_disk) type BitrotReaderTask<'a> = Pin, DiskError>)> + Send + 'a>>; @@ -1533,6 +1534,10 @@ pub(in crate::set_disk) struct BitrotReaderSetup { /// readers. The lockstep GET decode uses them to open a parity shard /// aligned to the stripe where a data shard failed (backlog#923). pub(in crate::set_disk) deferred_stripe_handles: Vec>, + /// Factories for a fresh, stripe-aligned parity reader. CopySource hedges + /// use these disposable readers so an abandoned hedge leaves the original + /// deferred reserve untouched. + pub(in crate::set_disk) deferred_reopeners: Vec>, pub(in crate::set_disk) errors: Vec>, pub(in crate::set_disk) scheduled: Vec, pub(in crate::set_disk) attempted: Vec, @@ -1595,6 +1600,16 @@ pub(in crate::set_disk) fn get_bitrot_reader_setup_strategy( mode: BitrotReaderSetupMode, prefer_data_blocks_first: bool, ) -> BitrotReaderSetupStrategy { + // CopyObject holds the source reader behind a backpressured destination. + // Keep its setup demand-bound even when an operator has retained the + // legacy all-shards environment setting for ordinary GETs. + if matches!( + crate::set_disk::get_object_read_policy(), + crate::set_disk::GetObjectReadPolicy::CopySource + ) { + return BitrotReaderSetupStrategy::DataBlocksFirst; + } + match mode { BitrotReaderSetupMode::ReadQuorum if prefer_data_blocks_first @@ -1620,6 +1635,7 @@ impl BitrotReaderSetup { Self { readers: (0..shards).map(|_| None).collect(), deferred_stripe_handles: (0..shards).map(|_| None).collect(), + deferred_reopeners: (0..shards).map(|_| None).collect(), errors: vec![Some(DiskError::DiskNotFound); shards], scheduled: vec![false; shards], attempted: vec![false; shards], @@ -1814,6 +1830,41 @@ pub(in crate::set_disk) fn next_unscheduled_reader_index( .find(|idx| !setup.scheduled[*idx]) } +/// Build a cloneable opener for an unopened deferred shard. The returned +/// reader is aligned to the requested stripe before its first poll, while the +/// source reader created during setup remains untouched as a reserve. +#[allow(clippy::too_many_arguments)] +fn deferred_reader_reopener( + inline_data: Option, + disk: Option, + bucket: &str, + path: &str, + read_offset: usize, + read_length: usize, + shard_size: usize, + checksum_algo: HashAlgorithm, + skip_verify_bitrot: bool, + use_mmap_read: bool, +) -> DeferredReaderReopener { + let bucket = bucket.to_owned(); + let path = path.to_owned(); + Arc::new(move |stripe_index| { + let (reader, handle) = create_deferred_bitrot_reader_with_stripe_handle( + inline_data.clone(), + disk.clone(), + &bucket, + &path, + read_offset, + read_length, + shard_size, + checksum_algo.clone(), + skip_verify_bitrot, + use_mmap_read, + ); + handle.advance_stripes(stripe_index).then_some(reader) + }) +} + #[allow(clippy::too_many_arguments)] pub(in crate::set_disk) fn fill_deferred_bitrot_readers( setup: &mut BitrotReaderSetup, @@ -1836,6 +1887,15 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( return; } + // Only CopySource uses disposable, stripe-aligned reopeners. Ordinary GET + // readers use the existing deferred handle and should not retain one + // heap-allocated closure (plus cloned path/disk state) for every parity + // slot. + let copy_source_demand_bound = matches!( + crate::set_disk::get_object_read_policy(), + crate::set_disk::GetObjectReadPolicy::CopySource + ); + for idx in 0..disks.len() { if setup.attempted[idx] { continue; @@ -1849,6 +1909,20 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( let disk = disks[idx].clone(); let data_dir = files[idx].data_dir.unwrap_or_default(); let path = format!("{object}/{data_dir}/part.{part_number}"); + let reopener = copy_source_demand_bound.then(|| { + deferred_reader_reopener( + inline_data.clone(), + disk.clone(), + bucket, + &path, + read_offset, + read_length, + shard_size, + checksum_algo.clone(), + skip_verify_bitrot, + use_mmap_read, + ) + }); let (reader, stripe_handle) = create_deferred_bitrot_reader_with_stripe_handle( inline_data, disk, @@ -1862,6 +1936,7 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( use_mmap_read, ); setup.retain_deferred_reader(idx, reader, stripe_handle); + setup.deferred_reopeners[idx] = reopener; } // With the data-shards-only lockstep gate on (backlog#923), the GET decode @@ -1887,6 +1962,20 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( let disk = disks[idx].clone(); let data_dir = files[idx].data_dir.unwrap_or_default(); let path = format!("{object}/{data_dir}/part.{part_number}"); + let reopener = copy_source_demand_bound.then(|| { + deferred_reader_reopener( + inline_data.clone(), + disk.clone(), + bucket, + &path, + read_offset, + read_length, + shard_size, + checksum_algo.clone(), + skip_verify_bitrot, + use_mmap_read, + ) + }); let (reader, stripe_handle) = create_deferred_bitrot_reader_with_stripe_handle( inline_data, disk, @@ -1901,6 +1990,7 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( ); setup.readers[idx] = Some(reader); setup.deferred_stripe_handles[idx] = Some(stripe_handle); + setup.deferred_reopeners[idx] = reopener; } } @@ -2210,6 +2300,10 @@ pub(in crate::set_disk) async fn create_bitrot_readers_until_quorum_with_prefere let strategy = get_bitrot_reader_setup_strategy(mode, prefer_data_blocks_first); if use_mmap_read + && !matches!( + crate::set_disk::get_object_read_policy(), + crate::set_disk::GetObjectReadPolicy::CopySource + ) && let Some(mut setup) = try_create_bitrot_readers_via_batch_pread( files, disks, diff --git a/crates/ecstore/src/set_disk/mod.rs b/crates/ecstore/src/set_disk/mod.rs index 0d0f1fb57..a63348116 100644 --- a/crates/ecstore/src/set_disk/mod.rs +++ b/crates/ecstore/src/set_disk/mod.rs @@ -792,6 +792,65 @@ const ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_OBJECT_PREFIX: &str = "RUSTFS_GET_M const ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: &str = "RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH"; const DEFAULT_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: bool = true; +/// Identifies the caller's read contract for policies that are deliberately +/// narrower than the storage API's ordinary GET contract. +/// +/// Server-side copy consumes a source reader while a destination writer is +/// applying backpressure. Its source read must not speculatively open the +/// next multipart part: those extra shard streams can share an internode H2 +/// connection with the current part and starve the lockstep decoder. Keep +/// this context internal so the public `ObjectOptions` and storage traits do +/// not acquire a copy-only field. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum GetObjectReadPolicy { + #[default] + Default, + CopySource, +} + +impl GetObjectReadPolicy { + pub(crate) const fn allows_multipart_setup_prefetch(self) -> bool { + matches!(self, Self::Default) + } +} + +tokio::task_local! { + static GET_OBJECT_READ_POLICY: GetObjectReadPolicy; + static GET_OBJECT_READ_CANCELLATION: tokio_util::sync::CancellationToken; +} + +pub(crate) fn get_object_read_policy() -> GetObjectReadPolicy { + GET_OBJECT_READ_POLICY.try_with(|policy| *policy).unwrap_or_default() +} + +pub(crate) async fn with_get_object_read_policy(policy: GetObjectReadPolicy, future: F) -> F::Output +where + F: std::future::Future, +{ + let decode_policy = match policy { + GetObjectReadPolicy::Default => crate::erasure::coding::decode::DecodeReadPolicy::Default, + GetObjectReadPolicy::CopySource => crate::erasure::coding::decode::DecodeReadPolicy::DemandBound, + }; + crate::erasure::coding::decode::with_decode_read_policy(decode_policy, GET_OBJECT_READ_POLICY.scope(policy, future)).await +} + +/// Return the request-owned cancellation token for a copy source, when one is +/// installed. The token is read before the detached legacy producer is spawned; +/// Tokio task-local values do not cross that spawn boundary on their own. +pub(crate) fn get_object_read_cancellation() -> Option { + GET_OBJECT_READ_CANCELLATION.try_with(|token| token.clone()).ok() +} + +pub(crate) async fn with_get_object_read_cancellation( + cancellation: tokio_util::sync::CancellationToken, + future: F, +) -> F::Output +where + F: std::future::Future, +{ + GET_OBJECT_READ_CANCELLATION.scope(cancellation, future).await +} + static OBJECT_LOCK_DIAG_ENABLED: OnceLock = OnceLock::new(); mod core; @@ -2296,6 +2355,7 @@ enum GetCodecStreamingFallbackReason { InvalidMinSize, ReadQuorumNotSafe, MultipartPartLimit, + CopySourceDemandBound, } impl GetCodecStreamingFallbackReason { @@ -2317,6 +2377,7 @@ impl GetCodecStreamingFallbackReason { Self::InvalidMinSize => "invalid_min_size", Self::ReadQuorumNotSafe => "read_quorum_not_safe", Self::MultipartPartLimit => "multipart_part_limit", + Self::CopySourceDemandBound => "copy_source_demand_bound", } } } @@ -2623,6 +2684,17 @@ fn get_codec_streaming_reader_gate( prefer_data_blocks_first_reader_setup: false, }; } + if matches!(get_object_read_policy(), GetObjectReadPolicy::CopySource) { + // The codec reader has its own bounded fill worker. It may still + // request an additional stripe for a plain single-part object even + // when multipart setup prefetch is disabled, so copy sources use the + // legacy demand-bound reader for every object class. + return GetCodecStreamingGate { + object_class, + decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::CopySourceDemandBound), + prefer_data_blocks_first_reader_setup: false, + }; + } if !config.rollout.is_opted_in() { return GetCodecStreamingGate { object_class, @@ -5912,6 +5984,29 @@ mod tests { use tokio::fs; use tokio::io::AsyncReadExt; + #[tokio::test] + async fn copy_source_read_policy_is_scoped_and_demand_bound() { + assert_eq!(get_object_read_policy(), GetObjectReadPolicy::Default); + assert!(GetObjectReadPolicy::Default.allows_multipart_setup_prefetch()); + assert!(!GetObjectReadPolicy::CopySource.allows_multipart_setup_prefetch()); + + with_get_object_read_policy(GetObjectReadPolicy::CopySource, async { + assert_eq!(get_object_read_policy(), GetObjectReadPolicy::CopySource); + assert!(!get_object_read_policy().allows_multipart_setup_prefetch()); + assert_eq!( + crate::erasure::coding::decode::decode_read_policy(), + crate::erasure::coding::decode::DecodeReadPolicy::DemandBound + ); + }) + .await; + + assert_eq!(get_object_read_policy(), GetObjectReadPolicy::Default); + assert_eq!( + crate::erasure::coding::decode::decode_read_policy(), + crate::erasure::coding::decode::DecodeReadPolicy::Default + ); + } + #[test] fn complete_part_error_maps_confirmed_missing_to_invalid_part() { for err in ["file not found", "Specified part could not be found", "part.7 not found"] { diff --git a/crates/ecstore/src/set_disk/ops/object.rs b/crates/ecstore/src/set_disk/ops/object.rs index 8f550be82..aa75a27e7 100644 --- a/crates/ecstore/src/set_disk/ops/object.rs +++ b/crates/ecstore/src/set_disk/ops/object.rs @@ -61,6 +61,8 @@ use http::HeaderValue; use rustfs_utils::path::decode_dir_object; use std::future::Future; use std::sync::OnceLock; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, ReadBuf}; use tokio_util::sync::CancellationToken; const OLD_DATA_CLEANUP_RECEIPT_FILE: &str = ".rustfs-old-data-cleanup-receipt.json"; @@ -1120,6 +1122,37 @@ where Ok((reader, offset, length)) } +/// Cancels a detached legacy GET producer when its consumer is dropped. +/// +/// The producer owns the shard readers and the object read lock, while the +/// consumer owns only the duplex read half. Closing that half eventually +/// unblocks a writer, but can leave a producer stuck in reader setup or remote +/// recovery until a lower-level timeout fires. This small boundary wrapper +/// provides an explicit cancellation signal without changing the public +/// `GetObjectReader` shape. +struct ProducerCancellationReader { + inner: R, + cancellation: CancellationToken, +} + +impl ProducerCancellationReader { + fn new(inner: R, cancellation: CancellationToken) -> Self { + Self { inner, cancellation } + } +} + +impl AsyncRead for ProducerCancellationReader { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Drop for ProducerCancellationReader { + fn drop(&mut self) { + self.cancellation.cancel(); + } +} + fn data_read_metadata_early_stop_request_shape_allowed(range: &Option, opts: &ObjectOptions) -> bool { range.is_none() && opts.part_number.is_none() @@ -1907,12 +1940,25 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks { // lookup on the streaming miss path (ODC-16). reader.body_source = body_source; + // The producer is otherwise detached from the returned reader. Tie its + // lifetime to the source stream so a cancelled copy (or an abandoned + // GET) releases in-flight shard opens, response bodies, and the read + // lock immediately instead of waiting for a disk timeout. + let producer_cancellation = crate::set_disk::get_object_read_cancellation(); + if let Some(cancellation) = producer_cancellation.as_ref() { + reader.stream = Box::new(ProducerCancellationReader::new(reader.stream, cancellation.clone())); + } + // let disks = disks.clone(); let bucket = bucket.to_owned(); let object = object.to_owned(); let set_index = self.set_index; let pool_index = self.pool_index; let skip_verify = opts.skip_verify_bitrot; + // The producer runs in a separate Tokio task, so carry the caller's + // read policy across the task boundary explicitly. Tokio task-local + // values are not inherited by spawned tasks. + let read_policy = crate::set_disk::get_object_read_policy(); let erasure_cache = Arc::clone(&self.erasure_cache); let (fi, files, disks) = snapshot.into_owned(); tokio::spawn(async move { @@ -1922,26 +1968,40 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks { // `get_object_with_fileinfo` also waits on `writer`, so an outer timeout // would incorrectly treat downstream backpressure as disk-read latency. // Disk read timeouts must be enforced at the actual disk I/O operations. - let producer_result = Self::get_object_with_fileinfo( - &bucket, - &object, - erasure_cache, - offset, - length, - &mut writer, - fi, - files, - &disks, - set_index, - pool_index, - skip_verify, - false, - GET_OBJECT_PATH_LEGACY_DUPLEX, - object_class.as_str(), - size_bucket, - ) - .await; - if let Err(e) = &producer_result { + let producer_result = tokio::select! { + biased; + result = crate::set_disk::with_get_object_read_policy( + read_policy, + Self::get_object_with_fileinfo( + &bucket, + &object, + erasure_cache, + offset, + length, + &mut writer, + fi, + files, + &disks, + set_index, + pool_index, + skip_verify, + false, + GET_OBJECT_PATH_LEGACY_DUPLEX, + object_class.as_str(), + size_bucket, + ), + ) => result, + _ = async { + if let Some(cancellation) = producer_cancellation.as_ref() { + cancellation.cancelled().await; + } else { + std::future::pending::<()>().await; + } + } => Err(Error::OperationCanceled), + }; + if let Err(e) = &producer_result + && !matches!(e, Error::OperationCanceled) + { let reason = classify_storage_error(e); if reason == GetObjectFailureReason::DownstreamClosed { debug!( @@ -3794,6 +3854,28 @@ mod legacy_duplex_producer_reader_tests { assert_eq!(out, b"complete"); } + #[tokio::test] + async fn producer_cancellation_reader_cancels_pending_producer_on_drop() { + let cancellation = CancellationToken::new(); + let producer_cancellation = cancellation.clone(); + let producer = tokio::spawn(async move { + tokio::select! { + _ = producer_cancellation.cancelled() => true, + _ = std::future::pending::<()>() => false, + } + }); + + let reader = ProducerCancellationReader::new(tokio::io::empty(), cancellation); + drop(reader); + + assert!( + tokio::time::timeout(std::time::Duration::from_secs(1), producer) + .await + .expect("dropping the consumer should cancel the producer promptly") + .expect("producer task should not panic") + ); + } + #[tokio::test] async fn legacy_duplex_reader_ignores_zero_capacity_read_buf() { let (mut writer, reader) = tokio::io::duplex(64); diff --git a/crates/ecstore/src/set_disk/read.rs b/crates/ecstore/src/set_disk/read.rs index 569ba57c0..ce3f47016 100644 --- a/crates/ecstore/src/set_disk/read.rs +++ b/crates/ecstore/src/set_disk/read.rs @@ -792,7 +792,7 @@ impl SetDisks { let use_mmap_read = object_mmap_read_enabled(); let files = Arc::new(files); let disks = Arc::new(disks); - let prefetch_enabled = is_multipart_reader_setup_prefetch_enabled(); + let prefetch_enabled = multipart_reader_setup_prefetch_enabled(get_object_read_policy()); let mut prefetched: Option<(usize, PrefetchedReaderSetup)> = None; let mut total_read = 0; @@ -1065,8 +1065,9 @@ impl SetDisks { let unattempted_data_shards = !reader_setup.data_shards_attempted(erasure.data_shards); let readers = reader_setup.readers; let deferred_stripe_handles = reader_setup.deferred_stripe_handles; + let deferred_reopeners = reader_setup.deferred_reopeners; let (written, err) = erasure - .decode_with_stripe_handles( + .decode_with_stripe_handles_and_reopeners( writer, readers, part_offset, @@ -1074,6 +1075,7 @@ impl SetDisks { part_size, read_costs, deferred_stripe_handles, + deferred_reopeners, ) .await; let decode_elapsed = decode_stage_start.elapsed(); @@ -1476,6 +1478,7 @@ impl SetDisks { erasure.clone(), reader_setup.readers, reader_setup.deferred_stripe_handles, + reader_setup.deferred_reopeners, read_costs, part_offset, part_length, @@ -1488,6 +1491,7 @@ impl SetDisks { let readers = reader_setup.readers; let deferred_stripe_handles = reader_setup.deferred_stripe_handles; + let deferred_reopeners = reader_setup.deferred_reopeners; let source = if let Some(read_costs) = read_costs { coding::decode::ParallelReader::new_with_metrics_path_read_costs_and_reconstruction_verification( readers, @@ -1506,7 +1510,8 @@ impl SetDisks { Some(metrics_path), ) } - .with_deferred_parity_handles(deferred_stripe_handles); + .with_deferred_parity_handles(deferred_stripe_handles) + .with_deferred_parity_reopeners(deferred_reopeners); let engine = build_get_codec_streaming_decode_engine(erasure.clone())?; let reader = coding::decode_reader::ErasureDecodeReader::new_with_metrics_path(source, engine, part_length, metrics_path)?; @@ -1535,6 +1540,10 @@ fn multipart_part_checksum_algo(fi: &FileInfo, part_number: usize) -> HashAlgori } } +fn multipart_reader_setup_prefetch_enabled(policy: GetObjectReadPolicy) -> bool { + policy.allows_multipart_setup_prefetch() && is_multipart_reader_setup_prefetch_enabled() +} + /// Run one part's bitrot reader setup and measure its wall-clock duration. /// /// Shared by the synchronous path and the prefetch task in @@ -1762,10 +1771,12 @@ impl Drop for LazyMultipartCodecStreamingReader { /// background task drives the decode into the write half while the returned /// reader drains the read half. No extra file descriptors are opened — the /// readers are moved in from the setup that just ran. +#[allow(clippy::too_many_arguments)] fn build_legacy_per_part_fallback_reader( erasure: coding::Erasure, readers: Vec>, deferred_stripe_handles: Vec>, + deferred_reopeners: Vec>, read_costs: Option>, part_offset: usize, part_length: usize, @@ -1775,7 +1786,7 @@ fn build_legacy_per_part_fallback_reader( let (read_half, mut write_half) = tokio::io::duplex(buffer); let decode = tokio::spawn(async move { let (_written, err) = erasure - .decode_with_stripe_handles( + .decode_with_stripe_handles_and_reopeners( &mut write_half, readers, part_offset, @@ -1783,6 +1794,7 @@ fn build_legacy_per_part_fallback_reader( part_size, read_costs, deferred_stripe_handles, + deferred_reopeners, ) .await; // Dropping `write_half` on return signals EOF to the reader half. @@ -3236,6 +3248,7 @@ mod metadata_cache_tests { mod tests { use super::*; use crate::erasure::coding::BitrotWriter; + use serial_test::serial; use std::io::{Cursor, ErrorKind, IoSlice}; use std::sync::{ Arc, @@ -3246,6 +3259,15 @@ mod tests { const CODEC_STREAMING_TEST_BUCKET: &str = "bucket"; const CODEC_STREAMING_TEST_OBJECT: &str = "object"; + #[test] + #[serial] + fn multipart_reader_setup_prefetch_is_disabled_only_for_copy_sources() { + temp_env::with_var(ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH, Some("true"), || { + assert!(multipart_reader_setup_prefetch_enabled(GetObjectReadPolicy::Default)); + assert!(!multipart_reader_setup_prefetch_enabled(GetObjectReadPolicy::CopySource)); + }); + } + #[tokio::test] async fn downstream_writer_marks_closed_duplex_reader_as_downstream_close() { let (reader, inner) = tokio::io::duplex(64); @@ -5481,6 +5503,7 @@ mod tests { erasure, setup.readers, setup.deferred_stripe_handles, + Vec::new(), None, 0, data.len(), @@ -5531,6 +5554,7 @@ mod tests { erasure, setup.readers, setup.deferred_stripe_handles, + Vec::new(), None, 0, part2_len, @@ -5571,6 +5595,7 @@ mod tests { erasure, setup.readers, setup.deferred_stripe_handles, + Vec::new(), None, 0, data.len(), @@ -6007,6 +6032,49 @@ mod tests { ); } + #[tokio::test] + #[serial] + async fn codec_streaming_reader_gate_keeps_copy_source_demand_bound_for_all_classes() { + temp_env::async_with_vars( + [ + (ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE, Some("true")), + (ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT, Some("benchmark")), + (ENV_RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED, Some("true")), + (ENV_RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED, Some("true")), + (ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE, Some("true")), + (ENV_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE, Some("1")), + ], + async { + let fi = codec_streaming_test_fileinfo(1024, 2); + let object_info = codec_streaming_test_object_info(&fi); + let normal = codec_streaming_reader_gate_for_test(&None, &object_info, &fi, true); + assert_eq!(normal.decision, GetCodecStreamingDecision::Use); + + let plain_fi = codec_streaming_test_fileinfo(1024, 1); + let plain_object_info = codec_streaming_test_object_info(&plain_fi); + let normal_plain = codec_streaming_reader_gate_for_test(&None, &plain_object_info, &plain_fi, true); + assert_eq!(normal_plain.object_class, GetCodecStreamingObjectClass::PlainSinglePart); + assert_eq!(normal_plain.decision, GetCodecStreamingDecision::Use); + + let copy = with_get_object_read_policy(GetObjectReadPolicy::CopySource, async { + let multipart = codec_streaming_reader_gate_for_test(&None, &object_info, &fi, true); + let plain = codec_streaming_reader_gate_for_test(&None, &plain_object_info, &plain_fi, true); + (multipart, plain) + }) + .await; + assert_eq!( + copy.0.decision, + GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::CopySourceDemandBound) + ); + assert_eq!( + copy.1.decision, + GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::CopySourceDemandBound) + ); + }, + ) + .await; + } + #[test] fn codec_streaming_reader_gate_keeps_multipart_default_off() { temp_env::with_vars( @@ -6108,6 +6176,10 @@ mod tests { assert_eq!(GetCodecStreamingFallbackReason::InvalidMinSize.as_str(), "invalid_min_size"); assert_eq!(GetCodecStreamingFallbackReason::ReadQuorumNotSafe.as_str(), "read_quorum_not_safe"); assert_eq!(GetCodecStreamingFallbackReason::MultipartPartLimit.as_str(), "multipart_part_limit"); + assert_eq!( + GetCodecStreamingFallbackReason::CopySourceDemandBound.as_str(), + "copy_source_demand_bound" + ); assert_eq!(GetCodecStreamingObjectClass::PlainSinglePart.as_str(), "plain_single_part"); assert_eq!(GetCodecStreamingObjectClass::Range.as_str(), "range"); assert_eq!(GetCodecStreamingObjectClass::Encrypted.as_str(), "encrypted"); diff --git a/crates/ecstore/src/store/object.rs b/crates/ecstore/src/store/object.rs index c5b7c321c..75fdc1aa4 100644 --- a/crates/ecstore/src/store/object.rs +++ b/crates/ecstore/src/store/object.rs @@ -2468,6 +2468,33 @@ impl ECStore { Self::resolve_decommission_tiered_object_result(result, bucket, &object) } + /// Open a source reader for a server-side copy. + /// + /// Copy consumers hold the source reader while a destination write can + /// apply backpressure. Keep that read contract explicit at the storage + /// boundary so the lower-level legacy multipart pipeline can suppress its + /// speculative next-part setup without changing the public `ObjectIO` + /// trait or `ObjectOptions` layout. + pub async fn get_object_reader_for_copy( + &self, + bucket: &str, + object: &str, + range: Option, + h: HeaderMap, + opts: &ObjectOptions, + ) -> Result<(GetObjectReader, tokio_util::sync::CancellationToken)> { + let cancellation = tokio_util::sync::CancellationToken::new(); + let reader = crate::set_disk::with_get_object_read_cancellation( + cancellation.clone(), + crate::set_disk::with_get_object_read_policy( + crate::set_disk::GetObjectReadPolicy::CopySource, + self.handle_get_object_reader(bucket, object, range, h, opts), + ), + ) + .await?; + Ok((reader, cancellation)) + } + #[instrument(level = "debug", skip(self, h))] #[hotpath::measure(impl_type = "ECStore")] pub(super) async fn handle_get_object_reader( diff --git a/rustfs/src/app/multipart_usecase.rs b/rustfs/src/app/multipart_usecase.rs index 89d116d14..7bff61075 100644 --- a/rustfs/src/app/multipart_usecase.rs +++ b/rustfs/src/app/multipart_usecase.rs @@ -33,7 +33,9 @@ use super::storage_api::multipart_usecase::contract::http::HTTPPreconditions; use super::storage_api::multipart_usecase::contract::multipart::{ CompletePart, MAX_MULTIPART_PART_NUMBER, MultipartOperations as _, MultipartUploadResult, }; -use super::storage_api::multipart_usecase::contract::object::{ObjectIO as _, ObjectOperations as _}; +#[cfg(test)] +use super::storage_api::multipart_usecase::contract::object::ObjectIO as _; +use super::storage_api::multipart_usecase::contract::object::ObjectOperations as _; use super::storage_api::multipart_usecase::contract::range::HTTPRangeSpec; use super::storage_api::multipart_usecase::data_usage::{ quota_object_size, record_bucket_object_version_write_memory, record_bucket_object_write_memory, @@ -1443,8 +1445,8 @@ impl DefaultMultipartUsecase { .into()); } - let src_reader = store - .get_object_reader(&src_bucket, &src_key, rs.clone(), h, &get_opts) + let (src_reader, _source_cancellation) = store + .get_object_reader_for_copy(&src_bucket, &src_key, rs.clone(), h, &get_opts) .await .map_err(map_get_object_reader_error)?; diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 022951744..0f5c39bbc 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -7948,11 +7948,18 @@ impl DefaultObjectUsecase { .into()); } - let gr = store - .get_object_reader(&src_bucket, &src_key, None, h, &src_get_opts) + let (gr, source_cancellation) = store + .get_object_reader_for_copy(&src_bucket, &src_key, None, h, &src_get_opts) .await .map_err(map_get_object_reader_error)?; + // The commit owner is intentionally detached so SetDisk can finish + // its rename/cleanup and post-commit publication if the HTTP caller + // goes away. Keep a request-owned guard for the source producer: + // cancellation drops the source read promptly, while the detached + // commit task retains the guards it needs to complete safely. + let _source_cancellation_guard = source_cancellation.clone().drop_guard(); + let mut src_info = gr.object_info.clone(); // A copy reads the source plaintext, so it needs the source key's decrypt permission diff --git a/rustfs/src/app/storage_api.rs b/rustfs/src/app/storage_api.rs index c9b3438af..40b4c459f 100644 --- a/rustfs/src/app/storage_api.rs +++ b/rustfs/src/app/storage_api.rs @@ -1185,7 +1185,9 @@ pub(crate) mod multipart_usecase { } pub(crate) mod object { - pub(crate) use super::super::super::storage_contracts::{ObjectIO, ObjectOperations}; + #[cfg(test)] + pub(crate) use super::super::super::storage_contracts::ObjectIO; + pub(crate) use super::super::super::storage_contracts::ObjectOperations; } pub(crate) mod range { diff --git a/scripts/run_get_codec_streaming_smoke.sh b/scripts/run_get_codec_streaming_smoke.sh index b8631634e..bfedc9496 100755 --- a/scripts/run_get_codec_streaming_smoke.sh +++ b/scripts/run_get_codec_streaming_smoke.sh @@ -710,6 +710,7 @@ Static gate reasons: - multipart_part_limit - invalid_min_size - read_quorum_not_safe +- copy_source_demand_bound Relevant focused proof points: - set_disk::read::tests::codec_streaming_reader_gate_defaults_to_disabled From 62a767a52d6573d3a01518472ac1222dafad7be8 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 21:25:02 +0800 Subject: [PATCH 03/54] test(connect): add short credential E2E profile (#6664) * feat(connect): add short credential E2E profile * ci(connect): test short credential boundary --- .github/workflows/ci.yml | 58 ++++++++++++++ rustfs/Cargo.toml | 2 + rustfs/src/connect/client.rs | 3 + rustfs/src/connect/registration.rs | 6 ++ rustfs/tests/connect_registration.rs | 108 +++++++++++++++++++++++---- 5 files changed, 161 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acb3ad106..d7cf207e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -495,6 +495,64 @@ jobs: cargo nextest run -p rustfs -p rustfs-ecstore --features rio-v2 cargo test -p rustfs --doc --features rio-v2 + connect-short-credential-boundary: + name: Connect Short Credential Boundary + if: github.event_name != 'pull_request' || github.event.action != 'closed' + needs: [ quick-checks ] + runs-on: sm-standard-4 + timeout-minutes: 60 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Rust environment + uses: ./.github/actions/setup + with: + rust-version: stable + cache-shared-key: ci-dev + cache-save-if: 'false' + install-build-packaging-tools: 'false' + install-test-tools: 'false' + + - name: Run short credential behavior tests + env: + CARGO_BUILD_JOBS: "2" + run: | + cargo test -p rustfs --test connect_registration \ + --features connect-e2e-short-credentials \ + registration_enforces_build_profile_credential_lifetime -- --exact + cargo test -p rustfs --test connect_registration \ + --features connect-e2e-short-credentials \ + rotation_waits_for_threshold_and_stops_on_revocation -- --exact + + - name: Reject short credentials in release builds + env: + CARGO_BUILD_JOBS: "2" + run: | + log="$(mktemp)" + set +e + CARGO_TERM_COLOR=never cargo check -p rustfs --release \ + --features connect-e2e-short-credentials >"$log" 2>&1 + status=$? + set -e + cat "$log" + expected='error: connect-e2e-short-credentials is restricted to debug builds' + summary="error: could not compile \`rustfs\` (lib) due to 1 previous error" + expected_count="$(grep -Fxc "$expected" "$log" || true)" + summary_count="$(grep -Fc "$summary" "$log" || true)" + error_count="$(grep -Ec '^error(:|\[)' "$log" || true)" + if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \ + || [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then + echo "release feature gate did not fail solely at the expected compile_error" >&2 + rm -f "$log" + exit 1 + fi + rm -f "$log" + test-and-lint-protocols: name: "Test and Lint (${{ matrix.features.name }})" if: github.event_name != 'pull_request' || github.event.action != 'closed' diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index a37ebfef8..670c4b395 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -57,6 +57,8 @@ tracing-chunk-debug = [] # Enable per-chunk tracing in data plane (high noise, full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope"] manual-test-runners = [] e2e-test-hooks = [] +# Shortens Connect credentials only in debug E2E builds. +connect-e2e-short-credentials = [] # Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root. offline-enrollment-e2e-root = [] rio-v2 = ["rustfs-ecstore/rio-v2"] diff --git a/rustfs/src/connect/client.rs b/rustfs/src/connect/client.rs index 114f1d742..4855adb46 100644 --- a/rustfs/src/connect/client.rs +++ b/rustfs/src/connect/client.rs @@ -37,6 +37,9 @@ use super::registration::{ const MAX_ATTEMPTS: usize = 3; const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +#[cfg(feature = "connect-e2e-short-credentials")] +const ROTATION_THRESHOLD_SECONDS: i64 = 120; +#[cfg(not(feature = "connect-e2e-short-credentials"))] const ROTATION_THRESHOLD_SECONDS: i64 = 8 * 60 * 60; const PENDING_REGISTRATION_STATE_DOMAIN: &[u8] = b"RUSTFS-CONNECT-PENDING-REGISTRATION-V1"; diff --git a/rustfs/src/connect/registration.rs b/rustfs/src/connect/registration.rs index 3be43c4ac..82d1dcae4 100644 --- a/rustfs/src/connect/registration.rs +++ b/rustfs/src/connect/registration.rs @@ -38,6 +38,12 @@ use super::identity::{DeviceIdentity, RegistrationProof}; pub const PROTOCOL_VERSION: &str = "v1"; +#[cfg(all(feature = "connect-e2e-short-credentials", not(debug_assertions)))] +compile_error!("connect-e2e-short-credentials is restricted to debug builds"); + +#[cfg(feature = "connect-e2e-short-credentials")] +const CERTIFICATE_LIFETIME_SECONDS: i64 = 300; +#[cfg(not(feature = "connect-e2e-short-credentials"))] const CERTIFICATE_LIFETIME_SECONDS: i64 = 86_400; const ROTATION_DOMAIN: &[u8] = b"RUSTFS-CONNECT-CREDENTIAL-ROTATION-V1"; const MAX_TOKEN_BYTES: u64 = 16 * 1024; diff --git a/rustfs/tests/connect_registration.rs b/rustfs/tests/connect_registration.rs index ff7937ab5..c6d5e8c61 100644 --- a/rustfs/tests/connect_registration.rs +++ b/rustfs/tests/connect_registration.rs @@ -50,6 +50,14 @@ use tokio::sync::watch; use tokio_rustls::TlsAcceptor; use tokio_util::sync::CancellationToken; +#[cfg(feature = "connect-e2e-short-credentials")] +const EXPECTED_CERTIFICATE_LIFETIME_SECONDS: i64 = 300; +#[cfg(not(feature = "connect-e2e-short-credentials"))] +const EXPECTED_CERTIFICATE_LIFETIME_SECONDS: i64 = 86_400; +#[cfg(feature = "connect-e2e-short-credentials")] +const EXPECTED_ROTATION_THRESHOLD_SECONDS: i64 = 120; +#[cfg(not(feature = "connect-e2e-short-credentials"))] +const EXPECTED_ROTATION_THRESHOLD_SECONDS: i64 = 8 * 60 * 60; const ORGANIZATION_UID: &str = "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70"; const CLUSTER_UID: &str = "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81"; const DEVICE_UID: &str = "0198f4b0-3c00-7e30-8f41-4a5b6c7d8e92"; @@ -106,7 +114,13 @@ impl TestPki { fn credential(&self, identity: &rustfs::connect::DeviceIdentity, uri: &str, serial_byte: u8) -> Value { let now = OffsetDateTime::now_utc().replace_nanosecond(0).expect("whole second"); - self.credential_window(identity, uri, serial_byte, now, now + time::Duration::days(1)) + self.credential_window( + identity, + uri, + serial_byte, + now, + now + time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS), + ) } fn credential_window( @@ -474,14 +488,19 @@ fn due_runtime_config( pki: &TestPki, endpoint: &str, ) -> (HeartbeatConfig, Value, rustfs::connect::DeviceIdentity) { - runtime_config(temp, pki, endpoint, 17) + runtime_config( + temp, + pki, + endpoint, + EXPECTED_CERTIFICATE_LIFETIME_SECONDS - EXPECTED_ROTATION_THRESHOLD_SECONDS, + ) } fn runtime_config( temp: &tempfile::TempDir, pki: &TestPki, endpoint: &str, - elapsed_hours: i64, + elapsed_seconds: i64, ) -> (HeartbeatConfig, Value, rustfs::connect::DeviceIdentity) { let (identity_store, credential_store) = stores(temp); let identity = identity_store.load_or_create().expect("create current identity"); @@ -490,13 +509,13 @@ fn runtime_config( &identity, &format!("urn:rustfs:connect:device:{DEVICE_UID}"), 0x20, - now - time::Duration::hours(elapsed_hours), - now + time::Duration::hours(24 - elapsed_hours), + now - time::Duration::seconds(elapsed_seconds), + now + time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS - elapsed_seconds), ); let not_before = OffsetDateTime::parse(credential["notBefore"].as_str().expect("notBefore"), &Rfc3339).expect("parse notBefore"); let not_after = OffsetDateTime::parse(credential["notAfter"].as_str().expect("notAfter"), &Rfc3339).expect("parse notAfter"); - assert_eq!(not_after - not_before, time::Duration::hours(24)); + assert_eq!(not_after - not_before, time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS)); fs::create_dir_all(temp.path().join("credential")).expect("credential directory"); write_stored_credential(&temp.path().join("credential/device.crt.json"), &credential); let next = stage_next_identity(temp); @@ -665,6 +684,49 @@ async fn registration_rejects_untrusted_or_misbound_credentials() { } } +#[tokio::test] +async fn registration_enforces_build_profile_credential_lifetime() { + let pki = TestPki::new(); + let accepted_temp = tempfile::tempdir().expect("temp dir"); + let (accepted_identity_store, accepted_credential_store) = stores(&accepted_temp); + let accepted_identity = accepted_identity_store.load_or_create().expect("create identity"); + let accepted = pki.credential(&accepted_identity, &format!("urn:rustfs:connect:device:{DEVICE_UID}"), 4); + let accepted_server = server(&pki, vec![Reply::Json(StatusCode::CREATED, accepted)]).await; + let credential = client(&accepted_server, &pki, Duration::from_secs(2)) + .register(&accepted_identity_store, &accepted_credential_store, &token()) + .await + .expect("configured lifetime must be accepted"); + assert_eq!( + credential.not_after_unix - credential.not_before_unix, + EXPECTED_CERTIFICATE_LIFETIME_SECONDS + ); + + let rejected_temp = tempfile::tempdir().expect("temp dir"); + let (rejected_identity_store, rejected_credential_store) = stores(&rejected_temp); + let rejected_identity = rejected_identity_store.load_or_create().expect("create identity"); + let now = OffsetDateTime::now_utc().replace_nanosecond(0).expect("whole second"); + let other_lifetime = if cfg!(feature = "connect-e2e-short-credentials") { + 86_400 + } else { + 300 + }; + let rejected = pki.credential_window( + &rejected_identity, + &format!("urn:rustfs:connect:device:{DEVICE_UID}"), + 5, + now, + now + time::Duration::seconds(other_lifetime), + ); + let rejected_server = server(&pki, vec![Reply::Json(StatusCode::CREATED, rejected)]).await; + assert!(matches!( + client(&rejected_server, &pki, Duration::from_secs(2)) + .register(&rejected_identity_store, &rejected_credential_store, &token()) + .await, + Err(ClientError::Credential(_)) + )); + assert!(!rejected_temp.path().join("credential/device.crt.json").exists()); +} + #[tokio::test] async fn stored_credential_is_revalidated_before_reuse() { let temp = tempfile::tempdir().expect("temp dir"); @@ -710,8 +772,8 @@ async fn register_rejects_expired_and_not_yet_valid_stored_credentials() { &identity, &format!("urn:rustfs:connect:device:{DEVICE_UID}"), 10, - now - time::Duration::days(2), - now - time::Duration::days(1), + now - time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS + 60), + now - time::Duration::seconds(60), ); write_stored_credential(&path, &expired); assert!(matches!( @@ -724,7 +786,7 @@ async fn register_rejects_expired_and_not_yet_valid_stored_credentials() { &format!("urn:rustfs:connect:device:{DEVICE_UID}"), 11, now + time::Duration::hours(1), - now + time::Duration::hours(25), + now + time::Duration::hours(1) + time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS), ); write_stored_credential(&path, &future); assert!(matches!( @@ -765,7 +827,7 @@ async fn concurrent_rotation_retries_converge_and_promote_the_next_key() { ) .await; let retry_client = client(&retries, &pki, Duration::from_millis(80)); - let due = registered.not_after_unix - 8 * 60 * 60; + let due = registered.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS; let (first, second) = tokio::join!( retry_client.rotate_if_due(&identity_store, &credential_store, due), retry_client.rotate_if_due(&identity_store, &credential_store, due) @@ -1074,7 +1136,7 @@ async fn heartbeat_retries_with_the_new_credential_after_concurrent_rotation() { let due = OffsetDateTime::parse(current["notAfter"].as_str().expect("notAfter"), &Rfc3339) .expect("parse notAfter") .unix_timestamp() - - 8 * 60 * 60; + - EXPECTED_ROTATION_THRESHOLD_SECONDS; client(&server, &pki, Duration::from_millis(250)) .rotate_if_due(&identity_store, &credential_store, due) .await @@ -1159,7 +1221,7 @@ async fn inventory_retries_with_the_new_credential_after_concurrent_rotation() { let due = OffsetDateTime::parse(current["notAfter"].as_str().expect("notAfter"), &Rfc3339) .expect("parse notAfter") .unix_timestamp() - - 8 * 60 * 60; + - EXPECTED_ROTATION_THRESHOLD_SECONDS; client(&server, &pki, Duration::from_millis(250)) .rotate_if_due(&identity_store, &credential_store, due) .await @@ -1391,7 +1453,7 @@ async fn rotation_commit_recovers_after_each_durable_step() { .await .expect("register"); let failed = server(&pki, vec![Reply::Json(StatusCode::SERVICE_UNAVAILABLE, json!({})); 3]).await; - let due = registered.not_after_unix - 8 * 60 * 60; + let due = registered.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS; assert!(matches!( client(&failed, &pki, Duration::from_secs(2)) .rotate_if_due(&identity_store, &credential_store, due) @@ -1473,7 +1535,11 @@ async fn pending_reenrollment_blocks_rotation_and_resumes_original_exchange() { let rotation = server(&pki, vec![]).await; let error = client(&rotation, &pki, Duration::from_secs(2)) - .rotate_if_due(&identity_store, &credential_store, registered.not_after_unix - 8 * 60 * 60) + .rotate_if_due( + &identity_store, + &credential_store, + registered.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS, + ) .await .expect_err("pending reenrollment blocks rotation"); assert!(matches!(error, ClientError::PendingRegistration)); @@ -1660,15 +1726,25 @@ async fn rotation_waits_for_threshold_and_stops_on_revocation() { .register(&identity_store, &credential_store, &token()) .await .expect("register"); + assert_eq!(current.not_after_unix - current.not_before_unix, EXPECTED_CERTIFICATE_LIFETIME_SECONDS); assert!( connect - .rotate_if_due(&identity_store, &credential_store, current.not_before_unix) + .rotate_if_due( + &identity_store, + &credential_store, + current.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS - 1, + ) .await .expect("not due") .is_none() ); + assert_eq!(rotation_server.seen.lock().expect("seen lock").len(), 1); let error = connect - .rotate_if_due(&identity_store, &credential_store, current.not_after_unix - 8 * 60 * 60) + .rotate_if_due( + &identity_store, + &credential_store, + current.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS, + ) .await .expect_err("revocation must stop rotation"); assert!(matches!(error, ClientError::AccessRevoked { .. })); From ba15588ce87732577f97a35517e482109b9e5643 Mon Sep 17 00:00:00 2001 From: houseme Date: Wed, 26 Aug 2026 21:25:16 +0800 Subject: [PATCH 04/54] chore(deps): refresh s3s and dependencies (#6665) * chore(deps): refresh s3s and related dependencies Update the RustFS s3s git dependency to the requested f4dedc905 revision and keep the resolved dependency refresh from Cargo. Co-Authored-By: heihutu * fix(api): adapt s3s upload stream error mapping Detect the s3s upload stream SHA256 mismatch through the error chain without relying on the removed crate-root re-export. Co-Authored-By: heihutu * fix(auth): preserve SigV2 S3 compatibility Keep RustFS S3 service configuration explicit after the s3s default disables SigV2. Co-Authored-By: heihutu --------- Co-authored-by: heihutu --- Cargo.lock | 90 ++++++++++++++++---------------- Cargo.toml | 8 +-- rustfs/src/app/object_usecase.rs | 21 ++++++-- rustfs/src/error.rs | 48 +++++++++++++---- rustfs/src/server/http.rs | 19 ++++++- 5 files changed, 120 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b83156ba..b2db988dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -766,9 +766,9 @@ dependencies = [ [[package]] name = "async-rs" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd5147201b63ba6883ffabca3a153822f71541748d7108e3e799beaeb283131" +checksum = "c5f55b2bcef73a79a2feb5496478693077b60b7337896f0b72431cd5311d988a" dependencies = [ "async-compat", "async-global-executor", @@ -971,9 +971,9 @@ dependencies = [ [[package]] name = "aws-sdk-kms" -version = "1.116.0" +version = "1.117.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484ecdbea2a1cfc0e6eea69ce0a665f93913671b303ba40b2361b1d826544e7e" +checksum = "83b602641be84ebe5f96606cfefe4b96efaae1fd947c1b34ea8513b8ac0d2d8d" dependencies = [ "arc-swap", "aws-credential-types", @@ -997,9 +997,9 @@ dependencies = [ [[package]] name = "aws-sdk-s3" -version = "1.143.0" +version = "1.144.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ade5433c9561daac7c0c6bc910f1240b4f8ec0d6148b0b463aac0691d747c9" +checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de" dependencies = [ "arc-swap", "aws-credential-types", @@ -1024,7 +1024,7 @@ dependencies = [ "http 0.2.12", "http 1.5.0", "http-body 1.1.0", - "lru 0.16.4", + "lru 0.18.2", "percent-encoding", "regex-lite", "sha2 0.11.0", @@ -1034,9 +1034,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.107.0" +version = "1.108.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "769b0abd0f89cfe11da5099986dd493e4f94347ce9a4562cb86ddecfe926b6c0" +checksum = "c15301b04372832947916607983b114b3374b9db0be058a00fb7513800de1f05" dependencies = [ "arc-swap", "aws-credential-types", @@ -1060,9 +1060,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.109.0" +version = "1.110.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4075b8a2c8cda4076a3dcc43b9d6dabd93e0c2502abeaaf7e14aaead9bb312b" +checksum = "72cc2c205cb27108183cf1856333f7d584c2ba0f505421b4209ca5828f9ea899" dependencies = [ "arc-swap", "aws-credential-types", @@ -1086,9 +1086,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.112.0" +version = "1.113.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f582002918346a3e685be1b391c7bea155073088cea6bd4e4b7663df9e43b6c" +checksum = "68182ecb449f7537db0f4d5d25917789cf41e32074a9fe47b6a0b847fe1d2032" dependencies = [ "arc-swap", "aws-credential-types", @@ -1629,7 +1629,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array 0.14.9", + "generic-array 0.14.7", ] [[package]] @@ -1648,7 +1648,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array 0.14.9", + "generic-array 0.14.7", ] [[package]] @@ -2003,7 +2003,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.6", + "crypto-common 0.1.7", "inout 0.1.4", ] @@ -2094,9 +2094,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "futures-core", @@ -2463,7 +2463,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array 0.14.9", + "generic-array 0.14.7", "rand_core 0.6.4", "subtle", "zeroize", @@ -2488,11 +2488,11 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array 0.14.9", + "generic-array 0.14.7", "typenum", ] @@ -3758,7 +3758,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid 0.9.6", - "crypto-common 0.1.6", + "crypto-common 0.1.7", "subtle", ] @@ -4021,7 +4021,7 @@ dependencies = [ "crypto-bigint 0.5.5", "digest 0.10.7", "ff 0.13.1", - "generic-array 0.14.9", + "generic-array 0.14.7", "group 0.13.0", "hkdf 0.12.4", "pem-rfc7468 0.7.0", @@ -4466,9 +4466,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -4481,7 +4481,7 @@ version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" dependencies = [ - "generic-array 0.14.9", + "generic-array 0.14.7", "rustversion", "typenum", ] @@ -5516,7 +5516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding 0.3.3", - "generic-array 0.14.9", + "generic-array 0.14.7", ] [[package]] @@ -6762,7 +6762,7 @@ dependencies = [ "ed25519-dalek 2.2.0", "getrandom 0.2.17", "log", - "rand 0.8.7", + "rand 0.8.8", "signatory", ] @@ -6818,7 +6818,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" dependencies = [ - "rand 0.8.7", + "rand 0.8.8", ] [[package]] @@ -6853,7 +6853,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.7", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -6971,7 +6971,7 @@ dependencies = [ "chrono", "getrandom 0.2.17", "http 1.5.0", - "rand 0.8.7", + "rand 0.8.8", "serde", "serde_json", "serde_path_to_error", @@ -7149,7 +7149,7 @@ dependencies = [ "oauth2", "p256 0.13.2", "p384 0.13.1", - "rand 0.8.7", + "rand 0.8.8", "rsa 0.9.10", "serde", "serde-value", @@ -8144,8 +8144,8 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "heck 0.4.1", - "itertools 0.10.5", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -8164,8 +8164,8 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", - "itertools 0.10.5", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "petgraph 0.8.3", @@ -8186,7 +8186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -8199,7 +8199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -8283,7 +8283,7 @@ dependencies = [ "prost 0.13.5", "prost-build 0.13.5", "prost-derive 0.13.5", - "rand 0.8.7", + "rand 0.8.8", "regex", "rustls", "tokio", @@ -8560,9 +8560,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -10993,7 +10993,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "s3s" version = "0.15.0" -source = "git+https://github.com/rustfs/s3s.git?rev=5f22e8d0a37e83f531f653024aac11c72586479a#5f22e8d0a37e83f531f653024aac11c72586479a" +source = "git+https://github.com/rustfs/s3s.git?rev=f4dedc905ec621fa85a4686df6304190b55375f6#f4dedc905ec621fa85a4686df6304190b55375f6" dependencies = [ "arc-swap", "arrayvec", @@ -11147,7 +11147,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct 0.2.0", "der 0.7.10", - "generic-array 0.14.9", + "generic-array 0.14.7", "pkcs8 0.10.2", "subtle", "zeroize", @@ -12473,7 +12473,7 @@ dependencies = [ "futures-sink", "http 1.5.0", "httparse", - "rand 0.8.7", + "rand 0.8.8", "rustls-pki-types", "tokio", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index 3400d9309..35b61c7fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -237,9 +237,9 @@ atoi = "3.1.0" atomic_enum = "0.3.0" aws-config = { version = "1.11.0" } aws-credential-types = { version = "1.3.0" } -aws-sdk-kms = { default-features = false, version = "1.116.0" } -aws-sdk-s3 = { default-features = false, version = "1.143.0" } -aws-sdk-sts = { default-features = false, version = "1.112.0" } +aws-sdk-kms = { default-features = false, version = "1.117.0" } +aws-sdk-s3 = { default-features = false, version = "1.144.0" } +aws-sdk-sts = { default-features = false, version = "1.113.0" } aws-smithy-http-client = { default-features = false, version = "1.4.0" } aws-smithy-runtime-api = { version = "1.15.0" } aws-smithy-types = { version = "1.6.2" } @@ -306,7 +306,7 @@ rustify = { version = "0.7", default-features = false } rustix = { version = "1.1.4" } rust-embed = { version = "8.12.0" } rustc-hash = { version = "2.1.3" } -s3s = { git = "https://github.com/rustfs/s3s.git", rev = "5f22e8d0a37e83f531f653024aac11c72586479a", version = "0.15.0", features = ["minio"] } +s3s = { git = "https://github.com/rustfs/s3s.git", rev = "f4dedc905ec621fa85a4686df6304190b55375f6", version = "0.15.0", features = ["minio"] } serial_test = "4.0.1" shadow-rs = { default-features = false, version = "2.0.0" } siphasher = "1.0.3" diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 0f5c39bbc..804352de6 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -10373,6 +10373,17 @@ mod tests { use tokio::io::{AsyncRead, ReadBuf}; use tokio_tar::{Builder, EntryType, Header}; + #[derive(Debug)] + struct MockUploadStreamSha256Mismatch; + + impl std::fmt::Display for MockUploadStreamSha256Mismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("UploadStreamError: Sha256Mismatch") + } + } + + impl std::error::Error for MockUploadStreamSha256Mismatch {} + #[tokio::test] async fn cancelled_eager_put_commit_owner_reaps_stalled_storage_task() { let health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); @@ -16386,20 +16397,20 @@ mod tests { #[test] fn s3s_body_error_to_io_preserves_upload_stream_error_source() { - let error = s3s_body_error_to_io(Box::new(s3s::UploadStreamError::Sha256Mismatch)); + let error = s3s_body_error_to_io(Box::new(MockUploadStreamSha256Mismatch)); assert!(matches!( error .get_ref() - .and_then(|source| source.downcast_ref::()), - Some(s3s::UploadStreamError::Sha256Mismatch) + .and_then(|source| source.downcast_ref::()), + Some(MockUploadStreamSha256Mismatch) )); } #[tokio::test] async fn read_small_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { let body = StreamReader::new(futures::stream::iter(vec![Err::(s3s_body_error_to_io(Box::new( - s3s::UploadStreamError::Sha256Mismatch, + MockUploadStreamSha256Mismatch, )))])); let error = read_small_put_body_exact_direct(body, 1) @@ -16411,7 +16422,7 @@ mod tests { #[tokio::test] async fn read_zero_copy_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { - let body = futures::stream::iter(vec![Err::(s3s::UploadStreamError::Sha256Mismatch)]); + let body = futures::stream::iter(vec![Err::(MockUploadStreamSha256Mismatch)]); let error = match read_zero_copy_put_body_exact(body, 1).await { Ok(_) => panic!("SHA256 mismatch should reject the zero-copy PUT body"), diff --git a/rustfs/src/error.rs b/rustfs/src/error.rs index c033caeda..5baffe40b 100644 --- a/rustfs/src/error.rs +++ b/rustfs/src/error.rs @@ -226,7 +226,7 @@ where } fn error_chain_has_upload_stream_sha256_mismatch(err: &(dyn std::error::Error + 'static)) -> bool { - if matches!(err.downcast_ref::(), Some(s3s::UploadStreamError::Sha256Mismatch)) { + if err.to_string() == "UploadStreamError: Sha256Mismatch" { return true; } @@ -239,7 +239,7 @@ fn error_chain_has_upload_stream_sha256_mismatch(err: &(dyn std::error::Error + let mut current = err.source(); while let Some(err) = current { - if matches!(err.downcast_ref::(), Some(s3s::UploadStreamError::Sha256Mismatch)) { + if err.to_string() == "UploadStreamError: Sha256Mismatch" { return true; } current = err.source(); @@ -452,6 +452,34 @@ mod tests { use s3s::{S3Error, S3ErrorCode}; use std::io::{Error as IoError, ErrorKind}; + #[derive(Debug)] + enum MockUploadStreamError { + Underlying(IoError), + Sha256Mismatch, + LengthMismatch, + Incomplete, + } + + impl std::fmt::Display for MockUploadStreamError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Underlying(err) => write!(f, "UploadStreamError: Underlying: {err}"), + Self::Sha256Mismatch => f.write_str("UploadStreamError: Sha256Mismatch"), + Self::LengthMismatch => f.write_str("UploadStreamError: LengthMismatch"), + Self::Incomplete => f.write_str("UploadStreamError: Incomplete"), + } + } + } + + impl std::error::Error for MockUploadStreamError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Underlying(err) => Some(err), + Self::Sha256Mismatch | Self::LengthMismatch | Self::Incomplete => None, + } + } + } + #[test] fn test_api_error_from_io_error() { let io_error = IoError::new(ErrorKind::PermissionDenied, "permission denied"); @@ -515,11 +543,11 @@ mod tests { #[test] fn upload_stream_sha256_mismatch_maps_to_bad_digest() { - let api_error = ApiError::from(IoError::other(s3s::UploadStreamError::Sha256Mismatch)); + let api_error = ApiError::from(IoError::other(MockUploadStreamError::Sha256Mismatch)); assert_eq!(api_error.code, S3ErrorCode::BadDigest); assert_eq!(api_error.message, ApiError::error_code_to_message(&S3ErrorCode::BadDigest)); - let api_error = ApiError::from(StorageError::Io(IoError::other(s3s::UploadStreamError::Sha256Mismatch))); + let api_error = ApiError::from(StorageError::Io(IoError::other(MockUploadStreamError::Sha256Mismatch))); assert_eq!(api_error.code, S3ErrorCode::BadDigest); assert_eq!(api_error.message, ApiError::error_code_to_message(&S3ErrorCode::BadDigest)); } @@ -527,9 +555,9 @@ mod tests { #[test] fn other_upload_stream_errors_do_not_map_to_bad_digest() { let errors = [ - s3s::UploadStreamError::Underlying(Box::new(IoError::other("underlying body error"))), - s3s::UploadStreamError::LengthMismatch, - s3s::UploadStreamError::Incomplete, + MockUploadStreamError::Underlying(IoError::other("underlying body error")), + MockUploadStreamError::LengthMismatch, + MockUploadStreamError::Incomplete, ]; for error in errors { @@ -538,9 +566,9 @@ mod tests { } let errors = [ - s3s::UploadStreamError::Underlying(Box::new(IoError::other("underlying body error"))), - s3s::UploadStreamError::LengthMismatch, - s3s::UploadStreamError::Incomplete, + MockUploadStreamError::Underlying(IoError::other("underlying body error")), + MockUploadStreamError::LengthMismatch, + MockUploadStreamError::Incomplete, ]; for error in errors { diff --git a/rustfs/src/server/http.rs b/rustfs/src/server/http.rs index 20253d2df..b3ac5e93d 100644 --- a/rustfs/src/server/http.rs +++ b/rustfs/src/server/http.rs @@ -151,6 +151,14 @@ static HTTP_STATUS_CLASS_METRICS: std::sync::LazyLock<[HttpStatusClassMetrics; 6 std::sync::LazyLock::new(|| HTTP_STATUS_CLASS_LABELS.map(HttpStatusClassMetrics::new)); static HTTP_TRANSPORT_FAILURES_COUNTER: std::sync::LazyLock = std::sync::LazyLock::new(|| counter!(METRIC_HTTP_SERVER_FAILURES_TOTAL, LABEL_HTTP_STATUS_CLASS => "transport")); + +fn rustfs_s3_config() -> S3Config { + let mut s3_config = S3Config::default(); + s3_config.normalize_forward_slash_path = true; + s3_config.enable_sig_v2 = true; + s3_config +} + const LOG_COMPONENT_SERVER: &str = "server"; const LOG_SUBSYSTEM_HTTP: &str = "http"; const LOG_SUBSYSTEM_TRANSPORT: &str = "transport"; @@ -922,8 +930,7 @@ pub async fn start_http_server( // `PUT /bucket//foo/bar` are rejected downstream with InvalidArgument // (ObjectNamePrefixAsSlash, issue #2427). MinIO collapses these slashes instead of preserving them, // so `//foo/bar` is stored and served as `foo/bar`. - let mut s3_config = S3Config::default(); - s3_config.normalize_forward_slash_path = true; + let s3_config = rustfs_s3_config(); b.set_config(Arc::new(StaticConfigProvider::new(Arc::new(s3_config)))); // Virtual-hosted-style requests are only set up for S3 API when server domains are configured and console is disabled @@ -2257,6 +2264,14 @@ mod tests { assert_eq!(HTTP_STATUS_CLASS_LABELS[HTTP_STATUS_UNKNOWN_INDEX], "unknown"); } + #[test] + fn rustfs_s3_config_preserves_compatibility_over_s3s_defaults() { + let s3_config = rustfs_s3_config(); + + assert!(s3_config.normalize_forward_slash_path); + assert!(s3_config.enable_sig_v2); + } + #[test] #[serial_test::serial] fn cached_http_metric_handles_preserve_metric_labels() { From 9f245e3fd41b6cd28c13b21424b1622d92905008 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 21:25:35 +0800 Subject: [PATCH 05/54] refactor(ecstore): move object_lock WORM evaluation onto storage-level types (#6666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The object_lock module evaluated WORM state through s3s wire DTOs (ObjectLockRetention, ObjectLockLegalHold, DefaultRetention, Date) and s3s header constants, keeping the storage engine coupled to the serving protocol (rustfs/backlog#1842, ARCHITECTURE.md invariant 4). This PR gives the module its own storage-level vocabulary and pushes the DTO conversions to the boundaries that already speak s3s. New crates/ecstore/src/bucket/object_lock/types.rs defines RetentionMode, LegalHoldStatus, ObjectRetention, ObjectLegalHold, and DefaultRetention with no s3s dependency. objectlock.rs parses persisted metadata into these types using the rustfs-utils lowercase header constants (the same literal keys as before, pinned by the existing g-key-002 test). objectlock_sys.rs evaluates retention/legal-hold/default-retention from them; the fail-closed error messages and decision logic are unchanged line for line where possible. Boundary conversions: - bucket/metadata_sys.rs gains default_retention_from_object_lock_config, converting the persisted s3s configuration into the storage-level DefaultRetention; a rule without a usable GOVERNANCE/COMPLIANCE mode converts to None exactly like the evaluation code always ignored it, and days/years pass through so an invalid period still fails closed at evaluation time. - check_object_lock_for_deletion_with_config becomes check_object_lock_for_deletion_with_default_retention (it only ever read the default retention); the lifecycle object_lock_boundary keeps the old s3s-typed signature and converts. - The ObjectLockApi / ObjectLockStatusExt trait impls for the s3s DTOs move next to the persisted configuration owner in bucket/metadata.rs; the traits stay in object_lock/mod.rs. - check_retention_for_modification now takes Option. The serving-layer wrappers (rustfs storage_api, set_disk options path) convert the request string with the new RetentionMode::parse_exact, which accepts only the canonical spelling — preserving the historical literal comparison where a non-canonical requested mode reads as a mode change and stays blocked. - rustfs app-layer wrappers return the storage types; the replication-overwrite gate in object_usecase.rs uses the typed API (legal_hold.is_on(), RetentionMode::Compliance). Ratchet: the ecstore-scoped s3s counter drops 42 -> 39 and the repo-wide file counter 211 -> 208 in scripts/check_s3s_footprint.sh. Verification: cargo check -p rustfs-ecstore --all-targets and -p rustfs (lib+bins); cargo clippy -p rustfs-ecstore --all-targets and -p rustfs --lib --bins (clean); cargo nextest run -p rustfs-ecstore --no-fail-fast (4534/4542; the 8 failures are the same store::rebalance / store::heal machine-baseline set that fails identically on pristine origin/main, plus one fencing flake that passes in isolation); all object_lock/retention/legal-hold tests pass; guard scripts (layer deps, migration rules, s3s footprint, logging, error-format ratchet, doc paths) pass. --- crates/ecstore/src/api/mod.rs | 6 + .../bucket/lifecycle/object_lock_boundary.rs | 3 +- crates/ecstore/src/bucket/metadata.rs | 22 +- crates/ecstore/src/bucket/metadata_sys.rs | 47 +++ crates/ecstore/src/bucket/object_lock/mod.rs | 23 +- .../src/bucket/object_lock/objectlock.rs | 97 ++--- .../src/bucket/object_lock/objectlock_sys.rs | 346 +++++++----------- .../ecstore/src/bucket/object_lock/types.rs | 179 +++++++++ crates/ecstore/src/set_disk/mod.rs | 9 +- rustfs/src/app/object_usecase.rs | 13 +- rustfs/src/app/storage_api.rs | 13 +- rustfs/src/storage/storage_api.rs | 4 + scripts/check_s3s_footprint.sh | 4 +- 13 files changed, 462 insertions(+), 304 deletions(-) create mode 100644 crates/ecstore/src/bucket/object_lock/types.rs diff --git a/crates/ecstore/src/api/mod.rs b/crates/ecstore/src/api/mod.rs index ea9822793..f8d9ac9d4 100644 --- a/crates/ecstore/src/api/mod.rs +++ b/crates/ecstore/src/api/mod.rs @@ -154,6 +154,12 @@ pub mod bucket { pub mod object_lock { pub use crate::bucket::object_lock::{ObjectLockApi, ObjectLockStatusExt}; + pub mod types { + pub use crate::bucket::object_lock::types::{ + DefaultRetention, LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode, + }; + } + pub mod objectlock { pub use crate::bucket::object_lock::objectlock::{get_object_legalhold_meta, get_object_retention_meta}; } diff --git a/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs b/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs index 79ec3c7a1..21d1c0656 100644 --- a/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs +++ b/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs @@ -26,7 +26,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config( obj_info: &ObjectInfo, bypass_governance: bool, ) -> crate::error::Result> { - objectlock_sys::check_object_lock_for_deletion_with_config(config, obj_info, bypass_governance) + let default_retention = config.and_then(crate::bucket::metadata_sys::default_retention_from_object_lock_config); + objectlock_sys::check_object_lock_for_deletion_with_default_retention(default_retention.as_ref(), obj_info, bypass_governance) } #[cfg(test)] diff --git a/crates/ecstore/src/bucket/metadata.rs b/crates/ecstore/src/bucket/metadata.rs index 32b3fb35f..944fe08d9 100644 --- a/crates/ecstore/src/bucket/metadata.rs +++ b/crates/ecstore/src/bucket/metadata.rs @@ -13,7 +13,7 @@ // limitations under the License. use super::msgp_decode::{read_msgp_ext8_time, skip_msgp_value, write_msgp_time}; -use super::object_lock::ObjectLockApi; +use super::object_lock::{ObjectLockApi, ObjectLockStatusExt}; use super::versioning::VersioningApi; use super::{quota::BucketQuota, target::BucketTargets}; use crate::bucket::replication::invalid_replication_config_status_field; @@ -39,6 +39,26 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time as CivilTime, UtcOffset use tracing::error; use uuid::Uuid; +// The serving-layer DTO impls for the storage-level Object Lock traits live +// here because this module owns the persisted `ObjectLockConfiguration` +// during the s3s ratchet migration (rustfs/backlog#1842). +impl ObjectLockApi for ObjectLockConfiguration { + fn enabled(&self) -> bool { + self.object_lock_enabled + .as_ref() + .is_some_and(|v| v.as_str() == s3s::dto::ObjectLockEnabled::ENABLED) + } +} + +impl ObjectLockStatusExt for s3s::dto::ObjectLockLegalHoldStatus { + fn valid(&self) -> bool { + matches!( + self.as_str(), + s3s::dto::ObjectLockLegalHoldStatus::ON | s3s::dto::ObjectLockLegalHoldStatus::OFF + ) + } +} + fn read_msgp_str(rd: &mut R) -> Result { let len = rmp::decode::read_str_len(rd)? as usize; let mut buf = vec![0u8; len]; diff --git a/crates/ecstore/src/bucket/metadata_sys.rs b/crates/ecstore/src/bucket/metadata_sys.rs index ba166d7f1..3e0e6ded6 100644 --- a/crates/ecstore/src/bucket/metadata_sys.rs +++ b/crates/ecstore/src/bucket/metadata_sys.rs @@ -167,6 +167,53 @@ pub(crate) fn object_lock_config_state_from_authoritative_metadata(bm: &BucketMe Ok(ObjectLockConfigState::ConfirmedAbsent) } +/// Convert the persisted serving-layer configuration into the storage-level +/// [`DefaultRetention`](crate::bucket::object_lock::types::DefaultRetention) +/// the WORM evaluation code consumes (rustfs/backlog#1842). A rule without a +/// usable GOVERNANCE/COMPLIANCE mode converts to `None`, exactly like the +/// evaluation code has always ignored such rules; days/years are passed +/// through untouched so an invalid period still fails closed at evaluation. +pub(crate) fn default_retention_from_object_lock_config( + config: &ObjectLockConfiguration, +) -> Option { + let default_retention = config.rule.as_ref()?.default_retention.as_ref()?; + let mode = crate::bucket::object_lock::types::RetentionMode::parse(default_retention.mode.as_ref()?.as_str())?; + Some(crate::bucket::object_lock::types::DefaultRetention { + mode, + days: default_retention.days, + years: default_retention.years, + }) +} + +/// Test-only builder for a `Configured` Object Lock state carrying a default +/// retention, so storage-side tests do not have to name serving-layer DTOs. +#[cfg(test)] +pub(crate) fn configured_object_lock_state_for_tests( + mode: crate::bucket::object_lock::types::RetentionMode, + days: i32, +) -> ObjectLockConfigState { + ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(s3s::dto::ObjectLockRule { + default_retention: Some(s3s::dto::DefaultRetention { + mode: Some(s3s::dto::ObjectLockRetentionMode::from_static(match mode { + crate::bucket::object_lock::types::RetentionMode::Governance => { + s3s::dto::ObjectLockRetentionMode::GOVERNANCE + } + crate::bucket::object_lock::types::RetentionMode::Compliance => { + s3s::dto::ObjectLockRetentionMode::COMPLIANCE + } + })), + days: Some(days), + years: None, + }), + }), + }, + updated_at: OffsetDateTime::now_utc(), + } +} + fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -> Result<()> { if config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str) != Some(ObjectLockEnabled::ENABLED) { return Err(Error::other("persisted bucket Object Lock enabled state is invalid")); diff --git a/crates/ecstore/src/bucket/object_lock/mod.rs b/crates/ecstore/src/bucket/object_lock/mod.rs index 0a821f81a..9f6f3b8ce 100644 --- a/crates/ecstore/src/bucket/object_lock/mod.rs +++ b/crates/ecstore/src/bucket/object_lock/mod.rs @@ -14,27 +14,18 @@ pub mod objectlock; pub mod objectlock_sys; +pub mod types; -use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus}; - +/// Whether a bucket Object Lock configuration has locking enabled. The +/// serving-layer `ObjectLockConfiguration` DTO implements this in +/// the bucket-metadata module, which owns the persisted configuration type +/// during the s3s ratchet migration (rustfs/backlog#1842). pub trait ObjectLockApi { fn enabled(&self) -> bool; } -impl ObjectLockApi for ObjectLockConfiguration { - fn enabled(&self) -> bool { - self.object_lock_enabled - .as_ref() - .is_some_and(|v| v.as_str() == ObjectLockEnabled::ENABLED) - } -} - +/// Whether a legal-hold status value is one of the two valid wire values. +/// Implemented for the serving-layer DTO in the bucket-metadata module. pub trait ObjectLockStatusExt { fn valid(&self) -> bool; } - -impl ObjectLockStatusExt for ObjectLockLegalHoldStatus { - fn valid(&self) -> bool { - matches!(self.as_str(), ObjectLockLegalHoldStatus::ON | ObjectLockLegalHoldStatus::OFF) - } -} diff --git a/crates/ecstore/src/bucket/object_lock/objectlock.rs b/crates/ecstore/src/bucket/object_lock/objectlock.rs index 48f20f29d..a45e1ac50 100644 --- a/crates/ecstore/src/bucket/object_lock/objectlock.rs +++ b/crates/ecstore/src/bucket/object_lock/objectlock.rs @@ -12,8 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -use s3s::dto::{Date, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode}; -use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE}; +use super::types::{LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode}; +use rustfs_utils::http::headers::{ + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, +}; use std::collections::HashMap; use time::{OffsetDateTime, format_description}; @@ -31,65 +33,48 @@ pub fn utc_now_ntp() -> OffsetDateTime { OffsetDateTime::now_utc() } -pub fn get_object_retention_meta(meta: &HashMap) -> ObjectLockRetention { - // Note: X_AMZ_OBJECT_LOCK_MODE.as_str() is already lowercase ("x-amz-object-lock-mode") - let mode_str = meta.get(X_AMZ_OBJECT_LOCK_MODE.as_str()); +pub fn get_object_retention_meta(meta: &HashMap) -> ObjectRetention { + // The persisted metadata keys are the lowercase wire header names. + let mode_str = meta.get(AMZ_OBJECT_LOCK_MODE_LOWER); let Some(mode_str) = mode_str else { - return ObjectLockRetention { - mode: None, - retain_until_date: None, - }; + return ObjectRetention::default(); }; // If mode is invalid, return empty retention (don't panic) let Some(mode) = parse_ret_mode(mode_str.as_str()) else { - return ObjectLockRetention { - mode: None, - retain_until_date: None, - }; + return ObjectRetention::default(); }; - let till_str = meta.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str()); + let till_str = meta.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER); - let retain_until_date = till_str - .and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok()) - .map(Date::from); + let retain_until_date = + till_str.and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok()); - ObjectLockRetention { + ObjectRetention { mode: Some(mode), retain_until_date, } } -pub fn get_object_legalhold_meta(meta: &HashMap) -> ObjectLockLegalHold { - // Note: X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str() is already lowercase - let hold_str = meta.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()); +pub fn get_object_legalhold_meta(meta: &HashMap) -> ObjectLegalHold { + let hold_str = meta.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER); - match hold_str.and_then(|s| parse_legalhold_status(s)) { - Some(status) => ObjectLockLegalHold { status: Some(status) }, - None => ObjectLockLegalHold { status: None }, + ObjectLegalHold { + status: hold_str.and_then(|s| parse_legalhold_status(s)), } } -/// Parse retention mode string into ObjectLockRetentionMode. +/// Parse retention mode string into [`RetentionMode`]. /// Returns None for invalid/unknown mode strings instead of panicking. -pub fn parse_ret_mode(mode_str: &str) -> Option { - match mode_str.to_uppercase().as_str() { - "GOVERNANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)), - "COMPLIANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), - _ => None, - } +pub fn parse_ret_mode(mode_str: &str) -> Option { + RetentionMode::parse(mode_str) } -/// Parse legal hold status string into ObjectLockLegalHoldStatus. +/// Parse legal hold status string into [`LegalHoldStatus`]. /// Returns None for invalid/unknown status strings instead of panicking. -pub fn parse_legalhold_status(hold_str: &str) -> Option { - match hold_str.to_uppercase().as_str() { - "ON" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)), - "OFF" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)), - _ => None, - } +pub fn parse_legalhold_status(hold_str: &str) -> Option { + LegalHoldStatus::parse(hold_str) } #[cfg(test)] @@ -101,25 +86,25 @@ mod tests { // Test uppercase let mode = parse_ret_mode("GOVERNANCE"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE); let mode = parse_ret_mode("COMPLIANCE"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE); // Test lowercase let mode = parse_ret_mode("governance"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE); let mode = parse_ret_mode("compliance"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE); // Test mixed case let mode = parse_ret_mode("Governance"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE); } #[test] @@ -136,20 +121,20 @@ mod tests { // Test uppercase let status = parse_legalhold_status("ON"); assert!(status.is_some()); - assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON); + assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON); let status = parse_legalhold_status("OFF"); assert!(status.is_some()); - assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF); + assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF); // Test lowercase let status = parse_legalhold_status("on"); assert!(status.is_some()); - assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON); + assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON); let status = parse_legalhold_status("off"); assert!(status.is_some()); - assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF); + assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF); } #[test] @@ -175,7 +160,7 @@ mod tests { meta.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string()); let retention = get_object_retention_meta(&meta); assert!(retention.mode.is_some()); - assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE); + assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::GOVERNANCE); assert!(retention.retain_until_date.is_none()); } @@ -196,7 +181,7 @@ mod tests { meta.insert("x-amz-object-lock-retain-until-date".to_string(), "2030-01-01T00:00:00Z".to_string()); let retention = get_object_retention_meta(&meta); assert!(retention.mode.is_some()); - assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE); + assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::COMPLIANCE); assert!(retention.retain_until_date.is_some()); } @@ -210,17 +195,11 @@ mod tests { meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string()); let retention = get_object_retention_meta(&meta); - assert_eq!( - retention.mode.as_ref().map(|mode| mode.as_str()), - Some(ObjectLockRetentionMode::COMPLIANCE) - ); + assert_eq!(retention.mode.as_ref().map(|mode| mode.as_str()), Some(RetentionMode::COMPLIANCE)); assert!(retention.retain_until_date.is_some(), "persisted retention date must remain readable"); let legal_hold = get_object_legalhold_meta(&meta); - assert_eq!( - legal_hold.status.as_ref().map(|status| status.as_str()), - Some(ObjectLockLegalHoldStatus::ON) - ); + assert_eq!(legal_hold.status.as_ref().map(|status| status.as_str()), Some(LegalHoldStatus::ON)); } #[test] @@ -236,7 +215,7 @@ mod tests { meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string()); let legalhold = get_object_legalhold_meta(&meta); assert!(legalhold.status.is_some()); - assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON); + assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::ON); } #[test] @@ -245,7 +224,7 @@ mod tests { meta.insert("x-amz-object-lock-legal-hold".to_string(), "OFF".to_string()); let legalhold = get_object_legalhold_meta(&meta); assert!(legalhold.status.is_some()); - assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF); + assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::OFF); } #[test] diff --git a/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs b/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs index 4eb0f79fb..4931948a3 100644 --- a/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs +++ b/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs @@ -12,12 +12,16 @@ // 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::metadata_sys::{ + ObjectLockConfigState, default_retention_from_object_lock_config, get_object_lock_config, get_object_lock_config_state, +}; use crate::bucket::object_lock::objectlock; +use crate::bucket::object_lock::types::{DefaultRetention, LegalHoldStatus, RetentionMode}; use crate::error::{Error, Result, StorageError}; use crate::object_api::{ObjectInfo, ObjectOptions}; -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 rustfs_utils::http::headers::{ + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, +}; use std::sync::Arc; use time::OffsetDateTime; @@ -29,11 +33,12 @@ impl BucketObjectLockSys { Arc::new(Self {}) } + /// The bucket's active default retention, if the bucket has an + /// authoritative Object Lock configuration with a usable + /// GOVERNANCE/COMPLIANCE default retention rule. pub async fn get(bucket: &str) -> Option { - 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; + if let Ok(object_lock_config) = get_object_lock_config(bucket).await { + return default_retention_from_object_lock_config(&object_lock_config.0); } None } @@ -54,13 +59,10 @@ pub(crate) fn ensure_recursive_force_delete_allowed_for_state(bucket: &str, stat } /// 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; - } +pub fn is_retention_active(_mode: RetentionMode, retain_until_date: Option) -> bool { 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(); + return retain_until.unix_timestamp() > now.unix_timestamp(); } false } @@ -68,23 +70,20 @@ pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date /// Check if retention modification is blocked for the given object. pub fn check_retention_for_modification( user_defined: &std::collections::HashMap, - new_mode: Option<&str>, + new_mode: Option, new_retain_until: Option, bypass_governance: bool, ) -> Option { let retention = objectlock::get_object_retention_meta(user_defined); - let Some(mode) = &retention.mode else { - return None; - }; + let mode = retention.mode?; - let mode_str = mode.as_str(); - if !is_retention_active(mode_str, retention.retain_until_date.as_ref()) { + if !is_retention_active(mode, retention.retain_until_date) { 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); + let existing_retain_until = retention.retain_until_date; + let mode_changed = new_mode != Some(mode); // Check if new retention period is shorter than existing let is_shortening = match (&existing_retain_until, &new_retain_until) { @@ -93,35 +92,34 @@ pub fn check_retention_for_modification( _ => 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, - }); + match mode { + // COMPLIANCE mode: cannot shorten retention at all (even with bypass) + // Can only extend the retention period + RetentionMode::Compliance => { + if mode_changed || is_shortening { + return Some(ObjectLockBlockReason::Retention { + mode, + retain_until: existing_retain_until, + }); + } + // Extending retention in COMPLIANCE mode is allowed + None } - // 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, - }); + // 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 + RetentionMode::Governance => { + if (mode_changed || is_shortening) && !bypass_governance { + return Some(ObjectLockBlockReason::Retention { + mode, + retain_until: existing_retain_until, + }); + } + // Extending retention or shortening with bypass is allowed + None } - // Extending retention or shortening with bypass is allowed - return None; } - - None } pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime { @@ -137,8 +135,7 @@ pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime { /// Check if an object has legal hold enabled. /// Returns true if legal hold is ON. fn has_legal_hold(user_defined: &std::collections::HashMap) -> bool { - let lhold = objectlock::get_object_legalhold_meta(user_defined); - matches!(lhold.status, Some(ref st) if st.as_str() == ObjectLockLegalHoldStatus::ON) + objectlock::get_object_legalhold_meta(user_defined).is_on() } /// Whether an authorized replication write (`ObjectOptions::replication_request`) @@ -172,11 +169,11 @@ pub fn replication_write_may_pass_worm_gate( // Delete markers are never locked (same as the WORM gate). return Ok(true); } - let config = object_lock_config_from_state(state)?; + let default_retention = default_retention_from_state(state)?; if legal_hold_locks(obj_info)? && opts.replication_legalhold_timestamp.is_none() { return Ok(false); } - let retention_locked = active_retention(config, obj_info)?.is_some(); + let retention_locked = active_retention(default_retention.as_ref(), obj_info)?.is_some(); Ok(!(retention_locked && opts.replication_retention_timestamp.is_none())) } @@ -204,8 +201,8 @@ pub fn is_object_locked_by_metadata(user_defined: &std::collections::HashMap, }, } @@ -246,30 +243,28 @@ impl ObjectLockBlockReason { /// 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, + mode: RetentionMode, retain_until: Option, bypass_governance: bool, ) -> Option { // COMPLIANCE mode cannot be bypassed; GOVERNANCE can only be bypassed with permission - let can_bypass = mode_str == ObjectLockRetentionMode::GOVERNANCE && bypass_governance; + let can_bypass = mode == RetentionMode::Governance && bypass_governance; if !can_bypass { - return Some(ObjectLockBlockReason::Retention { - mode: mode_str.to_string(), - retain_until, - }); + return Some(ObjectLockBlockReason::Retention { mode, retain_until }); } None } -/// Check an object's lock metadata using an already resolved bucket Object -/// Lock configuration. `None` means the configuration is confirmed absent. +/// Check an object's lock metadata using an already resolved bucket default +/// retention. `None` means the bucket configuration is confirmed absent or +/// carries no usable default retention rule. /// /// # 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>, +pub(crate) fn check_object_lock_for_deletion_with_default_retention( + default_retention: Option<&DefaultRetention>, obj_info: &ObjectInfo, bypass_governance: bool, ) -> Result> { @@ -281,8 +276,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config( return Ok(Some(ObjectLockBlockReason::LegalHold)); } - if let Some((mode_str, retain_until)) = active_retention(config, obj_info)? - && let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance) + if let Some((mode, retain_until)) = active_retention(default_retention, obj_info)? + && let Some(reason) = check_retention_blocks_deletion(mode, Some(retain_until), bypass_governance) { return Ok(Some(reason)); } @@ -300,56 +295,41 @@ fn persisted_lock_value<'a>(obj_info: &'a ObjectInfo, key: &str) -> Option<&'a S /// Whether the version's persisted legal hold is ON. Any other non-empty /// value than ON/OFF is malformed metadata and fails closed. fn legal_hold_locks(obj_info: &ObjectInfo) -> Result { - let Some(status) = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()) else { + let Some(status) = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) else { return Ok(false); }; - if status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::ON) { - return Ok(true); + match LegalHoldStatus::parse(status) { + Some(LegalHoldStatus::On) => Ok(true), + Some(LegalHoldStatus::Off) => Ok(false), + None => Err(Error::other("persisted object legal-hold metadata is invalid")), } - if !status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::OFF) { - return Err(Error::other("persisted object legal-hold metadata is invalid")); - } - Ok(false) } /// The retention that currently locks the version, if any: the explicit /// persisted retention when the keys are present, otherwise the bucket /// default retention computed from the version's modification time. Returns /// `(mode, retain_until)` only while the retention is still active. -fn active_retention<'a>( - config: Option<&'a ObjectLockConfiguration>, +fn active_retention( + default_retention: Option<&DefaultRetention>, obj_info: &ObjectInfo, -) -> Result> { - let mode = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_MODE.as_str()); - let retain_until = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str()); +) -> Result> { + let mode = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_MODE_LOWER); + let retain_until = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER); match (mode, retain_until) { (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"))?; - let mode_str = match mode.as_str() { - ObjectLockRetentionMode::COMPLIANCE => ObjectLockRetentionMode::COMPLIANCE, - ObjectLockRetentionMode::GOVERNANCE => ObjectLockRetentionMode::GOVERNANCE, - _ => return Err(Error::other("persisted object retention mode is invalid")), - }; - return Ok(is_retention_active(mode_str, Some(&retain_until)).then(|| (mode_str, OffsetDateTime::from(retain_until)))); + return Ok(is_retention_active(mode, Some(retain_until)).then_some((mode, retain_until))); } _ => return Err(Error::other("persisted object retention metadata is incomplete")), } - let Some(default_retention) = config.and_then(|config| config.rule.as_ref()?.default_retention.as_ref()) else { + let Some(default_retention) = default_retention else { return Ok(None); }; - let Some(mode) = &default_retention.mode else { - return Ok(None); - }; - let mode_str = mode.as_str(); - if mode_str != ObjectLockRetentionMode::COMPLIANCE && mode_str != ObjectLockRetentionMode::GOVERNANCE { - return Ok(None); - } // Calculate retention expiration date from object modification time let mod_time = obj_info .mod_time @@ -363,12 +343,16 @@ fn active_retention<'a>( .ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?; add_years(mod_time, years) }; - Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((mode_str, retain_until))) + Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((default_retention.mode, retain_until))) } -fn object_lock_config_from_state(state: &ObjectLockConfigState) -> Result> { +/// The bucket default retention carried by an authoritative Object Lock +/// state. `ConfirmedAbsent` and a configuration without a usable default +/// retention rule are both `None`; a fabricated state is an error, never a +/// pass. +fn default_retention_from_state(state: &ObjectLockConfigState) -> Result> { match state { - ObjectLockConfigState::Configured { config, .. } => Ok(Some(config)), + ObjectLockConfigState::Configured { config, .. } => Ok(default_retention_from_object_lock_config(config)), ObjectLockConfigState::ConfirmedAbsent => Ok(None), ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")), } @@ -379,7 +363,11 @@ pub(crate) fn check_object_lock_for_deletion_with_state( obj_info: &ObjectInfo, bypass_governance: bool, ) -> Result> { - check_object_lock_for_deletion_with_config(object_lock_config_from_state(state)?, obj_info, bypass_governance) + check_object_lock_for_deletion_with_default_retention( + default_retention_from_state(state)?.as_ref(), + obj_info, + bypass_governance, + ) } /// Compatibility wrapper for callers that predate fallible metadata lookup. @@ -402,7 +390,10 @@ pub async fn check_object_lock_for_deletion( #[cfg(test)] mod tests { use super::*; - use s3s::dto::{ObjectLockEnabled, ObjectLockRule}; + use crate::bucket::metadata_sys::configured_object_lock_state_for_tests; + use rustfs_utils::http::headers::{ + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, + }; use time::{Date, Month, PrimitiveDateTime, Time}; fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime { @@ -411,51 +402,46 @@ mod tests { 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, - }), - }), + fn default_retention(mode: RetentionMode) -> DefaultRetention { + DefaultRetention { + 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 retention = default_retention(RetentionMode::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); + let result = check_object_lock_for_deletion_with_default_retention(Some(&retention), &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 retention = default_retention(RetentionMode::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), + check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true), Ok(None) )); } #[test] fn deletion_with_default_retention_rejects_missing_object_mod_time() { - let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE); + let retention = default_retention(RetentionMode::Compliance); - let err = check_object_lock_for_deletion_with_config(Some(&config), &ObjectInfo::default(), false) + let err = check_object_lock_for_deletion_with_default_retention(Some(&retention), &ObjectInfo::default(), false) .expect_err("default retention needs an authoritative object modification time"); assert!(err.to_string().contains("modification time")); @@ -465,7 +451,7 @@ mod tests { 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-mode".to_string(), RetentionMode::COMPLIANCE.to_string()); user_defined.insert( "x-amz-object-lock-retain-until-date".to_string(), retain_until @@ -477,7 +463,7 @@ mod tests { ..Default::default() }; - let result = check_object_lock_for_deletion_with_config(None, &obj_info, true); + let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, true); assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. })))); } @@ -501,16 +487,13 @@ mod tests { #[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(), - ); + user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), RetentionMode::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) + let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false) .expect_err("mode without retain-until date must fail closed"); assert!(err.to_string().contains("incomplete")); @@ -523,29 +506,24 @@ mod tests { .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", - ), + ("invalid date", Some(RetentionMode::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()); + user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.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()); + user_defined.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.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); + let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false).expect_err(case); assert!(err.to_string().contains(expected), "unexpected {case} error: {err}"); } } @@ -579,10 +557,6 @@ mod tests { /// source timestamp of every category that currently locks the version. #[test] fn replication_write_passes_worm_gate_only_with_every_locking_category_timestamp() { - use rustfs_utils::http::headers::{ - AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, - }; - let hold = [(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "ON")]; let retention = [ (AMZ_OBJECT_LOCK_MODE_LOWER, "GOVERNANCE"), @@ -623,19 +597,15 @@ mod tests { } /// The bucket default retention locks a version that carries no explicit - /// retention keys (`check_object_lock_for_deletion_with_config` judges it - /// from the modification time), so the replication bypass must demand the - /// retention source timestamp for it too — a tagging-only replication - /// write must not overwrite the default-protected version unjudged. + /// retention keys (`check_object_lock_for_deletion_with_default_retention` + /// judges it from the modification time), so the replication bypass must + /// demand the retention source timestamp for it too — a tagging-only + /// replication write must not overwrite the default-protected version + /// unjudged. #[test] fn replication_write_under_bucket_default_retention_requires_retention_timestamp() { - use rustfs_utils::http::headers::{AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER}; - - for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] { - let state = ObjectLockConfigState::Configured { - config: default_retention_config(mode), - updated_at: OffsetDateTime::now_utc(), - }; + for mode in [RetentionMode::Compliance, RetentionMode::Governance] { + let state = configured_object_lock_state_for_tests(mode, 30); let no_keys = lock_object_info(std::collections::HashMap::new()); assert!( check_object_lock_for_deletion_with_state(&state, &no_keys, false) @@ -689,8 +659,6 @@ mod tests { /// state or malformed persisted lock metadata; both are errors, not a pass. #[test] fn replication_write_worm_gate_fails_closed_on_unverifiable_lock_state() { - use rustfs_utils::http::headers::AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER; - let opts = replication_opts(true, true); let err = replication_write_may_pass_worm_gate( &ObjectLockConfigState::Fabricated, @@ -705,10 +673,7 @@ mod tests { .expect_err("malformed legal hold must not be judged"); assert!(err.to_string().contains("legal-hold")); - let state = ObjectLockConfigState::Configured { - config: default_retention_config(ObjectLockRetentionMode::COMPLIANCE), - updated_at: OffsetDateTime::now_utc(), - }; + let state = configured_object_lock_state_for_tests(RetentionMode::Compliance, 30); let no_mod_time = ObjectInfo::default(); let err = replication_write_may_pass_worm_gate(&state, &no_mod_time, &opts) .expect_err("default retention without a modification time must not be judged"); @@ -722,10 +687,6 @@ mod tests { /// (rustfs/backlog#1953). #[test] fn deletion_treats_cleared_empty_lock_metadata_as_unlocked() { - use rustfs_utils::http::headers::{ - AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, - }; - let cases: [(&str, &[&str]); 3] = [ ( "cleared retention", @@ -749,7 +710,7 @@ mod tests { ..Default::default() }; - let result = check_object_lock_for_deletion_with_config(None, &obj_info, false); + let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false); assert!(matches!(result, Ok(None)), "{case}: empty lock keys must read as unlocked: {result:?}"); } } @@ -757,13 +718,13 @@ mod tests { #[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()); + user_defined.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.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) + let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false) .expect_err("invalid legal-hold value must fail closed"); assert!(err.to_string().contains("legal-hold")); @@ -829,42 +790,29 @@ mod tests { 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)); + assert!(!is_retention_active(RetentionMode::Compliance, None)); + assert!(!is_retention_active(RetentionMode::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))); + assert!(is_retention_active(RetentionMode::Compliance, Some(future_date))); + assert!(is_retention_active(RetentionMode::Governance, Some(future_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))); + assert!(!is_retention_active(RetentionMode::Compliance, Some(past_date))); + assert!(!is_retention_active(RetentionMode::Governance, Some(past_date))); } #[test] @@ -890,10 +838,7 @@ mod tests { // 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() - ); + assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false).is_none()); } #[test] @@ -911,8 +856,7 @@ mod tests { // 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); + let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false); assert!(result.is_some()); assert!(matches!(result, Some(ObjectLockBlockReason::Retention { .. }))); } @@ -950,8 +894,7 @@ mod tests { // 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); + let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false); assert!(result.is_some()); } @@ -971,10 +914,7 @@ mod tests { // 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() - ); + assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false).is_none()); } #[test] @@ -992,10 +932,7 @@ mod tests { // 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() - ); + assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, true).is_none()); } #[test] @@ -1010,12 +947,8 @@ mod tests { .unwrap(), ); - let result = check_retention_for_modification( - &user_defined, - Some(ObjectLockRetentionMode::COMPLIANCE), - Some(existing_retain), - false, - ); + let result = + check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), false); assert!(result.is_some()); } @@ -1032,13 +965,8 @@ mod tests { ); assert!( - check_retention_for_modification( - &user_defined, - Some(ObjectLockRetentionMode::COMPLIANCE), - Some(existing_retain), - true, - ) - .is_none() + check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), true) + .is_none() ); } @@ -1054,12 +982,8 @@ mod tests { .unwrap(), ); - let result = check_retention_for_modification( - &user_defined, - Some(ObjectLockRetentionMode::GOVERNANCE), - Some(existing_retain), - true, - ); + let result = + check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), Some(existing_retain), true); assert!(result.is_some()); } diff --git a/crates/ecstore/src/bucket/object_lock/types.rs b/crates/ecstore/src/bucket/object_lock/types.rs new file mode 100644 index 000000000..46f2165c4 --- /dev/null +++ b/crates/ecstore/src/bucket/object_lock/types.rs @@ -0,0 +1,179 @@ +// 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. + +//! Storage-level Object Lock types (rustfs/backlog#1842). +//! +//! The engine evaluates WORM state from persisted object metadata and the +//! bucket default retention; none of that needs S3 wire/DTO types. The +//! serving layer converts to/from its wire DTOs at its own boundary, and the +//! bucket-metadata module converts the persisted `ObjectLockConfiguration` +//! into [`DefaultRetention`] when handing it to the evaluation code here. + +use std::fmt; +use time::OffsetDateTime; + +/// Object Lock retention mode. Persisted metadata and the bucket default +/// retention only ever carry these two values; anything else is either +/// malformed metadata (fail-closed at the parse site) or an inactive +/// configuration (ignored at the conversion site). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetentionMode { + Governance, + Compliance, +} + +impl RetentionMode { + pub const GOVERNANCE: &'static str = "GOVERNANCE"; + pub const COMPLIANCE: &'static str = "COMPLIANCE"; + + /// Parse the canonical S3 wire spelling, case-insensitively (matching the + /// historical `parse_ret_mode` behavior). Returns `None` for anything + /// that is not GOVERNANCE/COMPLIANCE. + pub fn parse(value: &str) -> Option { + if value.eq_ignore_ascii_case(Self::GOVERNANCE) { + Some(Self::Governance) + } else if value.eq_ignore_ascii_case(Self::COMPLIANCE) { + Some(Self::Compliance) + } else { + None + } + } + + /// Parse only the exact canonical wire spelling. Use this for a mode a + /// caller supplies in a *request*: the retention-modification gate has + /// always compared the requested mode literally against the canonical + /// persisted mode, so a non-canonical spelling must stay "not the same + /// mode" (and therefore blocked), not be normalized into a match. + pub fn parse_exact(value: &str) -> Option { + match value { + Self::GOVERNANCE => Some(Self::Governance), + Self::COMPLIANCE => Some(Self::Compliance), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Governance => Self::GOVERNANCE, + Self::Compliance => Self::COMPLIANCE, + } + } +} + +impl fmt::Display for RetentionMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Object Lock legal hold status (ON/OFF). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegalHoldStatus { + On, + Off, +} + +impl LegalHoldStatus { + pub const ON: &'static str = "ON"; + pub const OFF: &'static str = "OFF"; + + /// Parse the canonical S3 wire spelling, case-insensitively (matching the + /// historical `parse_legalhold_status` behavior). + pub fn parse(value: &str) -> Option { + if value.eq_ignore_ascii_case(Self::ON) { + Some(Self::On) + } else if value.eq_ignore_ascii_case(Self::OFF) { + Some(Self::Off) + } else { + None + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::On => Self::ON, + Self::Off => Self::OFF, + } + } +} + +impl fmt::Display for LegalHoldStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// An object version's retention as read from persisted metadata. `mode` is +/// `None` when the metadata carries no (or an unparsable) retention mode. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ObjectRetention { + pub mode: Option, + pub retain_until_date: Option, +} + +/// An object version's legal hold as read from persisted metadata. `status` +/// is `None` when the metadata carries no (or an unparsable) legal hold. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ObjectLegalHold { + pub status: Option, +} + +impl ObjectLegalHold { + pub fn is_on(&self) -> bool { + self.status == Some(LegalHoldStatus::On) + } +} + +/// The bucket's default Object Lock retention, converted from the persisted +/// configuration. Conversion only yields a value for an active default +/// retention (a valid GOVERNANCE/COMPLIANCE mode); a rule without a usable +/// mode converts to `None`, matching how the evaluation code has always +/// ignored such rules. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DefaultRetention { + pub mode: RetentionMode, + pub days: Option, + pub years: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The modification gate compares a *requested* mode against the canonical + /// persisted mode literally: a non-canonical spelling must not normalize + /// into a match, or a client could shorten GOVERNANCE retention without + /// bypass by spelling the mode differently. `parse_exact` is that pin. + #[test] + fn parse_exact_accepts_only_canonical_spellings() { + assert_eq!(RetentionMode::parse_exact("GOVERNANCE"), Some(RetentionMode::Governance)); + assert_eq!(RetentionMode::parse_exact("COMPLIANCE"), Some(RetentionMode::Compliance)); + for non_canonical in ["governance", "Governance", "compliance", "Compliance", "", "INVALID"] { + assert_eq!(RetentionMode::parse_exact(non_canonical), None, "{non_canonical:?} must not parse"); + } + } + + /// Persisted metadata parsing stays case-insensitive (the historical + /// `parse_ret_mode` / `parse_legalhold_status` behavior): on-disk values + /// written by older builds must keep locking. + #[test] + fn parse_is_case_insensitive_for_persisted_values() { + assert_eq!(RetentionMode::parse("governance"), Some(RetentionMode::Governance)); + assert_eq!(RetentionMode::parse("Compliance"), Some(RetentionMode::Compliance)); + assert_eq!(LegalHoldStatus::parse("on"), Some(LegalHoldStatus::On)); + assert_eq!(LegalHoldStatus::parse("Off"), Some(LegalHoldStatus::Off)); + assert_eq!(RetentionMode::parse("INVALID"), None); + assert_eq!(LegalHoldStatus::parse("MAYBE"), None); + } +} diff --git a/crates/ecstore/src/set_disk/mod.rs b/crates/ecstore/src/set_disk/mod.rs index a63348116..71fbf5aee 100644 --- a/crates/ecstore/src/set_disk/mod.rs +++ b/crates/ecstore/src/set_disk/mod.rs @@ -46,8 +46,8 @@ use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE; use crate::bucket::metadata_sys; use crate::bucket::metadata_sys::ObjectLockConfigState; use crate::bucket::object_lock::objectlock_sys::{ - check_object_lock_for_deletion_with_config, check_object_lock_for_deletion_with_state, check_retention_for_modification, - replication_write_may_pass_worm_gate, + check_object_lock_for_deletion_with_default_retention, check_object_lock_for_deletion_with_state, + check_retention_for_modification, replication_write_may_pass_worm_gate, }; use crate::bucket::replication::{ ReplicateDecision, ReplicationObjectBridge, ReplicationState, ReplicationStatusType, VersionPurgeStatusType, @@ -4688,7 +4688,10 @@ fn check_object_lock_retention_update(bucket: &str, object: &str, obj_info: &Obj if let Some(retention) = &opts.object_lock_retention && check_retention_for_modification( &obj_info.user_defined, - retention.mode.as_deref(), + retention + .mode + .as_deref() + .and_then(crate::bucket::object_lock::types::RetentionMode::parse_exact), retention.retain_until, retention.bypass_governance, ) diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 804352de6..79c379651 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -40,6 +40,7 @@ use super::storage_api::object_usecase::bucket::{ object_lock::{ objectlock::{get_object_legalhold_meta, get_object_retention_meta}, objectlock_sys::{check_object_lock_for_deletion, is_retention_active, replication_write_may_pass_worm_gate}, + types::RetentionMode, }, predict_lifecycle_expiration, quota::{QuotaCheckResult, QuotaError, QuotaOperation}, @@ -4045,11 +4046,7 @@ pub(crate) fn validate_existing_object_lock_for_write( } let legal_hold = get_object_legalhold_meta(&existing_obj_info.user_defined); - if legal_hold - .status - .as_ref() - .is_some_and(|status| status.as_str() == ObjectLockLegalHoldStatus::ON) - { + if legal_hold.is_on() { return Err(S3Error::with_message( S3ErrorCode::AccessDenied, "Object has a legal hold and cannot be overwritten. Remove the legal hold first.".to_string(), @@ -4057,9 +4054,9 @@ pub(crate) fn validate_existing_object_lock_for_write( } let retention = get_object_retention_meta(&existing_obj_info.user_defined); - if let Some(mode) = retention.mode.as_ref() - && mode.as_str() == ObjectLockRetentionMode::COMPLIANCE - && is_retention_active(mode.as_str(), retention.retain_until_date.as_ref()) + if let Some(mode) = retention.mode + && mode == RetentionMode::Compliance + && is_retention_active(mode, retention.retain_until_date) { return Err(S3Error::with_message( S3ErrorCode::AccessDenied, diff --git a/rustfs/src/app/storage_api.rs b/rustfs/src/app/storage_api.rs index 40b4c459f..85967ab55 100644 --- a/rustfs/src/app/storage_api.rs +++ b/rustfs/src/app/storage_api.rs @@ -559,16 +559,20 @@ pub(crate) mod bucket { } pub(crate) mod object_lock { + pub(crate) mod types { + pub(crate) use crate::storage::storage_api::ecstore_bucket::object_lock::types::RetentionMode; + } + pub(crate) mod objectlock { pub(crate) fn get_object_legalhold_meta( meta: &std::collections::HashMap, - ) -> s3s::dto::ObjectLockLegalHold { + ) -> crate::storage::storage_api::ecstore_bucket::object_lock::types::ObjectLegalHold { crate::storage::storage_api::ecstore_bucket::object_lock::objectlock::get_object_legalhold_meta(meta) } pub(crate) fn get_object_retention_meta( meta: &std::collections::HashMap, - ) -> s3s::dto::ObjectLockRetention { + ) -> crate::storage::storage_api::ecstore_bucket::object_lock::types::ObjectRetention { crate::storage::storage_api::ecstore_bucket::object_lock::objectlock::get_object_retention_meta(meta) } } @@ -587,7 +591,10 @@ pub(crate) mod bucket { .await } - pub(crate) fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool { + pub(crate) fn is_retention_active( + mode: crate::storage::storage_api::ecstore_bucket::object_lock::types::RetentionMode, + retain_until_date: Option, + ) -> bool { crate::storage::storage_api::ecstore_bucket::object_lock::objectlock_sys::is_retention_active( mode, retain_until_date, diff --git a/rustfs/src/storage/storage_api.rs b/rustfs/src/storage/storage_api.rs index b9a8a5bfe..3f623eaa6 100644 --- a/rustfs/src/storage/storage_api.rs +++ b/rustfs/src/storage/storage_api.rs @@ -1745,6 +1745,10 @@ pub(crate) fn check_retention_for_modification( new_retain_until: Option, bypass_governance: bool, ) -> Option { + // The gate compares the requested mode literally against the canonical + // persisted mode, so only the exact canonical spelling maps to a typed + // mode; anything else stays `None` and is judged as a mode change. + let new_mode = new_mode.and_then(ecstore_bucket::object_lock::types::RetentionMode::parse_exact); ecstore_bucket::object_lock::objectlock_sys::check_retention_for_modification( user_defined, new_mode, diff --git a/scripts/check_s3s_footprint.sh b/scripts/check_s3s_footprint.sh index 07e826176..5f1b27061 100755 --- a/scripts/check_s3s_footprint.sh +++ b/scripts/check_s3s_footprint.sh @@ -25,14 +25,14 @@ cd "$(dirname "$0")/.." # Baselines verified on 2026-08-11. Lower-only; see header. # Excludes crates/e2e_test/ — test infrastructure legitimately uses s3s # to verify S3 behavior and does not widen the production s3s surface. -S3S_IMPORT_FILES_BASELINE=211 +S3S_IMPORT_FILES_BASELINE=208 S3_ERROR_LINES_BASELINE=1620 # ecstore-scoped ratchet (rustfs/backlog#1842): the storage engine must not # know S3 wire/DTO types (ARCHITECTURE.md invariant 4). The S3-*consuming* # client was extracted to crates/s3-client, where s3s usage is legitimate; # this counter ratchets the remaining serving-side s3s references out of # crates/ecstore. Baseline verified on 2026-08-26. -S3S_ECSTORE_FILES_BASELINE=42 +S3S_ECSTORE_FILES_BASELINE=39 S3S_PATH_PATTERN='(^|[^"[:alnum:]_])s3s::' E2E_TEST_GLOB='--glob=!crates/e2e_test/**' From efcd960b65bf37968f24296bc1a342df7e63cd4b Mon Sep 17 00:00:00 2001 From: houseme Date: Wed, 26 Aug 2026 21:31:17 +0800 Subject: [PATCH 06/54] feat(startup): expose resync reconcile observability (#6667) Co-authored-by: heihutu --- rustfs/src/startup_bucket_metadata.rs | 133 ++++++++++++++++++++++++++ rustfs/tests/connect_registration.rs | 16 +++- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/rustfs/src/startup_bucket_metadata.rs b/rustfs/src/startup_bucket_metadata.rs index 33e90cb1b..dec3f50c1 100644 --- a/rustfs/src/startup_bucket_metadata.rs +++ b/rustfs/src/startup_bucket_metadata.rs @@ -20,12 +20,31 @@ use crate::storage_api::startup::bucket_metadata::{ use std::{ io::{Error as IoError, Result as IoResult}, sync::Arc, + time::{Duration, Instant}, }; use tokio_util::sync::CancellationToken; +const EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_CANCELED: &str = "replication_resync_startup_background_canceled"; +const EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_COMPLETED: &str = "replication_resync_startup_background_completed"; const EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_FAILED: &str = "replication_resync_startup_background_failed"; +const EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_STARTED: &str = "replication_resync_startup_background_started"; const LOG_COMPONENT_STARTUP_BUCKET_METADATA: &str = "startup_bucket_metadata"; const LOG_SUBSYSTEM_REPLICATION: &str = "replication"; +const METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_DURATION_SECONDS: &str = + "rustfs_replication_resync_startup_background_duration_seconds"; +const METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_EVENTS_TOTAL: &str = + "rustfs_replication_resync_startup_background_events_total"; +const METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_STATUS: &str = "rustfs_replication_resync_startup_background_status"; +const STARTUP_BACKGROUND_MODE_EMBEDDED: &str = "embedded"; +const STARTUP_BACKGROUND_MODE_SERVER: &str = "server"; +const STARTUP_BACKGROUND_OUTCOME_CANCELED: &str = "canceled"; +const STARTUP_BACKGROUND_OUTCOME_FAILED: &str = "failed"; +const STARTUP_BACKGROUND_OUTCOME_STARTED: &str = "started"; +const STARTUP_BACKGROUND_OUTCOME_SUCCEEDED: &str = "succeeded"; +const STARTUP_BACKGROUND_STATUS_FAILED: f64 = 0.0; +const STARTUP_BACKGROUND_STATUS_SUCCEEDED: f64 = 1.0; +const STARTUP_BACKGROUND_STATUS_RUNNING: f64 = 2.0; +const STARTUP_BACKGROUND_STATUS_CANCELED: f64 = 3.0; pub(crate) async fn init_embedded_bucket_metadata_runtime(store: Arc, ctx: &CancellationToken) -> IoResult> { let buckets_list = store @@ -68,23 +87,127 @@ pub(crate) async fn init_bucket_metadata_runtime(store: Arc, ctx: Cance fn spawn_bucket_resync_startup_reconcile(buckets: Vec, ctx: CancellationToken, init_resync_after_reconcile: bool) { tokio::spawn(async move { + describe_bucket_resync_startup_background_metrics(); + let bucket_count = buckets.len(); + let mode = bucket_resync_startup_background_mode(init_resync_after_reconcile); + let started = Instant::now(); + + record_bucket_resync_startup_background_started(mode); + tracing::info!( + event = EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_STARTED, + component = LOG_COMPONENT_STARTUP_BUCKET_METADATA, + subsystem = LOG_SUBSYSTEM_REPLICATION, + state = STARTUP_BACKGROUND_OUTCOME_STARTED, + mode, + bucket_count, + init_resync_after_reconcile, + "Bucket metadata startup resync reconcile started in background" + ); + if let Err(error) = run_bucket_resync_startup_reconcile(buckets, ctx, init_resync_after_reconcile).await { if !report_bucket_resync_startup_background_error(&error) { + record_bucket_resync_startup_background_finished(mode, STARTUP_BACKGROUND_OUTCOME_CANCELED, started.elapsed()); + tracing::debug!( + event = EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_CANCELED, + component = LOG_COMPONENT_STARTUP_BUCKET_METADATA, + subsystem = LOG_SUBSYSTEM_REPLICATION, + result = STARTUP_BACKGROUND_OUTCOME_CANCELED, + mode, + bucket_count, + init_resync_after_reconcile, + duration_ms = started.elapsed().as_millis() as u64, + "Bucket metadata startup resync reconcile canceled during shutdown" + ); return; } + record_bucket_resync_startup_background_finished(mode, STARTUP_BACKGROUND_OUTCOME_FAILED, started.elapsed()); tracing::error!( event = EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_FAILED, component = LOG_COMPONENT_STARTUP_BUCKET_METADATA, subsystem = LOG_SUBSYSTEM_REPLICATION, result = "failed", + mode, + bucket_count, init_resync_after_reconcile, + duration_ms = started.elapsed().as_millis() as u64, error = %error, "Bucket metadata startup resync reconcile failed in background" ); + return; } + + record_bucket_resync_startup_background_finished(mode, STARTUP_BACKGROUND_OUTCOME_SUCCEEDED, started.elapsed()); + tracing::info!( + event = EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_COMPLETED, + component = LOG_COMPONENT_STARTUP_BUCKET_METADATA, + subsystem = LOG_SUBSYSTEM_REPLICATION, + result = "ok", + mode, + bucket_count, + init_resync_after_reconcile, + duration_ms = started.elapsed().as_millis() as u64, + "Bucket metadata startup resync reconcile completed in background" + ); }); } +fn describe_bucket_resync_startup_background_metrics() { + static DESCRIBE: std::sync::Once = std::sync::Once::new(); + DESCRIBE.call_once(|| { + metrics::describe_counter!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_EVENTS_TOTAL, + "Bucket metadata startup resync background task events, by fixed mode and outcome" + ); + metrics::describe_histogram!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_DURATION_SECONDS, + "Bucket metadata startup resync background task duration in seconds, by fixed mode and outcome" + ); + metrics::describe_gauge!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_STATUS, + "Latest bucket metadata startup resync background task status by fixed mode: 0=failed, 1=succeeded, 2=running, 3=canceled" + ); + }); +} + +fn bucket_resync_startup_background_mode(init_resync_after_reconcile: bool) -> &'static str { + if init_resync_after_reconcile { + STARTUP_BACKGROUND_MODE_SERVER + } else { + STARTUP_BACKGROUND_MODE_EMBEDDED + } +} + +fn record_bucket_resync_startup_background_started(mode: &'static str) { + metrics::counter!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_EVENTS_TOTAL, + "mode" => mode, + "outcome" => STARTUP_BACKGROUND_OUTCOME_STARTED + ) + .increment(1); + metrics::gauge!(METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_STATUS, "mode" => mode).set(STARTUP_BACKGROUND_STATUS_RUNNING); +} + +fn record_bucket_resync_startup_background_finished(mode: &'static str, outcome: &'static str, duration: Duration) { + let status = match outcome { + STARTUP_BACKGROUND_OUTCOME_SUCCEEDED => STARTUP_BACKGROUND_STATUS_SUCCEEDED, + STARTUP_BACKGROUND_OUTCOME_CANCELED => STARTUP_BACKGROUND_STATUS_CANCELED, + _ => STARTUP_BACKGROUND_STATUS_FAILED, + }; + metrics::counter!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_EVENTS_TOTAL, + "mode" => mode, + "outcome" => outcome + ) + .increment(1); + metrics::histogram!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_DURATION_SECONDS, + "mode" => mode, + "outcome" => outcome + ) + .record(duration.as_secs_f64()); + metrics::gauge!(METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_STATUS, "mode" => mode).set(status); +} + async fn run_bucket_resync_startup_reconcile( buckets: Vec, ctx: CancellationToken, @@ -117,4 +240,14 @@ mod tests { "replication pool is not initialized" ))); } + + #[test] + fn startup_resync_background_observability_uses_fixed_modes_and_statuses() { + assert_eq!(bucket_resync_startup_background_mode(true), STARTUP_BACKGROUND_MODE_SERVER); + assert_eq!(bucket_resync_startup_background_mode(false), STARTUP_BACKGROUND_MODE_EMBEDDED); + assert_eq!(STARTUP_BACKGROUND_STATUS_FAILED, 0.0); + assert_eq!(STARTUP_BACKGROUND_STATUS_SUCCEEDED, 1.0); + assert_eq!(STARTUP_BACKGROUND_STATUS_RUNNING, 2.0); + assert_eq!(STARTUP_BACKGROUND_STATUS_CANCELED, 3.0); + } } diff --git a/rustfs/tests/connect_registration.rs b/rustfs/tests/connect_registration.rs index c6d5e8c61..2a52a9551 100644 --- a/rustfs/tests/connect_registration.rs +++ b/rustfs/tests/connect_registration.rs @@ -46,7 +46,7 @@ use sha2::{Digest as _, Sha256}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tokio::net::TcpListener; -use tokio::sync::watch; +use tokio::sync::{Notify, watch}; use tokio_rustls::TlsAcceptor; use tokio_util::sync::CancellationToken; @@ -204,6 +204,7 @@ struct TestServer { seen: Arc>>, paths: Arc>>, client_certificates: Arc>>>, + request_notify: Arc, task: tokio::task::JoinHandle<()>, } @@ -225,9 +226,11 @@ async fn server_with_client_auth(pki: &TestPki, replies: Vec, require_cli let seen = Arc::new(Mutex::new(Vec::new())); let paths = Arc::new(Mutex::new(Vec::new())); let client_certificates = Arc::new(Mutex::new(Vec::new())); + let request_notify = Arc::new(Notify::new()); let captured = seen.clone(); let captured_paths = paths.clone(); let captured_certificates = client_certificates.clone(); + let captured_notify = request_notify.clone(); let task = tokio::spawn(async move { loop { let Ok((stream, _)) = listener.accept().await else { @@ -238,6 +241,7 @@ async fn server_with_client_auth(pki: &TestPki, replies: Vec, require_cli let seen = captured.clone(); let paths = captured_paths.clone(); let client_certificates = captured_certificates.clone(); + let request_notify = captured_notify.clone(); tokio::spawn(async move { let Ok(stream) = acceptor.accept(stream).await else { return; @@ -253,6 +257,7 @@ async fn server_with_client_auth(pki: &TestPki, replies: Vec, require_cli let seen = seen.clone(); let paths = paths.clone(); let client_certificates = client_certificates.clone(); + let request_notify = request_notify.clone(); let client_certificate = client_certificate.clone(); async move { let path = request.uri().path().to_owned(); @@ -265,6 +270,7 @@ async fn server_with_client_auth(pki: &TestPki, replies: Vec, require_cli .push(client_certificate); paths.lock().expect("paths lock").push(path); seen.lock().expect("seen lock").push(value.clone()); + request_notify.notify_waiters(); match reply { Reply::Json(status, value) => Ok::<_, hyper::Error>( Response::builder() @@ -344,6 +350,7 @@ async fn server_with_client_auth(pki: &TestPki, replies: Vec, require_cli seen, paths, client_certificates, + request_notify, task, } } @@ -545,9 +552,12 @@ fn heartbeat_response(server_time: &str) -> Value { } async fn wait_for_requests(server: &TestServer, count: usize) { - tokio::time::timeout(Duration::from_secs(3), async { + tokio::time::timeout(Duration::from_secs(10), async { while server.paths.lock().expect("paths lock").len() < count { - tokio::task::yield_now().await; + tokio::select! { + _ = server.request_notify.notified() => {} + _ = tokio::time::sleep(Duration::from_millis(5)) => {} + } } }) .await From 0e56ef4f1c15d81599511d477deebb529708e92f Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 22:02:11 +0800 Subject: [PATCH 07/54] refactor(rustfs): split object_usecase.rs into per-operation app/object modules (#6670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(rustfs): carve app/object out of object_usecase.rs — shared, extract, test_support children (backlog#1841 step 1) Mechanical move-only split of rustfs/src/app/object_usecase.rs (19.7K lines). The file body moves to rustfs/src/app/object/mod.rs, and the first self-contained slices move into children: shared.rs (cross-cutting helpers: quota admission, response checksum injection, object-lock write validation, table-catalog mutation guard, deadlock request guard, proxy passthrough utilities), extract.rs (snowball auto-extract path incl. tar/pax helpers and execute_put_object_extract), and cfg(test) test_support.rs for cross-module test scaffolding. object_usecase.rs stays as a thin pub use facade so every existing crate::app::object_usecase:: path keeps working. No behavior change: items move verbatim; the only source edits are visibility widenings required by the new module boundaries (private -> pub(super); pub(super) -> pub(crate) for the three helpers multipart_usecase and the app gating tests import). Guard scripts that pinned rustfs/src/app/object_usecase.rs now scan the rustfs/src/app/object tree, and the table_catalog source-text guard test concatenates the split files. * refactor(rustfs): move the GetObject read path into app/object/get.rs (backlog#1841 step 2) Move-only continuation of the object_usecase split: cold-fill orchestration, disk-permit admission, streaming readers and resume control, stream-buffer tuning, execute_get_object / execute_get_object_attributes, the GET replication proxy helpers, and their unit tests move from app/object/mod.rs into app/object/get.rs. Items keep their original text; cross-module call sites rely on the visibility widenings introduced in step 1. * refactor(rustfs): move the PutObject and CopyObject paths into app/object (backlog#1841 step 3) Move-only continuation: put.rs takes the PUT body admission and timeout readers, zero-copy and eager-commit machinery, execute_put_object, and the PUT unit tests; copy.rs takes the copy namespace/lifecycle lock helpers and execute_copy_object with its tests. Two source edits beyond visibility widenings: PutObjectChecksums fields become pub(super) (read by shared::apply_trailing_checksums across the new module boundary) and one relative super::storage_api call in the copy path becomes crate::app::storage_api since super now resolves to app::object. The table_catalog source-text guard concatenates the new files. * refactor(rustfs): finish the object_usecase split — delete, head, restore modules (backlog#1841 step 4) Move-only completion: delete.rs takes the delete helpers, cfg(test) delete hooks, and execute_delete_object/execute_delete_objects; head.rs takes execute_head_object with the HEAD replication proxy helpers; restore.rs takes execute_restore_object. app/object/mod.rs is now just the shared import prelude, module wiring, and the DefaultObjectUsecase struct with its constructors, accessors, and the execute_select_object_content delegation; the emptied tests module is gone. The delete re-export glob is cfg(test)-gated because its only cross-module consumers are the delete test hooks. The table_catalog source-text guard now isolates the delete entrypoints from app/object/delete.rs, and doc/comment references that pointed at rustfs/src/app/object_usecase.rs internals now point at the per-operation modules. --- ARCHITECTURE.md | 2 +- .../src/degraded_read_eof_regression_test.rs | 4 +- docs/architecture/global-state-inventory.md | 2 +- .../minio-rustfs-router-compatibility.md | 2 +- .../architecture/unified-object-generation.md | 2 +- rustfs/src/app/mod.rs | 1 + rustfs/src/app/object/copy.rs | 1137 + rustfs/src/app/object/delete.rs | 2000 ++ rustfs/src/app/object/extract.rs | 1506 ++ rustfs/src/app/object/get.rs | 9265 ++++++++ rustfs/src/app/object/head.rs | 481 + rustfs/src/app/object/mod.rs | 346 + rustfs/src/app/object/put.rs | 3199 +++ rustfs/src/app/object/restore.rs | 355 + rustfs/src/app/object/shared.rs | 1653 ++ rustfs/src/app/object/test_support.rs | 98 + rustfs/src/app/object_usecase.rs | 19696 +--------------- rustfs/src/table_catalog/tests.rs | 20 +- scripts/check_architecture_migration_rules.sh | 12 +- scripts/check_logging_guardrails.sh | 4 +- 20 files changed, 20075 insertions(+), 19710 deletions(-) create mode 100644 rustfs/src/app/object/copy.rs create mode 100644 rustfs/src/app/object/delete.rs create mode 100644 rustfs/src/app/object/extract.rs create mode 100644 rustfs/src/app/object/get.rs create mode 100644 rustfs/src/app/object/head.rs create mode 100644 rustfs/src/app/object/mod.rs create mode 100644 rustfs/src/app/object/put.rs create mode 100644 rustfs/src/app/object/restore.rs create mode 100644 rustfs/src/app/object/shared.rs create mode 100644 rustfs/src/app/object/test_support.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 31fbf0066..db91990d4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -73,7 +73,7 @@ The main crate is organized in layers, top to bottom: |-------|-----------|----------------| | **Server** | `server/` | HTTP listener, TLS, CORS, compression, middleware, graceful shutdown | | **Admin** | `admin/` | Admin API routing, 30+ handler modules, web console | -| **App** | `app/` | Use-case orchestration: object_usecase, bucket_usecase, multipart_usecase | +| **App** | `app/` | Use-case orchestration: object (per-operation modules under `app/object/`, re-exported as `object_usecase`), bucket_usecase, multipart_usecase | | **Storage** | `storage/` | S3 API translation, erasure-coded FS, SSE encryption, RPC, concurrency | | **Auth** | `auth.rs` | S3 signature verification, credential validation | | **Config** | `config/` | CLI parsing, config struct, workload profiles | diff --git a/crates/e2e_test/src/degraded_read_eof_regression_test.rs b/crates/e2e_test/src/degraded_read_eof_regression_test.rs index 9b3c66649..5592d831e 100644 --- a/crates/e2e_test/src/degraded_read_eof_regression_test.rs +++ b/crates/e2e_test/src/degraded_read_eof_regression_test.rs @@ -23,8 +23,8 @@ //! It was fixed in three layers on `main`, each with its own *unit* regression: //! * rustfs#4594 — `GetObjectStreamingReader::poll_read` now returns //! `UnexpectedEof` on a short body instead of a clean `Ok(())` -//! (`rustfs/src/app/object_usecase.rs`, -//! `app::object_usecase::tests::get_object_streaming_reader_errors_on_short_eof`). +//! (`rustfs/src/app/object/get.rs`, +//! `app::object::get::tests::get_object_streaming_reader_errors_on_short_eof`). //! * rustfs#4560 — the lazy multipart codec reader degrades a later part to //! the legacy per-part decode in place, and surfaces reconstruction errors //! instead of silently truncating diff --git a/docs/architecture/global-state-inventory.md b/docs/architecture/global-state-inventory.md index b110374dd..cbb2db326 100644 --- a/docs/architecture/global-state-inventory.md +++ b/docs/architecture/global-state-inventory.md @@ -107,7 +107,7 @@ inventory. Generic function-local names such as `CACHE`, `LOCK`, `INIT`, and | `AUTH_FS` | `rustfs/src/storage/access.rs` | Cache or constant / owner-local cache | Authorization tag-condition lookup keeps its filesystem helper private to the access owner. | | `DEADLOCK_DETECTOR` | `rustfs/src/storage/deadlock_detector.rs` | Process-global owner-local state | Deadlock detector lifecycle state stays private to the storage deadlock detector owner. | | `CONCURRENCY_MANAGER`, `ACTIVE_GET_REQUESTS`, `ACTIVE_PUT_REQUESTS` | `rustfs/src/storage/concurrency/*` | Process-global owner-local scheduler state | Storage concurrency manager and request counters remain inside the storage concurrency owner boundary. | -| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object_usecase.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. | +| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object/get.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. | | `SUPPORTED_HEADERS` | `rustfs/src/storage/options.rs` | Cache or constant / owner-local constant | Supported-header lookup state stays private to storage option parsing. | | `AUDIT_TARGET_SPECS`, `NOTIFICATION_TARGET_SPECS` | `rustfs/src/admin/handlers/audit.rs`, `rustfs/src/admin/handlers/event.rs`, `rustfs/src/admin/handlers/plugins_instances.rs` | Cache or constant / owner-local constant | Admin target descriptor tables stay private to their handler owners. | | `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/admin/handlers/site_replication.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to site-replication handlers. The state RMW transaction holds no process-local mutex — see `rustfs/src/admin/site_replication_state.rs`. | diff --git a/docs/architecture/minio-rustfs-router-compatibility.md b/docs/architecture/minio-rustfs-router-compatibility.md index 00c9ff68c..7055b6710 100644 --- a/docs/architecture/minio-rustfs-router-compatibility.md +++ b/docs/architecture/minio-rustfs-router-compatibility.md @@ -105,7 +105,7 @@ these as 部分兼容 at the client level. | CompleteMultipartUpload | 已实现 | `rustfs/src/storage/ecfs.rs` (`complete_multipart_upload`) | | AbortMultipartUpload | 已实现 | `rustfs/src/storage/ecfs.rs` (`abort_multipart_upload`) | | ListParts | 已实现 | `rustfs/src/storage/ecfs.rs` (`list_parts`) | -| PostObject (POST form upload) | 已实现 | Routed via the POST-object marker into the put-object path (`rustfs/src/app/object_usecase.rs`). See the "POST Object form upload checksum handling: planned" note in `s3-compatibility-matrix.md`. | +| PostObject (POST form upload) | 已实现 | Routed via the POST-object marker into the put-object path (`rustfs/src/app/object/put.rs`). See the "POST Object form upload checksum handling: planned" note in `s3-compatibility-matrix.md`. | | GetObjectTorrent | 行为不一致 | `rustfs/src/storage/ecfs.rs` (`get_object_torrent`) — returns `404 NoSuchKey` by design (not `501 NotImplemented`) so clients degrade gracefully. | For the gate-level view of which of these are covered by executable s3tests, diff --git a/docs/architecture/unified-object-generation.md b/docs/architecture/unified-object-generation.md index fffed4e74..40656c429 100644 --- a/docs/architecture/unified-object-generation.md +++ b/docs/architecture/unified-object-generation.md @@ -199,7 +199,7 @@ upgrade. When the cluster-level generation capability is **not** negotiated on every target disk, the behavior **falls back to current semantics** (existing lock + `is_lock_lost()` check for #1312; degraded-allow read-check for #1318 at -`rustfs/src/app/object_usecase.rs`; full fanout for #1314). Fail-closed is +`rustfs/src/app/object/get.rs`; full fanout for #1314). Fail-closed is **only** an explicit administrator strict mode. Defaulting to fail-closed is forbidden — it makes writes unavailable for the whole rolling-upgrade window. diff --git a/rustfs/src/app/mod.rs b/rustfs/src/app/mod.rs index 107ae3e0c..1b8546f3a 100644 --- a/rustfs/src/app/mod.rs +++ b/rustfs/src/app/mod.rs @@ -20,6 +20,7 @@ pub mod bucket_usecase; pub mod context; pub(crate) mod metadata_route; pub mod multipart_usecase; +pub mod object; pub(crate) mod object_data_cache; pub(crate) mod object_traffic_health; pub mod object_usecase; diff --git a/rustfs/src/app/object/copy.rs b/rustfs/src/app/object/copy.rs new file mode 100644 index 000000000..a7cc4e847 --- /dev/null +++ b/rustfs/src/app/object/copy.rs @@ -0,0 +1,1137 @@ +// 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. + +//! CopyObject path. + +use super::*; + +fn copy_namespace_lock_error(bucket: &str, object: &str, mode: &'static str, err: rustfs_lock::LockError) -> StorageError { + match err { + rustfs_lock::LockError::QuorumNotReached { required, achieved } => StorageError::NamespaceLockQuorumUnavailable { + mode, + bucket: bucket.to_owned(), + object: object.to_owned(), + required, + achieved, + }, + other => StorageError::Lock(other), + } +} + +async fn acquire_self_copy_namespace_lock(store: &S, bucket: &str, object: &str) -> S3Result +where + S: NamespaceLocking + ?Sized, +{ + let object = encode_dir_object(object); + let lock = store.new_ns_lock(bucket, &object).await.map_err(ApiError::from)?; + lock.get_write_lock(get_lock_acquire_timeout()) + .await + .map_err(|err| ApiError::from(copy_namespace_lock_error(bucket, &object, "write", err)).into()) +} + +pub(crate) async fn acquire_copy_bucket_lifecycle_lock(store: &S, bucket: &str) -> S3Result +where + S: NamespaceLocking + ?Sized, +{ + let lock = store + .new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT) + .await + .map_err(ApiError::from)?; + lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(|err| { + ApiError::from(copy_namespace_lock_error( + bucket, + BUCKET_LIFECYCLE_LOCK_OBJECT, + "bucket_lifecycle_read", + err, + )) + .into() + }) +} + +pub(crate) async fn acquire_copy_bucket_lifecycle_locks( + store: &S, + source_bucket: &str, + destination_bucket: &str, +) -> S3Result<(NamespaceLockGuard, Option)> +where + S: NamespaceLocking + ?Sized, +{ + if source_bucket == destination_bucket { + return Ok((acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?, None)); + } + + if source_bucket < destination_bucket { + let source_guard = acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?; + let destination_guard = acquire_copy_bucket_lifecycle_lock(store, destination_bucket).await?; + Ok((source_guard, Some(destination_guard))) + } else { + let destination_guard = acquire_copy_bucket_lifecycle_lock(store, destination_bucket).await?; + let source_guard = acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?; + Ok((source_guard, Some(destination_guard))) + } +} + +impl DefaultObjectUsecase { + pub fn execute_copy_object( + &self, + req: S3Request, + ) -> impl std::future::Future>> + Send + '_ { + Box::pin(self.execute_copy_object_inner(req)) + } + + #[instrument(name = "execute_copy_object", level = "debug", skip(self, req))] + async fn execute_copy_object_inner(&self, req: S3Request) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = OperationHelper::new(&req, EventName::ObjectCreatedCopy, S3Operation::CopyObject); + let CopyObjectInput { + copy_source, + bucket, + key, + version_id: dest_version_id, + server_side_encryption: requested_sse, + ssekms_key_id: requested_kms_key_id, + sse_customer_algorithm, + sse_customer_key, + sse_customer_key_md5, + copy_source_sse_customer_algorithm, + copy_source_sse_customer_key, + copy_source_sse_customer_key_md5, + metadata_directive, + metadata, + tagging, + tagging_directive, + copy_source_if_match, + copy_source_if_none_match, + cache_control, + content_disposition, + content_encoding, + content_language, + content_type, + expires, + website_redirect_location, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + storage_class, + checksum_algorithm, + .. + } = req.input.clone(); + let requested_checksum_type = checksum_algorithm + .as_ref() + .map(|algorithm| rustfs_rio::ChecksumType::from_string(algorithm.as_str())); + if requested_checksum_type.is_some_and(|checksum_type| !checksum_type.is_set()) { + return Err(s3_error!(InvalidArgument, "Unsupported checksum algorithm")); + } + let (src_bucket, src_key, version_id) = match copy_source { + CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)), + CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)), + CopySource::Bucket { + ref bucket, + ref key, + version_id, + } => (bucket.to_string(), key.to_string(), version_id.map(|v| v.to_string())), + }; + + // Normalize the copy-source version id like GET/HEAD do: trim, treat "null" as the + // nil UUID, and reject malformed ids up front (issue #4238). + let version_id = match version_id { + Some(v) => { + let trimmed = v.trim(); + if trimmed.eq_ignore_ascii_case("null") { + Some(Uuid::nil().to_string()) + } else if Uuid::parse_str(trimmed).is_ok() { + Some(trimmed.to_string()) + } else { + return Err(s3_error!(InvalidArgument, "Invalid version id specified in copy source")); + } + } + None => None, + }; + + if let Some(ref sc) = storage_class + && !is_valid_storage_class(sc.as_str()) + { + return Err(s3_error!(InvalidStorageClass)); + } + let ssekms_context = extract_ssekms_context_from_headers(&req.headers)?; + validate_sse_headers_for_write( + requested_sse.as_ref(), + requested_kms_key_id.as_ref(), + ssekms_context.as_ref(), + sse_customer_algorithm.as_ref(), + sse_customer_key.as_ref(), + sse_customer_key_md5.as_ref(), + true, + )?; + let has_explicit_ssec = sse_customer_algorithm.is_some() || sse_customer_key.is_some() || sse_customer_key_md5.is_some(); + + // Validate both source and destination keys + validate_object_key(&src_key, "COPY (source)")?; + validate_object_key(&key, "COPY (dest)")?; + validate_table_catalog_object_mutation(&bucket, &key).await?; + let replaces_metadata = match metadata_directive.as_ref().map(|directive| directive.as_str()) { + None | Some(MetadataDirective::COPY) => false, + Some(MetadataDirective::REPLACE) => true, + Some(_) => { + return Err(S3Error::with_message( + S3ErrorCode::InvalidArgument, + "The MetadataDirective header is invalid".to_string(), + )); + } + }; + let replacement_metadata = if replaces_metadata { + validate_archive_content_encoding(&key, content_type.as_deref(), content_encoding.as_deref())?; + let mut replacement_metadata = metadata.unwrap_or_default(); + namespace_reserved_user_metadata(&mut replacement_metadata); + apply_standard_object_metadata( + &mut replacement_metadata, + cache_control.as_deref(), + content_disposition.as_deref(), + content_encoding.as_deref(), + content_language.as_deref(), + content_type.as_deref(), + expires.as_ref(), + website_redirect_location.as_deref(), + )?; + Some(replacement_metadata) + } else { + None + }; + + // AWS S3 allows self-copy when metadata directive is REPLACE (used to update metadata in-place), + // when an explicit storage class change is requested, or when restoring a specific historical + // version onto the current key (source carries a versionId). Reject only a true no-op self-copy + // where none of these apply (issue #4238). + let replacement_tags = crate::app::storage_api::object_usecase::s3_api::tagging::resolve_copy_object_tags( + tagging.as_deref(), + tagging_directive.as_ref(), + )?; + + if !replaces_metadata + && tagging_directive.as_ref().map(TaggingDirective::as_str) != Some(TaggingDirective::REPLACE) + && storage_class.is_none() + && version_id.is_none() + && src_bucket == bucket + && src_key == key + { + error!(bucket, key, "Rejected self-copy operation"); + return Err(s3_error!( + InvalidRequest, + "Cannot copy an object to itself. Source and destination must be different." + )); + } + + // warn!("copy_object {}/{}, to {}/{}", &src_bucket, &src_key, &bucket, &key); + + let mut src_opts = copy_src_opts(&src_bucket, &src_key, &req.headers).map_err(ApiError::from)?; + + src_opts.version_id = version_id.clone(); + + let mut src_get_opts = ObjectOptions { + version_id: src_opts.version_id.clone(), + versioned: src_opts.versioned, + version_suspended: src_opts.version_suspended, + ..Default::default() + }; + apply_copy_source_bucket_generation_guard(&req, &src_bucket, &mut src_get_opts)?; + + let mut dst_opts = copy_dst_opts_with_replication_authorization( + &bucket, + &key, + dest_version_id.clone(), + &req.headers, + HashMap::new(), + replication_request_authorized(&req), + ) + .await + .map_err(ApiError::from)?; + apply_bucket_generation_guard(&req, &bucket, &mut dst_opts)?; + + let cp_src_dst_same = path_join_buf(&[&src_bucket, &src_key]) == path_join_buf(&[&bucket, &key]); + let expected_current_version_id = expected_current_version_id(&req.headers)?; + if expected_current_version_id.is_some() + && (!cp_src_dst_same || version_id.is_none() || dest_version_id.is_some() || !dst_opts.versioned) + { + return Err(s3_error!( + InvalidRequest, + "Expected current version precondition requires a versioned same-object historical copy that creates a new version" + )); + } + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + let (source_bucket_lifecycle_guard, destination_bucket_lifecycle_guard_storage) = + acquire_copy_bucket_lifecycle_locks(store.as_ref(), &src_bucket, &bucket).await?; + let current_source_incarnation_id = store + .bucket_incarnation_id_from_disk(&src_bucket) + .await + .map_err(ApiError::from)?; + if src_get_opts + .expected_bucket_incarnation_id + .is_some_and(|expected| expected != current_source_incarnation_id) + { + return Err(ApiError::from(StorageError::BucketNotFound(src_bucket.clone())).into()); + } + let current_destination_incarnation_id = if src_bucket == bucket { + current_source_incarnation_id + } else { + store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)? + }; + if dst_opts + .expected_bucket_incarnation_id + .is_some_and(|expected| expected != current_destination_incarnation_id) + { + return Err(ApiError::from(StorageError::BucketNotFound(bucket.clone())).into()); + } + let destination_bucket_lifecycle_guard = destination_bucket_lifecycle_guard_storage + .as_ref() + .unwrap_or(&source_bucket_lifecycle_guard); + if source_bucket_lifecycle_guard.is_lock_lost() || destination_bucket_lifecycle_guard.is_lock_lost() { + return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable { + mode: "copy_bucket_generation", + bucket: bucket.clone(), + object: key.clone(), + required: 1, + achieved: 0, + }) + .into()); + } + src_get_opts.expected_bucket_incarnation_id = Some(current_source_incarnation_id); + dst_opts.expected_bucket_incarnation_id = Some(current_destination_incarnation_id); + if src_bucket != bucket { + dst_opts.add_bucket_lifecycle_lock_guard(&source_bucket_lifecycle_guard); + } + dst_opts.add_bucket_lifecycle_lock_guard(destination_bucket_lifecycle_guard); + + // Bucket metadata uses the bucket name as its namespace-lock key. Load + // every copy-time bucket snapshot before a same-object key can collide + // with that key (for example, copying `bucket/bucket` onto itself). + let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); + let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?; + if cp_src_dst_same && key == bucket && expected_current_version_id.is_none() { + dst_opts.object_lock_config_snapshot = + Some(store.object_lock_config_snapshot(&bucket).await.map_err(ApiError::from)?); + } + let mut current_opts: ObjectOptions = internal_object_info_lookup_opts( + get_opts(&bucket, &key, dest_version_id.clone(), None, &req.headers) + .await + .map_err(ApiError::from)?, + ); + + let _self_copy_lock_guard = if cp_src_dst_same && expected_current_version_id.is_none() { + let guard = acquire_self_copy_namespace_lock(store.as_ref(), &bucket, &key).await?; + src_opts.no_lock = true; + src_get_opts.no_lock = true; + dst_opts.no_lock = true; + Some(guard) + } else { + None + }; + if let Some(guard) = _self_copy_lock_guard.as_ref() { + dst_opts.add_namespace_lock_guard(guard); + } + dst_opts.expected_current_version_id = expected_current_version_id.clone(); + + if _self_copy_lock_guard.is_some() { + current_opts.no_lock = true; + } + let previous_current_sizes = match store.get_object_info(&bucket, &key, ¤t_opts).await { + Ok(existing_obj_info) => { + validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &dst_opts)?; + if let Some(expected) = expected_current_version_id.as_deref() + && existing_obj_info.version_id.unwrap_or_default().to_string() != expected + { + return Err(s3_error!(PreconditionFailed)); + } + Some((existing_obj_info.size.max(0) as u64, quota_object_size(&existing_obj_info))) + } + Err(err) => { + if expected_current_version_id.is_some() { + return Err(s3_error!(PreconditionFailed)); + } + if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { + return Err(ApiError::from(err).into()); + } + None + } + }; + + let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( + bucket_sse_config.as_ref().map(|(config, _)| config), + requested_sse, + requested_kms_key_id, + has_explicit_ssec, + ); + + let h = build_ssec_read_headers( + copy_source_sse_customer_algorithm.as_ref(), + copy_source_sse_customer_key.as_ref(), + copy_source_sse_customer_key_md5.as_ref(), + ); + + let copy_principal = SseKmsPrincipal::from_request(&req); + + if source_bucket_lifecycle_guard.is_lock_lost() { + return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable { + mode: "copy_source_bucket_generation", + bucket: src_bucket.clone(), + object: src_key.clone(), + required: 1, + achieved: 0, + }) + .into()); + } + + let (gr, source_cancellation) = store + .get_object_reader_for_copy(&src_bucket, &src_key, None, h, &src_get_opts) + .await + .map_err(map_get_object_reader_error)?; + + // The commit owner is intentionally detached so SetDisk can finish + // its rename/cleanup and post-commit publication if the HTTP caller + // goes away. Keep a request-owned guard for the source producer: + // cancellation drops the source read promptly, while the detached + // commit task retains the guards it needs to complete safely. + let _source_cancellation_guard = source_cancellation.clone().drop_guard(); + + let mut src_info = gr.object_info.clone(); + + // A copy reads the source plaintext, so it needs the source key's decrypt permission + // as well as the destination key's generate permission below. The source read resolves + // its material inside the object layer, which has no request identity, so the check + // happens here. + authorize_sse_kms_object_read(copy_principal.as_ref(), &src_info.user_defined).await?; + + // Capture the version actually read from the source before src_info is mutated/consumed + // below. This is the exact source version copied (issue #4976): the response must echo it + // via x-amz-copy-source-version-id, distinct from the destination version_id. + let src_resolved_version_id = src_info.version_id; + + // Source object's existing checksum, if any. When the copy does not request a new + // algorithm, AWS preserves the source object's checksum on the destination (#4996); the + // copy does not transform the plaintext, so we carry the stored value over unchanged + // rather than re-hashing every byte. + let src_checksum = src_info.checksum.as_ref().and_then(|bytes| { + let (pairs, _) = rustfs_rio::read_checksums(bytes.as_ref(), 0); + pairs + .into_iter() + .find_map(|(k, v)| rustfs_rio::Checksum::new_from_string(&k, &v)) + }); + + // Validate copy source conditions + if let Some(if_match) = copy_source_if_match { + if let Some(ref etag) = src_info.etag { + if let Some(strong_etag) = if_match.into_etag() { + if ETag::Strong(etag.clone()) != strong_etag { + return Err(s3_error!(PreconditionFailed)); + } + } else { + // Weak ETag or Any (*) in If-Match should fail per RFC 9110 + return Err(s3_error!(PreconditionFailed)); + } + } else { + return Err(s3_error!(PreconditionFailed)); + } + } + + if let Some(if_none_match) = copy_source_if_none_match + && let Some(ref etag) = src_info.etag + && let Some(strong_etag) = if_none_match.into_etag() + && ETag::Strong(etag.clone()) == strong_etag + { + return Err(s3_error!(PreconditionFailed)); + } + + // A same-name copy is normally serviced as a metadata-only update: the store layer + // rewrites xl.meta in place and leaves the data blocks alone. That shortcut is only sound + // when the destination's physical bytes are identical to the source's, and encryption + // breaks exactly that. The destination metadata is rebuilt from scratch below — + // `strip_managed_encryption_metadata` drops the source DEK and `sse_encryption` mints a + // fresh one — so reusing the stored ciphertext would leave a new DEK sitting beside bytes + // it cannot decrypt, permanently destroying the object (GET fails with an AEAD tag + // mismatch). The mirror case is worse because it is silent: an encrypted source copied + // without any destination SSE keeps its ciphertext while losing the key metadata, so GET + // hands back raw ciphertext as if it were plaintext. So whenever either side is + // encrypted, leave metadata_only = false and let the store layer do a full read/write + // rewrite through put_object, the same resolution the versioned historical-restore path + // uses (issue #4238, crates/ecstore/src/store/object.rs). + // + // This mirrors MinIO's `isSourceEncrypted || isTargetEncrypted -> metadataOnly = false` + // in CopyObjectHandler, with one deliberate difference: MinIO decides "target encrypted" + // from request headers alone, while `effective_sse` here also resolves the bucket default + // encryption rule. `sse_encryption` mints a DEK from that resolved value, so a + // header-only check would miss a self-copy under a bucket default rule. The source half + // deliberately reuses `ObjectInfo::is_encrypted` rather than naming individual headers, + // so a future encryption flavour is covered here the moment it is recognised there. + // + // The zero-copy shortcut is only recoverable for encrypted objects by *preserving* the + // DEK that sealed the bytes and re-wrapping it under a new master key (MinIO's + // `rotateKey` + `keyRotation` flag, which is why it may keep metadataOnly = true). RustFS + // has no such rewrap primitive today; adding one is backlog#1637, and it would enter here + // as an explicit exception rather than by relaxing this guard. + let copy_changes_encryption = src_info.is_encrypted() || effective_sse.is_some() || has_explicit_ssec; + if cp_src_dst_same && src_info.transitioned_object.tier.is_empty() && !copy_changes_encryption { + src_info.metadata_only = true; + } + + // Extract user_defined from Arc for mutation; it will be re-wrapped after all edits. + let mut user_defined = (*src_info.user_defined).clone(); + let effective_tags = replacement_tags.unwrap_or_else(|| (*src_info.user_tags).clone()); + if !replaces_metadata { + let source_expires = src_info.expires.map(Timestamp::from); + insert_expires_metadata(&mut user_defined, source_expires.as_ref())?; + } + + strip_managed_encryption_metadata(&mut user_defined); + + let destination_storage_class = storage_class + .as_ref() + .map(StorageClass::as_str) + .unwrap_or(storageclass::STANDARD); + src_info.storage_class = Some(destination_storage_class.to_string()); + + let actual_size = src_info.get_actual_size().map_err(ApiError::from)?; + + let length = actual_size; + + let mut compress_metadata = HashMap::new(); + + let should_compress = is_disk_compressible(&req.headers, &key) && actual_size > MIN_DISK_COMPRESSIBLE_SIZE as i64; + + if should_compress { + insert_str( + &mut compress_metadata, + SUFFIX_COMPRESSION, + compression_metadata_value(CompressionAlgorithm::default()), + ); + insert_str(&mut compress_metadata, SUFFIX_ACTUAL_SIZE, actual_size.to_string()); + } else { + remove_str(&mut user_defined, SUFFIX_COMPRESSION); + remove_str(&mut user_defined, SUFFIX_ACTUAL_SIZE); + remove_str(&mut user_defined, SUFFIX_COMPRESSION_SIZE); + } + + // Handle MetadataDirective REPLACE: replace user metadata while preserving system metadata. + // System metadata (compression, encryption) is added after this block to ensure + // it's not cleared by the REPLACE operation. + if let Some(replacement_metadata) = replacement_metadata { + user_defined = replacement_metadata; + src_info.content_type = content_type.clone(); + src_info.content_encoding = content_encoding.as_deref().and_then(normalize_content_encoding_for_storage); + src_info.expires = expires.map(OffsetDateTime::from); + } else if metadata_directive.is_some() || website_redirect_location.is_some() { + user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_WEBSITE_REDIRECT_LOCATION)); + if let Some(website_redirect_location) = website_redirect_location { + user_defined.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location); + } + } + + user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_STORAGE_CLASS)); + if destination_storage_class != storageclass::STANDARD { + user_defined.insert(AMZ_STORAGE_CLASS.to_string(), destination_storage_class.to_string()); + } + + user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_OBJECT_TAGGING)); + if !effective_tags.is_empty() { + user_defined.insert(AMZ_OBJECT_TAGGING.to_string(), effective_tags.clone()); + } + src_info.user_tags = Arc::new(effective_tags); + + let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); + remove_object_lock_metadata_for_copy(&mut user_defined); + if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( + &bucket, + &object_lock_config_state, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + )? { + user_defined.extend(object_lock_metadata); + } + apply_bucket_default_lock_retention( + &bucket, + &object_lock_config_state, + &mut user_defined, + has_explicit_object_lock_retention, + )?; + + let mut write_plan = WritePlan::new(); + let mut reader = if should_compress { + let algorithm = CompressionAlgorithm::default(); + let hrd = HashReader::from_stream(gr.stream, length, actual_size, None, None, false).map_err(ApiError::from)?; + write_plan = write_plan.with_compression(algorithm); + hrd + } else { + HashReader::from_stream(gr.stream, length, actual_size, None, None, false).map_err(ApiError::from)? + }; + + // Give the destination object a checksum so CopyObject returns it and a later checksum-mode + // HEAD/GET matches (#4996). When the caller requests an algorithm, compute it fresh over the + // copied plaintext (the hasher sits on the innermost reader so it digests plaintext). When + // none is requested, carry the source object's stored checksum over unchanged — the copy + // does not alter the plaintext, so re-hashing would be wasted work and would flatten a + // multipart composite value. + match requested_checksum_type { + Some(checksum_type) => { + reader.add_calculated_checksum(checksum_type).map_err(ApiError::from)?; + } + None => { + if let Some(cs) = src_checksum { + reader.add_non_trailing_checksum(Some(cs), true).map_err(ApiError::from)?; + } + } + } + + let encryption_request = EncryptionRequest { + bucket: &bucket, + key: &key, + server_side_encryption: effective_sse.clone(), + ssekms_key_id: effective_kms_key_id.clone(), + ssekms_context, + sse_customer_algorithm: sse_customer_algorithm.clone(), + sse_customer_key, + sse_customer_key_md5: sse_customer_key_md5.clone(), + content_size: actual_size, + principal: copy_principal.as_ref(), + }; + + if let Some(material) = sse_encryption(encryption_request).await? { + effective_sse = Some(material.server_side_encryption.clone()); + effective_kms_key_id = material.kms_key_id.clone(); + + write_plan = write_plan.with_encryption(material.write_encryption(None)); + + user_defined.extend(encryption_material_to_metadata(&material)?); + } + + reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?; + + src_info.put_object_reader = Some(PutObjReader::new(reader)); + + // check quota + + for (k, v) in compress_metadata { + user_defined.insert(k, v); + } + + // The source object's replication bookkeeping (internal status/timestamp, + // replica state, and the surfaced x-amz-replication-status) describes the + // SOURCE's replication history; carried onto the destination it fakes a + // COMPLETED/REPLICA state for an object that never replicated (MinIO + // filterReplicationStatusMetadata parity). Inbound replica writes are + // exempt: the authorized replication request owns these keys (see + // copy_dst_opts_with_replication_authorization above). + if !dst_opts.replication_request { + user_defined.retain(|k, _| !k.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)); + remove_str(&mut user_defined, SUFFIX_REPLICATION_STATUS); + remove_str(&mut user_defined, SUFFIX_REPLICATION_TIMESTAMP); + remove_str(&mut user_defined, SUFFIX_REPLICA_STATUS); + remove_str(&mut user_defined, SUFFIX_REPLICA_TIMESTAMP); + } + + // Compute the replication decision exactly once per copy. The same + // immutable `dsc` drives both the pending metadata written below and the + // post-commit schedule (see the reuse site after copy_object), so a + // replication-config hot update cannot split the two phases — same + // contract as the PUT path (https://github.com/rustfs/backlog/issues/1320). + // `must_replicate_object` itself declines inbound replica writes + // (replication_request / REPLICA status), so replicas are never + // re-scheduled outbound. + let dsc = must_replicate_object( + &bucket, + &key, + &user_defined, + "".to_string(), + dst_opts.delete_marker_replication_status(), + dst_opts.clone(), + ) + .await; + if dsc.replicate_any() { + insert_str(&mut user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); + insert_str(&mut user_defined, SUFFIX_REPLICATION_STATUS, dsc.pending_status().unwrap_or_default()); + } + + src_info.user_defined = Arc::new(user_defined); + + let quota_check = self + .check_bucket_quota( + &bucket, + QuotaOperation::CopyObject, + u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + ) + .await?; + let quota_enabled = quota_check.as_ref().is_some_and(|result| result.quota_limit.is_some()); + if let Some(quota_check) = quota_check.as_ref() { + apply_quota_admission(&mut dst_opts, quota_check)?; + } + let previous_current_size = match previous_current_sizes { + Some((_, Ok(logical_size))) if quota_enabled => Some(logical_size), + Some((_, Err(err))) if quota_enabled => return Err(ApiError::from(err).into()), + Some((physical_size, _)) => Some(physical_size), + None => None, + }; + if let Some(quota_check) = quota_check.as_ref() { + ensure_object_size_within_quota( + quota_check, + u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + )?; + } + let has_bucket_metadata = self.bucket_metadata_sys().is_some(); + let cache_adapter = self.object_data_cache(); + let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; + + let copy_commit = spawn_traced_join({ + let store = Arc::clone(&store); + let src_bucket = src_bucket.clone(); + let src_key = src_key.clone(); + let bucket = bucket.clone(); + let key = key.clone(); + let src_opts = src_opts.clone(); + let dst_opts = dst_opts.clone(); + async move { + let _source_bucket_lifecycle_guard = source_bucket_lifecycle_guard; + let _destination_bucket_lifecycle_guard_storage = destination_bucket_lifecycle_guard_storage; + let _self_copy_lock_guard = _self_copy_lock_guard; + + let oi = store + .copy_object(&src_bucket, &src_key, &bucket, &key, &mut src_info, &src_opts, &dst_opts) + .await + .map_err(ApiError::from)?; + + // Reuse the single pre-commit replication decision (see `dsc` above) so + // the persisted pending marker and the schedule always agree, mirroring + // the PUT path. + if dsc.replicate_any() { + schedule_object_replication(oi.clone(), Arc::clone(&store), dsc).await; + } + + maybe_enqueue_transition_immediate(&oi, LcEventSrc::S3CopyObject).await; + let _ = invalidate_object_data_cache_after_copy_success(&cache_adapter, &bucket, &key).await; + + let dest_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await; + if has_bucket_metadata { + let committed_size = quota_accounting_object_size(&oi, quota_enabled)?; + if dest_versioned { + record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; + } else { + record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; + } + } + + rustfs_scanner::record_dirty_usage_bucket(&bucket); + Ok::<_, S3Error>((oi, dest_versioned)) + } + }); + let (oi, dest_versioned) = copy_commit.await.map_err(|err| { + S3Error::with_message(S3ErrorCode::InternalError, format!("copy object commit owner task failed: {err}")) + })??; + + let raw_dest_version = oi.version_id.map(|v| v.to_string()); + let dest_version = if dest_versioned { raw_dest_version } else { None }; + + // Echo the source version that was copied via x-amz-copy-source-version-id (issue #4976). + // AWS/MinIO return this whenever the source bucket carries versioning (enabled or + // suspended); render the null version as "null" like GET/HEAD do. This is the exact source + // version, kept distinct from the destination version_id above. + let src_versioned = BucketVersioningSys::prefix_enabled(&src_bucket, &src_key).await + || BucketVersioningSys::prefix_suspended(&src_bucket, &src_key).await; + let copy_source_version_id = if src_versioned { + src_resolved_version_id.map(|vid| { + if vid == Uuid::nil() { + "null".to_string() + } else { + vid.to_string() + } + }) + } else { + None + }; + + // Report the destination object's checksum in the response, decoded the same way GetObject + // / HeadObject do so the value is identical to a later checksum-mode HEAD/GET (#4996). + let response_checksums = oi + .decrypt_checksums(0, &req.headers) + .map(|(pairs, is_multipart)| classify_response_checksums(pairs, is_multipart)) + .unwrap_or_default(); + + // warn!("copy_object oi {:?}", &oi); + let object_info = oi.clone(); + let mut checksum_md5 = None; + let mut checksum_sha512 = None; + let mut checksum_xxhash3 = None; + let mut checksum_xxhash64 = None; + let mut checksum_xxhash128 = None; + for (name, value) in response_checksums.extra { + match name { + "x-amz-checksum-md5" => checksum_md5 = Some(value), + "x-amz-checksum-sha512" => checksum_sha512 = Some(value), + "x-amz-checksum-xxhash3" => checksum_xxhash3 = Some(value), + "x-amz-checksum-xxhash64" => checksum_xxhash64 = Some(value), + "x-amz-checksum-xxhash128" => checksum_xxhash128 = Some(value), + _ => {} + } + } + let copy_object_result = CopyObjectResult { + e_tag: oi.etag.as_ref().map(|etag| to_s3s_etag(etag)), + last_modified: oi.mod_time.map(Timestamp::from), + checksum_crc32: response_checksums.crc32, + checksum_crc32c: response_checksums.crc32c, + checksum_sha1: response_checksums.sha1, + checksum_sha256: response_checksums.sha256, + checksum_crc64nvme: response_checksums.crc64nvme, + checksum_md5, + checksum_sha512, + checksum_xxhash3, + checksum_xxhash64, + checksum_xxhash128, + checksum_type: response_checksums.checksum_type, + }; + + let output = CopyObjectOutput { + copy_object_result: Some(copy_object_result), + copy_source_version_id, + server_side_encryption: effective_sse, + ssekms_key_id: effective_kms_key_id, + sse_customer_algorithm, + sse_customer_key_md5, + version_id: dest_version, + ..Default::default() + }; + + let version_id = req.input.version_id.clone().unwrap_or_default(); + helper = helper.object(object_info).version_id(version_id); + + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderValue, Method}; + use s3s::dto::{ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule}; + use std::sync::Arc; + + // A malformed bucket-default algorithm reaches this resolution only through + // corrupt or hand-edited bucket metadata (PutBucketEncryption validates the + // value), so the invariant is pinned here rather than end-to-end: the copy + // path must resolve managed AES256 exactly like PUT/extract. With an + // unencrypted same-name source and no SSE-C, the resolved default alone + // keeps `copy_changes_encryption` true, so the metadata-only shortcut stays + // off while `sse_encryption` mints a fresh DEK (backlog#1826). + #[test] + fn copy_bucket_default_unknown_sse_algorithm_falls_back_to_aes256() { + let config = ServerSideEncryptionConfiguration { + rules: vec![ServerSideEncryptionRule { + apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault { + sse_algorithm: ServerSideEncryption::from(String::from("garbage")), + kms_master_key_id: None, + }), + bucket_key_enabled: None, + }], + }; + + let effective_sse = config + .rules + .first() + .and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref()) + .map(bucket_default_write_sse); + + assert_eq!(effective_sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); + + // Valid algorithms map to themselves, byte-identical to the PUT path. + for (configured, expected) in [ + (ServerSideEncryption::AES256, ServerSideEncryption::AES256), + (ServerSideEncryption::AWS_KMS, ServerSideEncryption::AWS_KMS), + ] { + let sse = ServerSideEncryptionByDefault { + sse_algorithm: ServerSideEncryption::from_static(configured), + kms_master_key_id: None, + }; + assert_eq!(bucket_default_write_sse(&sse).as_str(), expected); + } + } + + #[tokio::test] + async fn execute_copy_object_rejects_self_copy_without_replace_directive() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: None, + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[tokio::test] + async fn execute_copy_object_rejects_invalid_storage_class() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "src-bucket".into(), + key: "src-key".into(), + version_id: None, + }) + .bucket("dst-bucket".to_string()) + .key("dst-key".to_string()) + .storage_class(Some(StorageClass::from_static("INVALID"))) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); + } + + #[tokio::test] + async fn execute_copy_object_allows_self_copy_with_storage_class_change() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: None, + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .storage_class(Some(StorageClass::from_static(storageclass::RRS))) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + // Self-copy with explicit storage class change must pass the self-copy guard. + assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[tokio::test] + #[serial_test::serial] + async fn execute_self_copy_when_object_name_equals_bucket_observes_lock_order() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; + use s3s::access::S3Access as _; + + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let ambient = current_app_context().expect("self-copy lock-order test requires an AppContext"); + let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); + let server_ctx = crate::app::runtime_sources::ServerContextSlot::new(); + assert!(server_ctx.install(Arc::clone(&context))); + let fs = FS::with_server_ctx(server_ctx); + + let bucket = format!("self-copy-lock-order-{}", Uuid::new_v4()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create self-copy test bucket"); + let payload = b"object whose key equals its bucket".to_vec(); + let mut reader = PutObjReader::from_vec(payload.clone()); + let setup_opts = ObjectOptions { + no_lock: true, + ..Default::default() + }; + store + .put_object(&bucket, &bucket, &mut reader, &setup_opts) + .await + .expect("put object whose key equals its bucket"); + + let policy_json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"# + ); + let mut bucket_metadata = (*crate::storage::get_bucket_metadata(&bucket) + .await + .expect("self-copy bucket metadata should be cached")) + .clone(); + bucket_metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("self-copy policy should parse")); + bucket_metadata.policy_config_json = policy_json.into_bytes(); + crate::storage::storage_api::set_bucket_metadata(bucket.clone(), bucket_metadata) + .await + .expect("publish self-copy test policy"); + + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: bucket.clone().into(), + key: bucket.clone().into(), + version_id: None, + }) + .bucket(bucket.clone()) + .key(bucket.clone()) + .metadata_directive(Some(MetadataDirective::from_static(MetadataDirective::REPLACE))) + .metadata(Some(HashMap::from([("lock-order".to_string(), "verified".to_string())]))) + .build() + .expect("self-copy input should build"); + let mut req = build_request(input, Method::PUT); + req.extensions.insert(crate::storage::access::ReqInfo::default()); + fs.copy_object(&mut req) + .await + .expect("authorize self-copy whose object key equals its bucket"); + + let response = tokio::time::timeout( + std::time::Duration::from_secs(30), + DefaultObjectUsecase::with_context(Some(context)).execute_copy_object(req), + ) + .await + .expect("lifecycle, authority, and exact object locks must not deadlock") + .expect("self-copy whose object key equals its bucket should succeed"); + assert!(response.output.copy_object_result.is_some()); + let info = store + .get_object_info(&bucket, &bucket, &ObjectOptions::default()) + .await + .expect("self-copied object should remain readable"); + assert_eq!(info.size, payload.len() as i64); + + store + .delete_bucket( + &bucket, + &DeleteBucketOptions { + force: true, + ..Default::default() + }, + ) + .await + .expect("clean up self-copy test bucket"); + } + + #[tokio::test] + async fn execute_copy_object_allows_tiered_self_copy_with_storage_class_change() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: None, + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .storage_class(Some(StorageClass::from_static(storageclass::STANDARD))) + .metadata_directive(Some(MetadataDirective::from_static(MetadataDirective::REPLACE))) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + // Tiered self-copy with STANDARD storage class must pass all validation checks. + // The call fails at store init (no store in unit tests), not at validation. + assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); + assert_ne!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_copy_object_allows_self_copy_of_historical_version() { + // Restoring a specific historical version onto the current key (same bucket/key with a + // source versionId, default COPY directive) must pass the self-copy guard (issue #4238). + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: Some("11111111-1111-1111-1111-111111111111".into()), + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + // Must not be rejected by the self-copy guard; it fails later at store init instead. + assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[tokio::test] + async fn execute_copy_object_allows_self_copy_of_null_version() { + // A "null" source version id is a restore of the null version, not a no-op self-copy. + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: Some("null".into()), + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[tokio::test] + async fn execute_copy_object_rejects_malformed_copy_source_version_id() { + // A malformed (non-null, non-UUID) source version id is rejected up front. + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "src-bucket".into(), + key: "src-key".into(), + version_id: Some("not-a-uuid".into()), + }) + .bucket("dst-bucket".to_string()) + .key("dst-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[tokio::test] + async fn execute_copy_object_rejects_expected_version_for_different_destination() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "source-key".into(), + version_id: Some(Uuid::new_v4().to_string().into()), + }) + .bucket("test-bucket".to_string()) + .key("destination-key".to_string()) + .build() + .unwrap(); + let mut req = build_request(input, Method::PUT); + req.headers.insert( + RUSTFS_EXPECTED_CURRENT_VERSION_ID, + HeaderValue::from_str(&Uuid::new_v4().to_string()).unwrap(), + ); + + let err = Box::pin(DefaultObjectUsecase::without_context().execute_copy_object(req)) + .await + .unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } +} diff --git a/rustfs/src/app/object/delete.rs b/rustfs/src/app/object/delete.rs new file mode 100644 index 000000000..a77d84242 --- /dev/null +++ b/rustfs/src/app/object/delete.rs @@ -0,0 +1,2000 @@ +// 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. + +//! DeleteObject / DeleteObjects path. + +use super::*; + +fn successful_delete_audit_objects( + delete: &s3s::dto::Delete, + successful_results: impl IntoIterator, +) -> Vec { + delete + .objects + .iter() + .zip(successful_results) + .filter(|(_, successful)| *successful) + .map(|(requested, _)| AuditObjectVersion::new(requested.key.clone(), requested.version_id.clone())) + .collect() +} + +fn normalize_delete_objects_version_id( + version_id: Option, +) -> std::result::Result<(Option, Option), String> { + let version_id = version_id.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); + match version_id { + Some(id) => { + if id.eq_ignore_ascii_case("null") { + Ok((Some("null".to_string()), Some(Uuid::nil()))) + } else { + let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + Ok((Some(id), Some(uuid))) + } + } + None => Ok((None, None)), + } +} + +#[cfg(test)] +type DeleteSnapshotTestHook = (String, Arc, Arc); + +#[cfg(test)] +static DELETE_SNAPSHOT_TEST_HOOK: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +static DELETE_SOURCE_TEST_HOOK: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +static DELETE_OBJECTS_AUTH_TEST_HOOK: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +pub(crate) fn install_delete_snapshot_test_hook( + bucket: String, + loaded: Arc, + resume: Arc, +) { + *DELETE_SNAPSHOT_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete snapshot test hook lock should not be poisoned") = Some((bucket, loaded, resume)); +} + +#[cfg(test)] +async fn wait_for_delete_snapshot_test_hook(bucket: &str) { + let hook = { + let mut slot = DELETE_SNAPSHOT_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete snapshot test hook lock should not be poisoned"); + if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { + slot.take() + } else { + None + } + }; + if let Some((_bucket, loaded, resume)) = hook { + loaded.wait().await; + resume.wait().await; + } +} + +#[cfg(test)] +pub(crate) fn install_delete_source_test_hook( + bucket: String, + loaded: Arc, + resume: Arc, +) { + *DELETE_SOURCE_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete source test hook lock should not be poisoned") = Some((bucket, loaded, resume)); +} + +#[cfg(test)] +async fn wait_for_delete_source_test_hook(bucket: &str) { + let hook = { + let mut slot = DELETE_SOURCE_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete source test hook lock should not be poisoned"); + if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { + slot.take() + } else { + None + } + }; + if let Some((_bucket, loaded, resume)) = hook { + loaded.wait().await; + resume.wait().await; + } +} + +#[cfg(test)] +pub(crate) fn install_delete_objects_auth_test_hook( + bucket: String, + loaded: Arc, + resume: Arc, +) { + *DELETE_OBJECTS_AUTH_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete objects auth test hook lock should not be poisoned") = Some((bucket, loaded, resume)); +} + +#[cfg(test)] +async fn wait_for_delete_objects_auth_test_hook(bucket: &str) { + let hook = { + let mut slot = DELETE_OBJECTS_AUTH_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete objects auth test hook lock should not be poisoned"); + if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { + slot.take() + } else { + None + } + }; + if let Some((_bucket, loaded, resume)) = hook { + loaded.wait().await; + resume.wait().await; + } +} + +fn enrich_delete_replication_state_if_needed( + snapshot: &DeleteReplicationConfigSnapshot, + delete_object: &mut StorageDeletedObject, + obj_info: &ObjectInfo, +) { + let Some(replication_state) = delete_object.replication_state.as_ref() else { + return; + }; + if obj_info.replication_status != ReplicationStatusType::Replica + && !replication_state.replicate_decision_str.is_empty() + && (!replication_state.targets.is_empty() || !replication_state.purge_targets.is_empty()) + { + return; + } + + let Some(config) = snapshot.replication_config() else { + return; + }; + let version_id = if delete_object.delete_marker { + None + } else if delete_object.delete_marker_version_id.is_some() { + delete_object.delete_marker_version_id + } else { + delete_object.version_id + }; + if let Some(local_state) = delete_replication_state_from_config( + config, + obj_info, + version_id, + obj_info.replication_status == ReplicationStatusType::Replica, + ) { + set_deleted_object_replication_state(delete_object, &local_state); + } +} + +fn should_schedule_replica_delete_replication( + snapshot: &DeleteReplicationConfigSnapshot, + replication_source: &ObjectInfo, + version_id: Option, +) -> bool { + let Some(config) = snapshot.replication_config() else { + return false; + }; + + delete_replication_state_from_config(config, replication_source, version_id, true).is_some() +} + +fn validate_undo_delete_version(expected: Option<&str>, requested: Option<&str>) -> S3Result<()> { + if expected.is_some() && expected != requested { + return Err(s3_error!(PreconditionFailed)); + } + Ok(()) +} + +fn delete_creates_delete_marker(opts: &ObjectOptions) -> bool { + opts.version_id.is_none() && opts.versioned && !opts.version_suspended +} + +fn delete_removes_current_object(opts: &ObjectOptions) -> bool { + delete_request_targets_current( + opts.version_id + .as_deref() + .and_then(|version_id| Uuid::parse_str(version_id).ok()), + ) +} + +fn delete_request_targets_current(version_id: Option) -> bool { + version_id.is_none() || version_id.is_some_and(|version_id| version_id.is_nil()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeleteMemoryUpdate { + DeleteMarker, + Object { size: u64, removed_current_object: bool }, +} + +fn delete_memory_update( + creates_delete_marker: bool, + committed_delete_marker: bool, + requested_current: bool, + accounting_size: Option, + removed_current_object: bool, +) -> Option { + if creates_delete_marker || (committed_delete_marker && requested_current) { + return Some(DeleteMemoryUpdate::DeleteMarker); + } + + (!committed_delete_marker) + .then_some(accounting_size) + .flatten() + .map(|size| DeleteMemoryUpdate::Object { + size, + removed_current_object, + }) +} + +async fn apply_delete_memory_update(bucket: &str, update: Option) { + match update { + Some(DeleteMemoryUpdate::DeleteMarker) => record_bucket_delete_marker_memory(bucket).await, + Some(DeleteMemoryUpdate::Object { + size, + removed_current_object, + }) => record_bucket_object_delete_memory(bucket, size, removed_current_object).await, + None => {} + } +} + +/// `DeleteObjects` is idempotent. A raw filesystem `NotFound` can cross the +/// distributed delete path instead of its usual typed missing-object error. +fn is_delete_objects_not_found(error: &EcstoreError) -> bool { + is_err_object_not_found(error) + || is_err_version_not_found(error) + || matches!(error, StorageError::Io(source) if source.kind() == std::io::ErrorKind::NotFound) +} + +/// Bounded concurrency for the per-object pre-delete stat fanout in +/// `execute_delete_objects` (backlog#929 / HP-8). Keeps the metadata reads for +/// a 1000-key batch from serializing while capping the disk fanout pressure. +const DELETE_OBJECTS_PRE_STAT_CONCURRENCY: usize = 16; + +/// backlog#929 (HP-8): whether the pre-delete `get_object_info` for one entry +/// of a DeleteObjects batch can be skipped without changing behavior. +/// +/// The stat result feeds four consumers, and each must be provably idle: +/// - the app-layer object-lock admission check never runs for deletes that +/// create a delete marker, and non-lock buckets cannot hold retention or +/// legal-hold metadata (`bucket_lock_enabled == false`); +/// - replication reads its authoritative source metadata later while the +/// SetDisks write lock is held, so it does not consume this advisory stat; +/// - usage accounting for delete-marker creation goes through +/// `record_bucket_delete_marker_memory` and never reads the object size +/// (`accounting_creates_delete_marker` is computed from the same versioning +/// snapshot the accounting branch uses); +/// - transitioned-object (ILM tier) cleanup journaling is a no-op for +/// delete-marker creation because no version is removed, so `ObjSweeper` +/// produces no journal entry regardless of the stat result. +/// +/// Object-lock enabled buckets always keep the stat, so their delete path is +/// byte-for-byte the pre-#929 one (see PR #4297). +fn can_skip_delete_objects_pre_stat( + bucket_lock_enabled: bool, + opts: &ObjectOptions, + accounting_creates_delete_marker: bool, +) -> bool { + !bucket_lock_enabled && delete_creates_delete_marker(opts) && accounting_creates_delete_marker +} + +fn complete_delete_noop( + helper: OperationHelper, + bucket: String, + key: String, + version_id: Option, +) -> (S3Result>, OperationHelper) { + let helper = helper + .event_name(EventName::ObjectRemovedNoOP) + .object(ObjectInfo { + name: key, + bucket, + ..Default::default() + }) + .version_id(version_id.unwrap_or_default()); + let result = Ok(S3Response::with_status(DeleteObjectOutput::default(), StatusCode::NO_CONTENT)); + let helper = helper.complete(&result); + (result, helper) +} + +fn delete_response_version_id(version_id: Option, synthetic_version_id: bool) -> Option { + if synthetic_version_id { + None + } else if version_id == Some(Uuid::nil()) { + Some(NULL_VERSION_ID.to_string()) + } else { + version_id.map(|version_id| version_id.to_string()) + } +} + +fn reduce_delete_objects_result<'a>( + object: &ObjectToDelete, + deleted: &'a StorageDeletedObject, + error: Option<&EcstoreError>, + synthetic_version_id: bool, +) -> Result<&'a StorageDeletedObject, s3s::dto::Error> { + match error { + None => Ok(deleted), + Some(error) if is_delete_objects_not_found(error) => Ok(deleted), + Some(error) => { + let api_error = ApiError::from(error.clone()); + Err(s3s::dto::Error { + code: Some(api_error.code.as_str().to_string()), + key: Some(object.object_name.clone()), + message: Some(api_error.message), + version_id: delete_response_version_id(object.version_id, synthetic_version_id), + }) + } + } +} + +impl DefaultObjectUsecase { + #[instrument(level = "debug", skip(self, req))] + pub async fn execute_delete_objects( + &self, + mut req: S3Request, + ) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObjects).suppress_event(); + let request_context = helper.request_context_or_from_request(&req); + let (bucket, delete) = { + let bucket = req.input.bucket.clone(); + let delete = req.input.delete.clone(); + (bucket, delete) + }; + + if delete.objects.is_empty() || delete.objects.len() > 1000 { + return Err(S3Error::with_message( + S3ErrorCode::InvalidArgument, + "No objects to delete or too many objects to delete".to_string(), + )); + } + + let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false); + if !recursive_force_delete_is_authorized(&req.headers, is_owner, false) { + return Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Recursive force-delete is restricted to administrative requests", + )); + } + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + // Capture the bucket generation before per-object authorization, but + // do not expose a bucket-state error unless at least one object is authorized. + let bucket_generation = load_bucket_generation_from_store(store.as_ref(), &req, &bucket).await; + + let bypass_governance = has_bypass_governance_header(&req.headers); + + #[derive(Default, Clone)] + struct DeleteResult { + delete_object: Option, + error: Option, + synthetic_version_id: bool, + } + + let mut delete_results = vec![DeleteResult::default(); delete.objects.len()]; + + struct AuthorizedDelete { + idx: usize, + object: ObjectToDelete, + } + + let mut authorized_deletes = Vec::with_capacity(delete.objects.len()); + // Issue #5740: keep the first per-key denial of this bulk request at + // warn and demote the rest to debug, so a denied 1000-key DeleteObjects + // cannot flood the log. + let mut bulk_denial_logged = false; + for (idx, obj_id) in delete.objects.iter().enumerate() { + let raw_version_id = obj_id.version_id.clone(); + let (version_id, version_uuid) = match normalize_delete_objects_version_id(raw_version_id.clone()) { + Ok(parsed) => parsed, + Err(err) => { + delete_results[idx].error = Some(s3s::dto::Error { + code: Some("NoSuchVersion".to_string()), + key: Some(obj_id.key.clone()), + message: Some(err), + version_id: raw_version_id, + }); + continue; + } + }; + + { + let req_info = req_info_mut(&mut req)?; + req_info.bucket = Some(bucket.clone()); + req_info.object = Some(obj_id.key.clone()); + req_info.version_id = version_id.clone(); + } + + let auth_res = authorize_request(&mut req, Action::S3Action(S3Action::DeleteObjectAction)).await; + if auth_res.is_err() { + if !bulk_denial_logged { + bulk_denial_logged = true; + req_info_mut(&mut req)?.suppress_denial_log = true; + } + delete_results[idx].error = Some(s3s::dto::Error { + code: Some("AccessDenied".to_string()), + key: Some(obj_id.key.clone()), + message: Some("Access Denied".to_string()), + version_id: version_id.clone(), + }); + continue; + } + + if bypass_governance { + let auth_res = authorize_request(&mut req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await; + if auth_res.is_err() { + if !bulk_denial_logged { + bulk_denial_logged = true; + req_info_mut(&mut req)?.suppress_denial_log = true; + } + delete_results[idx].error = Some(s3s::dto::Error { + code: Some("AccessDenied".to_string()), + key: Some(obj_id.key.clone()), + message: Some("Access Denied".to_string()), + version_id: version_id.clone(), + }); + continue; + } + } + + if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await { + delete_results[idx].error = Some(s3s::dto::Error { + code: Some("InvalidRequest".to_string()), + key: Some(obj_id.key.clone()), + message: Some(err.to_string()), + version_id: version_id.clone(), + }); + continue; + } + + let synthetic_version_id = version_id.is_none() && is_dir_object(&obj_id.key); + let object = ObjectToDelete { + object_name: obj_id.key.clone(), + version_id: version_uuid, + synthetic_version_id, + ..Default::default() + }; + delete_results[idx].synthetic_version_id = synthetic_version_id; + + authorized_deletes.push(AuthorizedDelete { idx, object }); + } + + if authorized_deletes.is_empty() { + let output = DeleteObjectsOutput { + deleted: Some(Vec::new()), + errors: Some(delete_results.into_iter().filter_map(|result| result.error).collect()), + ..Default::default() + }; + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + return result; + } + #[cfg(test)] + wait_for_delete_objects_auth_test_hook(&bucket).await; + req.extensions.insert(bucket_generation?); + let bucket_lock_enabled = object_lock_checks_required(&bucket).await; + + let delete_config_snapshot = Arc::new( + load_delete_config_snapshot(store.as_ref(), &bucket) + .await + .map_err(ApiError::from)?, + ); + let version_cfg = delete_config_snapshot.versioning_config(); + let replicate_deletes = authorized_deletes + .iter() + .any(|authorized| has_active_delete_rule(&delete_config_snapshot, &authorized.object.object_name)); + + struct PreparedDelete { + idx: usize, + object: ObjectToDelete, + opts: ObjectOptions, + skip_stat: bool, + } + + // Phase 1 (serial): derive storage options from the request-scoped + // configuration after every candidate has passed authorization. + let mut prepared_deletes: Vec = Vec::with_capacity(authorized_deletes.len()); + for authorized in authorized_deletes { + let AuthorizedDelete { idx, object } = authorized; + + let metadata = extract_metadata(&req.headers); + let opts: ObjectOptions = del_opts_with_versioning( + &bucket, + &object.object_name, + object.version_id.map(|f| f.to_string()), + &req.headers, + metadata, + version_cfg, + false, + ) + .map_err(ApiError::from)?; + + // backlog#929 (HP-8): the accounting branch after the store delete + // decides delete-marker vs object-delete from this exact snapshot, + // so evaluate it here with the same inputs to keep the stat-skip + // decision and the accounting path provably consistent. + let accounting_creates_delete_marker = object.version_id.is_none() && opts.versioned && !opts.version_suspended; + let skip_stat = can_skip_delete_objects_pre_stat(bucket_lock_enabled, &opts, accounting_creates_delete_marker); + + prepared_deletes.push(PreparedDelete { + idx, + object, + opts, + skip_stat, + }); + } + + struct AdmittedDelete { + idx: usize, + object: ObjectToDelete, + versioned: bool, + version_suspended: bool, + } + + // Phase 2 (bounded concurrency, backlog#929 / HP-8): collect the + // metadata needed for accounting and tier cleanup. Entries are + // independent per key, and `buffered` preserves input order. Object + // Lock admission is enforced later in set_disk under the write lock. + let store_ref = &store; + let bucket_ref = bucket.as_str(); + let admitted_deletes: Vec = + futures::stream::iter(prepared_deletes.into_iter().map(|prepared| async move { + let PreparedDelete { + idx, + mut object, + opts, + skip_stat, + } = prepared; + let synthetic_version_id = object.version_id.is_none() && is_dir_object(&object.object_name); + if !skip_stat { + match store_ref.get_object_info(bucket_ref, &object.object_name, &opts).await { + Ok(_) => {} + Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {} + Err(err) => return Err(ApiError::from(err)), + } + } + + if synthetic_version_id { + object.version_id = Some(Uuid::nil()); + } + + Ok::<_, ApiError>(AdmittedDelete { + idx, + object, + versioned: opts.versioned, + version_suspended: opts.version_suspended, + }) + })) + .buffered(DELETE_OBJECTS_PRE_STAT_CONCURRENCY) + .try_collect() + .await?; + + // Phase 3 (serial): apply outcomes in the original request order so + // per-key success/failure reporting is unchanged. + let mut object_to_delete = Vec::new(); + let mut object_to_delete_idx = Vec::new(); + let mut object_versioning = Vec::new(); + for admitted in admitted_deletes { + object_to_delete_idx.push(admitted.idx); + object_versioning.push((admitted.versioned, admitted.version_suspended)); + object_to_delete.push(admitted.object); + } + let cache_adapter = self.object_data_cache(); + let cache_keys_before_delete = object_to_delete + .iter() + .map(|object| object.object_name.clone()) + .collect::>(); + invalidate_object_data_cache_objects_before_mutation(&cache_adapter, &bucket, cache_keys_before_delete.iter()).await; + + let mut storage_delete_opts = ObjectOptions { + versioned: version_cfg.enabled(), + version_suspended: version_cfg.suspended(), + delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)), + object_lock_delete: Some(StorageObjectLockDeleteOptions { bypass_governance }), + ..Default::default() + }; + apply_bucket_generation_guard(&req, &bucket, &mut storage_delete_opts)?; + let (dobjs, errs, accounting) = store + .delete_objects_with_tier_delete_journal_and_accounting(&bucket, object_to_delete.clone(), storage_delete_opts) + .await; + + let _manager = get_concurrency_manager(); + let _bucket_clone = bucket.clone(); + let _deleted_objects = dobjs.clone(); + if !errs.is_empty() && errs.iter().all(|err| err.as_ref().is_some_and(is_err_bucket_not_found)) { + let result = Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string())); + let _ = helper.complete(&result); + return result; + } + + for (i, err) in errs.iter().enumerate() { + let didx = object_to_delete_idx[i]; + + match reduce_delete_objects_result( + &object_to_delete[i], + &dobjs[i], + err.as_ref(), + delete_results[didx].synthetic_version_id, + ) { + Ok(deleted_object) => { + delete_results[didx].delete_object = Some(deleted_object.clone()); + let (versioned, version_suspended) = object_versioning[i]; + let creates_delete_marker = object_to_delete[i].version_id.is_none() && versioned && !version_suspended; + let committed_delete_marker = dobjs[i].delete_marker; + let delete_accounting = accounting.get(i).and_then(Option::as_ref); + let update = delete_memory_update( + creates_delete_marker, + committed_delete_marker, + delete_request_targets_current(object_to_delete[i].version_id), + delete_accounting.and_then(|value| value.size), + delete_accounting.is_some_and(|value| value.removed_current_object), + ); + apply_delete_memory_update(&bucket, update).await; + } + Err(error) => { + delete_results[didx].error = Some(error); + } + } + } + + let deleted = delete_results + .iter() + .filter_map(|result| result.delete_object.as_ref().map(|object| (result, object))) + .map(|(result, object)| DeletedObject { + delete_marker: { if object.delete_marker { Some(true) } else { None } }, + delete_marker_version_id: delete_response_version_id( + object.delete_marker_version_id, + result.synthetic_version_id, + ), + key: Some(object.object_name.clone()), + version_id: delete_response_version_id(object.version_id, result.synthetic_version_id), + }) + .collect(); + let deleted_cache_keys = delete_results + .iter() + .filter_map(|result| result.delete_object.as_ref().map(|deleted| deleted.object_name.clone())) + .collect::>(); + invalidate_object_data_cache_objects_after_delete_success(&cache_adapter, &bucket, deleted_cache_keys.iter()).await; + + let errors = delete_results + .iter() + .filter_map(|v| v.error.clone()) + .collect::>(); + let output = DeleteObjectsOutput { + deleted: Some(deleted), + errors: Some(errors), + ..Default::default() + }; + let helper = if helper.wants_audit_object_info() { + let audit_objects = + successful_delete_audit_objects(&delete, delete_results.iter().map(|result| result.delete_object.is_some())); + helper.audit_objects(audit_objects) + } else { + helper + }; + + let replication_deletes = if replicate_deletes { + delete_results + .iter() + .filter_map(|result| result.delete_object.as_ref()) + .filter(|dobj| deleted_object_has_pending_replication_delete(dobj)) + .cloned() + .collect::>() + } else { + Vec::new() + }; + if !replication_deletes.is_empty() { + let bucket_for_replication = bucket.clone(); + let replication_task = tokio::spawn(async move { + let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Replication); + schedule_replication_deletes(replication_deletes, bucket_for_replication, REPLICATE_INCOMING_DELETE.to_string()) + .await; + }); + // The spawned task owns every locally committed delete. Dropping the + // join handle on request cancellation therefore cannot lose the tail. + let _ = replication_task.await; + } + + let req_headers = req.headers.clone(); + let notify = current_notify_interface_for_context(self.context.as_deref()); + let req_params = rustfs_targets::extract_params_header(&req_headers); + let resp_elements = + build_event_resp_elements(&S3Response::new(DeleteObjectsOutput::default()), &request_context.request_id); + let deleted_any = delete_results.iter().any(|result| result.delete_object.is_some()); + let notify_bucket = bucket.clone(); + spawn_background_with_context(Some(request_context), async move { + let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Notify); + for res in delete_results { + if let Some(dobj) = res.delete_object { + let event_name = delete_event_name_for_marker(dobj.delete_marker); + let event_args = EventArgsBuilder::new( + event_name, + notify_bucket.clone(), + convert_ecstore_object_info(ObjectInfo { + name: dobj.object_name.clone(), + bucket: notify_bucket.clone(), + ..Default::default() + }), + ) + .version_id(delete_response_version_id(dobj.version_id, res.synthetic_version_id).unwrap_or_default()) + .req_params(req_params.clone()) + .resp_elements(resp_elements.clone()) + .host(get_request_host(&req_headers)) + .user_agent(get_request_user_agent(&req_headers)) + .build(); + + notify.notify(event_args).await; + } + } + }); + + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + if deleted_any { + rustfs_scanner::record_dirty_usage_bucket(&bucket); + } + // Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead) + let manager = get_capacity_manager(); + manager.record_write_operation().await; + result + } + + #[instrument(level = "info", skip(self, req))] + pub async fn execute_delete_object(&self, mut req: S3Request) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObject); + let DeleteObjectInput { + bucket, key, version_id, .. + } = req.input.clone(); + + // Validate object key + validate_object_key(&key, "DELETE")?; + + let replica = req + .headers + .get(AMZ_BUCKET_REPLICATION_STATUS) + .map(|v| v.to_str().unwrap_or_default() == ReplicationStatusType::Replica.as_str()) + .unwrap_or_default(); + + if replica { + authorize_request(&mut req, Action::S3Action(S3Action::ReplicateDeleteAction)).await?; + } + + let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false); + if !recursive_force_delete_is_authorized(&req.headers, is_owner, replica) { + return Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Recursive force-delete is restricted to internal or administrative requests", + )); + } + validate_table_catalog_object_mutation(&bucket, &key).await?; + + // Establish bucket existence before any bucket-metadata work (matches + // PUT/GET): nonexistent buckets fail here instead of paying the + // versioning lookups in del_opts/get_opts first. Resolve the store + // through the request-bound server context (backlog#1052 S6), not the + // process-global handle. + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + validate_bucket_exists(&store, &bucket).await?; + + let metadata = extract_metadata(&req.headers); + // Clone version_id before it's moved + let version_id_clone = version_id.clone(); + let synthetic_version_id = version_id_clone.is_none() && is_dir_object(&key); + + let delete_config_snapshot = Arc::new( + load_delete_config_snapshot(store.as_ref(), &bucket) + .await + .map_err(ApiError::from)?, + ); + #[cfg(test)] + wait_for_delete_snapshot_test_hook(&bucket).await; + let version_cfg = delete_config_snapshot.versioning_config(); + let mut opts: ObjectOptions = + del_opts_with_versioning(&bucket, &key, version_id, &req.headers, metadata, version_cfg, replica) + .map_err(ApiError::from)?; + opts.delete_replication_config_snapshot = Some(Arc::clone(&delete_config_snapshot)); + opts.object_lock_delete = Some(StorageObjectLockDeleteOptions { + bypass_governance: has_bypass_governance_header(&req.headers), + }); + apply_bucket_generation_guard(&req, &bucket, &mut opts)?; + let force_delete = opts.delete_prefix; + + // let mut vid = opts.version_id.clone(); + + if replica { + opts.set_replica_status(ReplicationStatusType::Replica); + + // if opts.version_purge_status().is_empty() { + // vid = None; + // } + } + + let expected_current_version_id = expected_current_version_id(&req.headers)?; + if expected_current_version_id.is_some() && (force_delete || !opts.versioned) { + return Err(s3_error!( + InvalidRequest, + "Expected current version precondition requires a version-specific delete in a versioned bucket" + )); + } + validate_undo_delete_version(expected_current_version_id.as_deref(), opts.version_id.as_deref())?; + opts.expected_current_version_id = expected_current_version_id.clone(); + + let replicate_force_delete = force_delete && !replica && has_active_delete_rule(&delete_config_snapshot, &key); + let mut force_delete_intent = None; + + let get_opts = opts.clone(); + let existing_object_info = match store.get_object_info(&bucket, &key, &get_opts).await { + Ok(obj_info) => Some(obj_info), + Err(err) => { + // If object not found, allow deletion to proceed (will return 204 No Content) + if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { + return Err(ApiError::from(err).into()); + } + None + } + }; + #[cfg(test)] + wait_for_delete_source_test_hook(&bucket).await; + + let cache_adapter = self.object_data_cache(); + // A force (delete_prefix) delete removes every object under `key` as a + // prefix, so invalidating only the exact key would strand every cached + // body beneath it. Use the prefix primitive in that branch (ODC-27). + if force_delete { + let _ = invalidate_object_data_cache_prefix_before_mutation(&cache_adapter, &bucket, &key).await; + } else { + let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; + } + + if replicate_force_delete + && let Some((target_arns, generation)) = force_delete_target_set(&delete_config_snapshot, &key) + && !target_arns.is_empty() + { + let operation_id = + persist_force_delete_intent(store.clone(), bucket.clone(), key.clone(), target_arns.clone(), generation) + .await + .map_err(ApiError::from)?; + force_delete_intent = Some((operation_id, target_arns, generation)); + } + + let obj_info = { + match store + .delete_object_with_tier_delete_journal(&bucket, &key, opts.clone()) + .await + { + Ok(obj) => obj, + Err(err) => { + if let Some((operation_id, _, _)) = force_delete_intent.as_ref() + && let Err(cleanup_error) = + crate::storage::storage_api::complete_force_delete_intent(store.clone(), *operation_id).await + { + warn!( + bucket = %bucket, + object = %key, + operation_id = %operation_id, + error = %cleanup_error, + "failed to remove uncommitted force-delete intent after local delete failure" + ); + } + if is_err_bucket_not_found(&err) { + return Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string())); + } + + if is_err_object_not_found(&err) || is_err_version_not_found(&err) { + let (result, _helper) = complete_delete_noop(helper, bucket, key, version_id_clone); + return result; + } + + if matches!(&err, StorageError::PrefixAccessDenied(_, _)) + && let Some(existing_object_info) = existing_object_info.as_ref() + && let Some(reason) = check_object_lock_for_deletion( + &bucket, + existing_object_info, + has_bypass_governance_header(&req.headers), + ) + .await + { + return Err(S3Error::with_message(S3ErrorCode::AccessDenied, reason.error_message())); + } + + return Err(ApiError::from(err).into()); + } + } + }; + + if force_delete { + let _ = invalidate_object_data_cache_prefix_after_delete(&cache_adapter, &bucket, &key).await; + } else { + let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await; + } + + // Fast in-memory update for immediate quota and admin usage consistency. + // Prefix/force deletes and synthetic directory entries do not carry one + // committed object identity; leave their cache delta to reconciliation. + let update = if force_delete || obj_info.name.is_empty() || synthetic_version_id { + None + } else { + // The storage commit returns this object's metadata while its + // generation lock is held. Never fall back to a pre-delete stat: + // an overwrite can commit between that stat and this delete. + delete_memory_update( + delete_creates_delete_marker(&opts), + obj_info.delete_marker, + opts.version_id.is_none(), + quota_object_size(&obj_info).ok(), + delete_removes_current_object(&opts), + ) + }; + apply_delete_memory_update(&bucket, update).await; + + if obj_info.name.is_empty() { + if let Some((operation_id, target_arns, generation)) = force_delete_intent { + if let Err(error) = commit_force_delete_intent(store.clone(), operation_id).await { + warn!( + bucket = %bucket, + object = %key, + operation_id = %operation_id, + error = %error, + "failed to mark force-delete intent committed after local delete" + ); + } + let generation = i64::try_from(generation.unix_timestamp_nanos()).unwrap_or(i64::MAX); + schedule_replication_delete( + StorageDeletedObject { + object_name: key.clone(), + force_delete: true, + force_delete_id: Some(operation_id), + force_delete_target_arns: target_arns, + force_delete_generation: Some(generation), + ..Default::default() + }, + bucket.clone(), + REPLICATE_INCOMING_DELETE.to_string(), + ) + .await; + } else if replicate_force_delete { + let mut delete_object = StorageDeletedObject { + object_name: key.clone(), + force_delete: true, + ..Default::default() + }; + if let Some(replication_state) = delete_replication_state_from_config( + delete_config_snapshot + .replication_config() + .unwrap_or_else(|| unreachable!("force-delete requires a replication config")), + &ObjectInfo { + bucket: bucket.clone(), + name: key.clone(), + ..Default::default() + }, + None, + false, + ) { + set_deleted_object_replication_state(&mut delete_object, &replication_state); + } + schedule_replication_delete(delete_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await; + } + // Prefix/force-delete returns empty ObjectInfo; still emit bucket notification so webhooks match S3 DELETE. + helper = helper + .event_name(delete_event_name_for_marker(false)) + .object(ObjectInfo { + name: key.clone(), + bucket: bucket.clone(), + ..Default::default() + }) + .version_id(String::new()); + let result = Ok(S3Response::with_status(DeleteObjectOutput::default(), StatusCode::NO_CONTENT)); + // Match non-empty delete path: capacity manager write-op telemetry. + let manager = get_capacity_manager(); + manager.record_write_operation().await; + let _ = helper.complete(&result); + rustfs_scanner::record_dirty_usage_bucket(&bucket); + return result; + } + + let deleted_replication_info = existing_object_info + .as_ref() + .filter(|_| should_use_existing_delete_replication_info(&opts, opts.version_id.is_some())); + let _delete_tail_guard = DeleteTailActivityGuard::new(DeleteTailStage::Tail); + let deleted_object_source = deleted_replication_info.unwrap_or(&obj_info); + let replication_state_source = &obj_info; + let deleted_delete_marker_version = deleted_replication_info.is_some_and(|info| info.delete_marker); + + let delete_replication_version_id = delete_replication_version_id(deleted_object_source, deleted_delete_marker_version); + let schedule_delete_replication = if opts.replication_request && replica { + should_schedule_replica_delete_replication( + &delete_config_snapshot, + replication_state_source, + delete_replication_version_id, + ) + } else { + should_schedule_delete_replication( + &opts, + replication_state_source, + deleted_delete_marker_version, + opts.version_id.is_some(), + ) + }; + + if schedule_delete_replication { + let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Replication); + let mut deleted_object = StorageDeletedObject { + delete_marker: deleted_object_source.delete_marker && !deleted_delete_marker_version, + delete_marker_version_id: if deleted_object_source.delete_marker { + deleted_object_source.version_id + } else { + None + }, + object_name: key.clone(), + version_id: if deleted_object_source.delete_marker { + None + } else { + deleted_object_source.version_id + }, + delete_marker_mtime: deleted_object_source.mod_time, + replication_state: None, + ..Default::default() + }; + set_deleted_object_replication_state(&mut deleted_object, &replication_state_source.replication_state()); + enrich_delete_replication_state_if_needed(&delete_config_snapshot, &mut deleted_object, replication_state_source); + schedule_replication_delete(deleted_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await; + } + + let delete_marker = obj_info.delete_marker; + let version_id = obj_info.version_id; + let response_version_id = delete_response_version_id(version_id, synthetic_version_id); + + let output = DeleteObjectOutput { + delete_marker: Some(delete_marker), + version_id: response_version_id.clone(), + ..Default::default() + }; + + let event_name = delete_event_name_for_marker(delete_marker); + + helper = helper.event_name(event_name); + helper = helper.object(obj_info).version_id(response_version_id.unwrap_or_default()); + + let result = Ok(S3Response::new(output)); + // Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead) + let manager = get_capacity_manager(); + manager.record_write_operation().await; + let _ = helper.complete(&result); + rustfs_scanner::record_dirty_usage_bucket(&bucket); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderValue, Method}; + use s3s::dto::{ + Delete, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination, + ExistingObjectReplication, ExistingObjectReplicationStatus, ObjectIdentifier, ReplicaModifications, + ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, SourceSelectionCriteria, + }; + use std::sync::Arc; + + #[test] + fn delete_response_version_id_preserves_null_and_synthetic_semantics() { + let version_id = Uuid::new_v4(); + + assert_eq!(delete_response_version_id(Some(version_id), false), Some(version_id.to_string())); + assert_eq!(delete_response_version_id(Some(Uuid::nil()), false), Some("null".to_string())); + assert_eq!(delete_response_version_id(Some(Uuid::nil()), true), None); + assert_eq!(delete_response_version_id(None, false), None); + } + + #[tokio::test] + async fn execute_delete_object_rejects_invalid_object_key() { + let input = DeleteObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("bad\0key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::DELETE); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_delete_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn delete_not_found_completes_noop_event_with_version_context() { + temp_env::with_var(rustfs_config::ENV_NOTIFY_ENABLE, Some("true"), || { + crate::server::refresh_notify_module_enabled(); + for (version_id, expected_version) in [(None, ""), (Some("requested-version".to_string()), "requested-version")] { + let input = DeleteObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("missing-key".to_string()) + .version_id(version_id.clone()) + .build() + .expect("delete input should build"); + let mut req = build_request(input, Method::DELETE); + req.extensions.insert(crate::storage::access::ReqInfo { + bucket: Some("test-bucket".to_string()), + object: Some("missing-key".to_string()), + version_id: version_id.clone(), + ..Default::default() + }); + let helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObject); + + let (result, helper) = + complete_delete_noop(helper, "test-bucket".to_string(), "missing-key".to_string(), version_id); + let event = helper.event_args().expect("successful no-op delete should retain an event"); + + assert_eq!(result.expect("no-op delete should succeed").status, Some(StatusCode::NO_CONTENT)); + assert_eq!(event.event_name, EventName::ObjectRemovedNoOP); + assert_eq!(event.bucket_name, "test-bucket"); + assert_eq!(event.object.name, "missing-key"); + assert_eq!(event.version_id, expected_version); + } + }); + crate::server::refresh_notify_module_enabled(); + } + + #[test] + fn undo_delete_requires_version_id_to_match_expected_current() { + let expected = Uuid::new_v4().to_string(); + assert!(validate_undo_delete_version(Some(&expected), Some(&expected)).is_ok()); + assert_eq!( + validate_undo_delete_version(Some(&expected), Some(&Uuid::new_v4().to_string())) + .unwrap_err() + .code(), + &S3ErrorCode::PreconditionFailed + ); + assert_eq!( + validate_undo_delete_version(Some(&expected), None).unwrap_err().code(), + &S3ErrorCode::PreconditionFailed + ); + assert!(validate_undo_delete_version(None, None).is_ok()); + } + + #[tokio::test] + async fn execute_delete_objects_rejects_empty_object_list() { + let input = DeleteObjectsInput::builder() + .bucket("test-bucket".to_string()) + .delete(Delete { + objects: vec![], + quiet: None, + }) + .build() + .unwrap(); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_delete_objects(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[tokio::test] + async fn execute_delete_objects_rejects_more_than_one_thousand_objects_before_store_lookup() { + let objects = (0..1001) + .map(|idx| ObjectIdentifier { + key: format!("test-key-{idx}"), + version_id: None, + ..Default::default() + }) + .collect(); + let input = DeleteObjectsInput::builder() + .bucket("test-bucket".to_string()) + .delete(Delete { objects, quiet: None }) + .build() + .expect("delete objects input should build"); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_delete_objects(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[tokio::test] + async fn execute_delete_objects_returns_internal_error_when_store_uninitialized() { + let input = DeleteObjectsInput::builder() + .bucket("test-bucket".to_string()) + .delete(Delete { + objects: vec![ObjectIdentifier { + key: "test-key".to_string(), + version_id: None, + ..Default::default() + }], + quiet: None, + }) + .build() + .unwrap(); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_delete_objects(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InternalError); + assert_eq!(err.message(), Some("Not init")); + } + + #[tokio::test] + #[serial_test::serial] + async fn execute_delete_objects_rejects_bucket_recreated_after_authorization() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; + + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let context = current_app_context().expect("delete objects generation test requires an AppContext"); + let bucket = format!("delete-objects-generation-{}", Uuid::new_v4()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create authorized bucket generation"); + let mut reader = PutObjReader::from_vec(b"old generation".to_vec()); + store + .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) + .await + .expect("put old-generation object"); + + let policy_json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:DeleteObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"# + ); + let mut metadata = (*crate::storage::get_bucket_metadata(&bucket) + .await + .expect("authorized bucket metadata should be cached")) + .clone(); + metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse")); + metadata.policy_config_json = policy_json.into_bytes(); + crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata) + .await + .expect("publish test bucket policy"); + + let input = DeleteObjectsInput::builder() + .bucket(bucket.clone()) + .delete(Delete { + objects: vec![ObjectIdentifier { + key: "object".to_string(), + version_id: None, + ..Default::default() + }], + quiet: None, + }) + .build() + .expect("delete objects input should build"); + let mut req = build_request(input, Method::POST); + req.extensions.insert(crate::storage::access::ReqInfo::default()); + let loaded = Arc::new(tokio::sync::Barrier::new(2)); + let resume = Arc::new(tokio::sync::Barrier::new(2)); + install_delete_objects_auth_test_hook(bucket.clone(), Arc::clone(&loaded), Arc::clone(&resume)); + + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let delete = tokio::spawn(async move { usecase.execute_delete_objects(req).await }); + loaded.wait().await; + + store + .delete_bucket( + &bucket, + &DeleteBucketOptions { + force: true, + ..Default::default() + }, + ) + .await + .expect("delete authorized bucket generation"); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("recreate same bucket name"); + let mut reader = PutObjReader::from_vec(b"new generation".to_vec()); + store + .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) + .await + .expect("put new-generation object"); + resume.wait().await; + + let err = delete + .await + .expect("delete objects task should join") + .expect_err("old authorization must not delete from the recreated bucket"); + assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket); + store + .get_object_info(&bucket, "object", &ObjectOptions::default()) + .await + .expect("new-generation object must survive the stale batch request"); + } + + #[tokio::test] + async fn execute_delete_object_allows_non_force_request_without_req_info_until_store_lookup() { + let input = DeleteObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let err = DefaultObjectUsecase::without_context() + .execute_delete_object(build_request(input, Method::DELETE)) + .await + .expect_err("an uninitialized store should be reported after non-force admission"); + assert_eq!(err.code(), &S3ErrorCode::InternalError); + assert_eq!(err.message(), Some("Not init")); + } + + #[test] + fn delete_objects_audit_details_include_only_successful_request_entries() { + let requested = vec![ + ObjectIdentifier { + key: "first-key".to_string(), + version_id: None, + ..Default::default() + }, + ObjectIdentifier { + key: "denied-key".to_string(), + version_id: Some(Uuid::new_v4().to_string()), + ..Default::default() + }, + ObjectIdentifier { + key: "versioned-key".to_string(), + version_id: Some("requested-version".to_string()), + ..Default::default() + }, + ]; + + let objects = successful_delete_audit_objects( + &Delete { + objects: requested, + quiet: Some(true), + }, + [true, false, true], + ); + + assert_eq!( + objects, + vec![ + AuditObjectVersion::new("first-key".to_string(), None), + AuditObjectVersion::new("versioned-key".to_string(), Some("requested-version".to_string())), + ] + ); + } + + #[test] + fn delete_objects_audit_details_are_empty_when_every_entry_fails() { + let requested = vec![ObjectIdentifier { + key: "failed-key".to_string(), + version_id: None, + ..Default::default() + }]; + + assert!( + successful_delete_audit_objects( + &Delete { + objects: requested, + quiet: None, + }, + [false] + ) + .is_empty() + ); + } + + #[test] + fn normalize_delete_objects_version_id_preserves_explicit_null_marker() { + let (wire_version_id, internal_version_id) = + normalize_delete_objects_version_id(Some("null".to_string())).expect("null version marker should parse"); + + assert_eq!(wire_version_id.as_deref(), Some("null")); + assert_eq!(internal_version_id, Some(Uuid::nil())); + + let (wire_version_id, internal_version_id) = + normalize_delete_objects_version_id(Some(" \t ".to_string())).expect("empty version marker should normalize"); + assert_eq!(wire_version_id, None); + assert_eq!(internal_version_id, None); + } + + #[test] + fn delete_objects_treats_raw_io_not_found_as_idempotent() { + assert!(is_delete_objects_not_found(&StorageError::FileNotFound)); + assert!(is_delete_objects_not_found(&StorageError::Io(std::io::Error::from( + std::io::ErrorKind::NotFound, + )))); + assert!(!is_delete_objects_not_found(&StorageError::Io(std::io::Error::from( + std::io::ErrorKind::PermissionDenied, + )))); + assert!(!is_delete_objects_not_found(&StorageError::DiskNotFound)); + } + + #[test] + fn delete_objects_result_reducer_reports_raw_not_found_as_deleted() { + let object = ObjectToDelete { + object_name: "missing-key".to_string(), + ..Default::default() + }; + let deleted = StorageDeletedObject { + object_name: object.object_name.clone(), + ..Default::default() + }; + let error = StorageError::Io(std::io::Error::from(std::io::ErrorKind::NotFound)); + + let deleted = reduce_delete_objects_result(&object, &deleted, Some(&error), false) + .expect("raw not-found must produce a deleted result"); + assert_eq!(deleted.object_name, "missing-key"); + } + + #[test] + fn recursive_force_delete_requires_administrative_or_replica_context() { + let mut headers = HeaderMap::new(); + headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); + + assert!(!recursive_force_delete_is_authorized(&headers, false, false)); + assert!(recursive_force_delete_is_authorized(&headers, true, false)); + assert!(recursive_force_delete_is_authorized(&headers, false, true)); + assert!(recursive_force_delete_is_authorized(&HeaderMap::new(), false, false)); + } + + #[tokio::test] + async fn execute_delete_object_rejects_untrusted_force_delete_before_store_access() { + let input = DeleteObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("prefix/object".to_string()) + .build() + .unwrap(); + let mut req = build_request(input, Method::DELETE); + req.headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); + req.extensions.insert(crate::storage::access::ReqInfo::default()); + + let err = DefaultObjectUsecase::without_context() + .execute_delete_object(req) + .await + .expect_err("untrusted force-delete must be rejected before storage lookup"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + #[tokio::test] + async fn execute_delete_objects_rejects_untrusted_force_delete_before_store_access() { + let input = DeleteObjectsInput::builder() + .bucket("test-bucket".to_string()) + .delete(Delete { + objects: vec![ObjectIdentifier { + key: "prefix/object".to_string(), + version_id: None, + ..Default::default() + }], + quiet: None, + }) + .build() + .unwrap(); + let mut req = build_request(input, Method::POST); + req.headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); + req.extensions.insert(crate::storage::access::ReqInfo::default()); + + let err = DefaultObjectUsecase::without_context() + .execute_delete_objects(req) + .await + .expect_err("untrusted force-delete must be rejected before storage lookup"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + // backlog#929 (HP-8): the pre-delete stat may only be skipped when every + // consumer of its result is provably idle. Each guard flips one condition + // to prove the skip is fenced on all four data dependencies. + fn delete_marker_creating_opts() -> ObjectOptions { + ObjectOptions { + version_id: None, + versioned: true, + version_suspended: false, + ..Default::default() + } + } + + #[test] + fn delete_objects_pre_stat_skippable_for_delete_marker_on_plain_bucket() { + assert!(can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), true)); + } + + #[test] + fn delete_objects_pre_stat_kept_for_object_lock_buckets() { + assert!(!can_skip_delete_objects_pre_stat(true, &delete_marker_creating_opts(), true)); + } + + #[test] + fn delete_objects_pre_stat_kept_for_explicit_version_deletes() { + let opts = ObjectOptions { + version_id: Some(Uuid::new_v4().to_string()), + versioned: true, + version_suspended: false, + ..Default::default() + }; + assert!(!can_skip_delete_objects_pre_stat(false, &opts, true)); + } + + #[test] + fn delete_objects_pre_stat_kept_for_unversioned_buckets() { + // Unversioned deletes remove the current object: usage accounting needs + // the object size and ILM tier cleanup needs the transition metadata. + let opts = ObjectOptions { + version_id: None, + versioned: false, + version_suspended: false, + ..Default::default() + }; + assert!(!can_skip_delete_objects_pre_stat(false, &opts, false)); + } + + #[test] + fn delete_objects_pre_stat_kept_for_suspended_versioning() { + let opts = ObjectOptions { + version_id: None, + versioned: true, + version_suspended: true, + ..Default::default() + }; + assert!(!can_skip_delete_objects_pre_stat(false, &opts, false)); + } + + #[test] + fn delete_objects_pre_stat_kept_when_accounting_snapshot_disagrees() { + // If the accounting-side versioning snapshot does not also classify the + // delete as a delete-marker creation, the stat must stay so usage + // accounting keeps its size input. + assert!(!can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), false)); + } + + #[test] + fn delete_accounting_recognizes_explicit_null_as_current_object() { + let opts = ObjectOptions { + version_id: Some(Uuid::nil().to_string()), + version_suspended: true, + ..Default::default() + }; + assert!(delete_removes_current_object(&opts)); + assert!(delete_request_targets_current(Some(Uuid::nil()))); + assert!(!delete_request_targets_current(Some(Uuid::new_v4()))); + assert!(!delete_removes_current_object(&ObjectOptions { + version_id: Some(Uuid::new_v4().to_string()), + ..Default::default() + })); + } + + #[test] + fn compressed_object_delete_restores_usage_baseline() { + let mut metadata = HashMap::new(); + insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string()); + let object = ObjectInfo { + size: 400, + actual_size: 1000, + user_defined: Arc::new(metadata), + ..Default::default() + }; + let accounting_size = quota_object_size(&object).expect("logical compressed size should be canonical"); + + assert_eq!( + delete_memory_update(false, false, true, Some(accounting_size), true), + Some(DeleteMemoryUpdate::Object { + size: 1000, + removed_current_object: true, + }) + ); + } + + #[test] + fn invalid_accounting_metadata_is_reconciled_without_overflow() { + assert_eq!(delete_memory_update(false, false, true, None, true), None); + assert_eq!( + delete_memory_update(false, true, true, None, true), + Some(DeleteMemoryUpdate::DeleteMarker) + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn compressed_delete_requests_update_observed_usage_without_releasing_quota_floor() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; + use crate::app::storage_api::test::data_usage::apply_bucket_usage_memory_overlay; + + async fn observed_bucket_usage(bucket: &str) -> Option { + let mut usage = rustfs_data_usage::DataUsageInfo::default(); + apply_bucket_usage_memory_overlay(&mut usage).await; + usage.buckets_usage.get(bucket).map(|value| value.size) + } + + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let bucket = format!("compressed-delete-request-{}", Uuid::new_v4().simple()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create compressed delete request bucket"); + + // Seed the process-local usage with the canonical logical bytes. The + // direct storage PUT below intentionally does not apply an app-layer + // usage delta; the two real DELETE requests must remove exactly this + // amount through their request-layer wiring. + crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 2_000).await; + + for object in ["single", "batch"] { + let mut metadata = HashMap::new(); + insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string()); + insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, "1000".to_string()); + let reader = HashReader::from_stream(std::io::Cursor::new(vec![0x5a; 400]), 400, 1000, None, None, false) + .expect("compressed fixture reader should be valid"); + let mut reader = PutObjReader::new(reader); + store + .put_object( + &bucket, + object, + &mut reader, + &ObjectOptions { + user_defined: metadata, + ..Default::default() + }, + ) + .await + .expect("compressed fixture object should be written"); + } + + let mut single_req = build_request( + DeleteObjectInput::builder() + .bucket(bucket.clone()) + .key("single".to_string()) + .build() + .expect("single delete input should build"), + Method::DELETE, + ); + single_req.extensions.insert(crate::storage::access::ReqInfo { + cred: Some(rustfs_credentials::Credentials::default()), + is_owner: true, + ..Default::default() + }); + DefaultObjectUsecase::from_global() + .execute_delete_object(single_req) + .await + .expect("single compressed delete should succeed"); + assert_eq!( + observed_bucket_usage(&bucket).await, + Some(1_000), + "single delete must subtract the logical accounting size" + ); + assert_eq!( + crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await, + Some(2_000), + "quota must retain the pre-delete floor until scanner reconciliation" + ); + + let mut batch_req = build_request( + DeleteObjectsInput::builder() + .bucket(bucket.clone()) + .delete(Delete { + objects: vec![ObjectIdentifier { + key: "batch".to_string(), + ..Default::default() + }], + quiet: None, + }) + .build() + .expect("batch delete input should build"), + Method::POST, + ); + batch_req.extensions.insert(crate::storage::access::ReqInfo { + cred: Some(rustfs_credentials::Credentials::default()), + is_owner: true, + ..Default::default() + }); + DefaultObjectUsecase::from_global() + .execute_delete_objects(batch_req) + .await + .expect("batch compressed delete should succeed"); + assert_eq!( + observed_bucket_usage(&bucket).await, + Some(0), + "batch delete must subtract the committed logical accounting size" + ); + assert_eq!( + crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await, + Some(2_000), + "quota must retain both pending deletes until scanner reconciliation" + ); + + store + .delete_bucket( + &bucket, + &DeleteBucketOptions { + force: true, + ..Default::default() + }, + ) + .await + .expect("clean up compressed delete request bucket"); + } + + #[test] + fn delete_replication_state_from_config_tracks_downstream_delete_marker_targets() { + let arn = "arn:aws:s3:::target-bucket".to_string(); + let config = ReplicationConfiguration { + role: arn.clone(), + rules: vec![ReplicationRule { + delete_marker_replication: Some(DeleteMarkerReplication { + status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), + }), + delete_replication: None, + destination: Destination { + bucket: arn.clone(), + ..Default::default() + }, + existing_object_replication: Some(ExistingObjectReplication { + status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), + }), + filter: None, + id: Some("rule-1".to_string()), + prefix: Some("test/".to_string()), + priority: Some(1), + source_selection_criteria: Some(SourceSelectionCriteria { + replica_modifications: Some(ReplicaModifications { + status: ReplicaModificationsStatus::from_static(ReplicaModificationsStatus::ENABLED), + }), + sse_kms_encrypted_objects: None, + }), + status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), + }], + }; + let obj_info = ObjectInfo { + bucket: "bucket".to_string(), + name: "test/object.txt".to_string(), + delete_marker: true, + replication_status: ReplicationStatusType::Replica, + ..Default::default() + }; + + let state = delete_replication_state_from_config(&config, &obj_info, None, true) + .expect("replica delete marker should be forwarded to downstream targets"); + let pending = format!("{arn}=PENDING;"); + + assert_eq!(state.replication_status_internal.as_deref(), Some(pending.as_str())); + assert_eq!(state.replicate_decision_str, format!("{arn}=true;false;{arn};")); + assert!(state.targets.contains_key(&arn)); + } + + #[test] + fn delete_replication_state_from_config_skips_replica_delete_without_replica_modifications() { + let arn = "arn:aws:s3:::target-bucket".to_string(); + let config = ReplicationConfiguration { + role: arn.clone(), + rules: vec![ReplicationRule { + delete_marker_replication: Some(DeleteMarkerReplication { + status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), + }), + delete_replication: None, + destination: Destination { + bucket: arn, + ..Default::default() + }, + existing_object_replication: Some(ExistingObjectReplication { + status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), + }), + filter: None, + id: Some("rule-1".to_string()), + prefix: Some("test/".to_string()), + priority: Some(1), + source_selection_criteria: None, + status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), + }], + }; + let obj_info = ObjectInfo { + bucket: "bucket".to_string(), + name: "test/object.txt".to_string(), + delete_marker: true, + replication_status: ReplicationStatusType::Replica, + ..Default::default() + }; + + assert!( + delete_replication_state_from_config(&config, &obj_info, None, true).is_none(), + "replica deletes must only fan out when ReplicaModifications are enabled" + ); + } + + #[test] + fn delete_replication_state_from_config_requires_delete_switch_for_marker_version_purges() { + let arn = "arn:aws:s3:::target-bucket".to_string(); + let mut config = ReplicationConfiguration { + role: arn.clone(), + rules: vec![ReplicationRule { + delete_marker_replication: Some(DeleteMarkerReplication { + status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), + }), + delete_replication: None, + destination: Destination { + bucket: arn.clone(), + ..Default::default() + }, + existing_object_replication: Some(ExistingObjectReplication { + status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), + }), + filter: None, + id: Some("rule-1".to_string()), + prefix: Some("test/".to_string()), + priority: Some(1), + source_selection_criteria: None, + status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), + }], + }; + let obj_info = ObjectInfo { + bucket: "bucket".to_string(), + name: "test/object.txt".to_string(), + delete_marker: true, + replication_status: ReplicationStatusType::Completed, + ..Default::default() + }; + + let version_id = Some(Uuid::new_v4()); + assert!( + delete_replication_state_from_config(&config, &obj_info, version_id, false).is_none(), + "delete-marker version purge must not use DeleteMarkerReplication" + ); + + config.rules[0].delete_replication = Some(DeleteReplication { + status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED), + }); + let state = delete_replication_state_from_config(&config, &obj_info, version_id, false) + .expect("delete-marker version purge should honor DeleteReplication"); + let pending = format!("{arn}=PENDING;"); + + assert_eq!(state.version_purge_status_internal.as_deref(), Some(pending.as_str())); + assert_eq!(state.replicate_decision_str, format!("{arn}=true;false;{arn};")); + assert!(state.purge_targets.contains_key(&arn)); + } + + #[test] + fn replica_delete_enrichment_must_not_reuse_upstream_targets() { + let upstream_state = ReplicationState { + replicate_decision_str: "arn:aws:s3:::upstream=true;false;arn:aws:s3:::upstream;".to_string(), + replication_status_internal: Some("arn:aws:s3:::upstream=COMPLETED;".to_string()), + targets: replication_statuses_map("arn:aws:s3:::upstream=COMPLETED;"), + ..Default::default() + }; + let mut delete_object = StorageDeletedObject::default(); + set_deleted_object_replication_state(&mut delete_object, &upstream_state); + let obj_info = ObjectInfo { + replication_status: ReplicationStatusType::Replica, + ..Default::default() + }; + + let should_keep_existing = delete_object.replication_state.as_ref().is_some_and(|state| { + obj_info.replication_status != ReplicationStatusType::Replica + && !state.replicate_decision_str.is_empty() + && (!state.targets.is_empty() || !state.purge_targets.is_empty()) + }); + + assert!( + !should_keep_existing, + "replica fanout deletes must recompute targets from the local bucket config instead of reusing upstream replication state" + ); + } + + #[test] + fn delete_replication_version_id_uses_none_for_delete_marker_creation() { + let source = ObjectInfo { + delete_marker: true, + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + + assert_eq!( + delete_replication_version_id(&source, false), + None, + "delete-marker creation must stay on the delete-marker replication path" + ); + } + + #[test] + fn delete_replication_version_id_keeps_version_for_marker_purge() { + let version_id = Uuid::new_v4(); + let source = ObjectInfo { + delete_marker: true, + version_id: Some(version_id), + ..Default::default() + }; + + assert_eq!( + delete_replication_version_id(&source, true), + Some(version_id), + "delete-marker version purge must preserve the concrete version id for downstream purge replication" + ); + } + + #[test] + fn should_use_existing_delete_replication_info_ignores_replication_delete_marker_creation() { + let opts = ObjectOptions { + version_id: Some(Uuid::new_v4().to_string()), + delete_marker: true, + ..Default::default() + }; + + assert!( + !should_use_existing_delete_replication_info(&opts, true), + "replicated delete-marker creation carries a source version id header but must not be treated as a version purge" + ); + } + + #[test] + fn should_use_existing_delete_replication_info_keeps_version_delete_requests() { + let opts = ObjectOptions { + version_id: Some(Uuid::new_v4().to_string()), + ..Default::default() + }; + + assert!( + should_use_existing_delete_replication_info(&opts, true), + "true version-delete requests should keep using the pre-delete object info" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn durable_quota_reclaims_overwrites_and_deleted_bytes() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("quota-delta-reconcile", 4096).await; + + for byte in [0x41, 0x42] { + let mut reader = PutObjReader::from_vec(vec![byte; 4096]); + store + .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) + .await + .expect("same-size overwrite must consume no additional quota"); + } + + store + .delete_object(&bucket, "object", ObjectOptions::default()) + .await + .expect("delete quota-tracked object"); + let mut replacement = PutObjReader::from_vec(vec![0x43; 4096]); + store + .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) + .await + .expect("deleted bytes must be reclaimed before rejecting a replacement"); + + let mut excess = PutObjReader::from_vec(vec![0x44]); + let err = store + .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) + .await + .expect_err("one byte beyond the reclaimed exact quota must be denied"); + assert!(matches!( + err, + StorageError::QuotaExceeded { + current: 4096, + limit: 4096 + } + )); + } +} diff --git a/rustfs/src/app/object/extract.rs b/rustfs/src/app/object/extract.rs new file mode 100644 index 000000000..1a4cbcc4c --- /dev/null +++ b/rustfs/src/app/object/extract.rs @@ -0,0 +1,1506 @@ +// 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. + +//! Snowball auto-extract (PutObject x-amz-meta-snowball-auto-extract) path. + +use super::*; + +fn ensure_legacy_archive_size_within_quota(result: &QuotaCheckResult, total_unpacked_size: u64) -> S3Result<()> { + if result.uses_durable_reservations { + return Ok(()); + } + let (Some(current_usage), Some(quota_limit)) = (result.current_usage, result.quota_limit) else { + return Ok(()); + }; + let expected_usage = current_usage + .checked_add(total_unpacked_size) + .ok_or_else(|| s3_error!(InvalidArgument, "Archive total size overflowed quota accounting"))?; + if expected_usage > quota_limit { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), + )); + } + Ok(()) +} + +pin_project! { + struct ExtractArchiveEtagReader { + #[pin] + inner: R, + md5: Md5, + finished: bool, + etag: Arc>>, + } +} + +impl ExtractArchiveEtagReader { + fn new(inner: R, etag: Arc>>) -> Self { + Self { + inner, + md5: Md5::new(), + finished: false, + etag, + } + } +} + +impl AsyncRead for ExtractArchiveEtagReader { + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let this = self.project(); + let before = buf.filled().len(); + match this.inner.poll_read(cx, buf) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => { + let filled = &buf.filled()[before..]; + if !filled.is_empty() { + this.md5.update(filled); + } else if !*this.finished { + *this.finished = true; + if let Ok(mut etag) = this.etag.lock() { + *etag = Some(hex_simd::encode_to_string(this.md5.clone().finalize(), hex_simd::AsciiCase::Lower)); + } + } + Poll::Ready(Ok(())) + } + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + } + } +} + +const AMZ_SNOWBALL_EXTRACT_COMPAT: &str = "X-Amz-Snowball-Auto-Extract"; + +#[cfg(test)] +const AMZ_SNOWBALL_PREFIX_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Prefix"; + +#[cfg(test)] +const AMZ_SNOWBALL_IGNORE_DIRS_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Ignore-Dirs"; + +#[cfg(test)] +const AMZ_SNOWBALL_IGNORE_ERRORS_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Ignore-Errors"; + +const AMZ_META_PREFIX_LOWER: &str = "x-amz-meta-"; + +const SNOWBALL_PREFIX_SUFFIX_LOWER: &str = "snowball-prefix"; + +const SNOWBALL_IGNORE_DIRS_SUFFIX_LOWER: &str = "snowball-ignore-dirs"; + +const SNOWBALL_IGNORE_ERRORS_SUFFIX_LOWER: &str = "snowball-ignore-errors"; + +const SNOWBALL_PREFIX_HEADER_KEYS: &[&str] = &[AMZ_MINIO_SNOWBALL_PREFIX, AMZ_SNOWBALL_PREFIX, AMZ_RUSTFS_SNOWBALL_PREFIX]; + +const SNOWBALL_IGNORE_DIRS_HEADER_KEYS: &[&str] = &[ + AMZ_MINIO_SNOWBALL_IGNORE_DIRS, + AMZ_SNOWBALL_IGNORE_DIRS, + AMZ_RUSTFS_SNOWBALL_IGNORE_DIRS, +]; + +const SNOWBALL_IGNORE_ERRORS_HEADER_KEYS: &[&str] = &[ + AMZ_MINIO_SNOWBALL_IGNORE_ERRORS, + AMZ_SNOWBALL_IGNORE_ERRORS, + AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, +]; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct PutObjectExtractOptions { + prefix: Option, + ignore_dirs: bool, + ignore_errors: bool, +} + +fn header_value_is_true(headers: &HeaderMap, key: &str) -> bool { + headers + .get(key) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case("true")) +} + +pub(super) fn is_put_object_extract_requested(headers: &HeaderMap) -> bool { + header_value_is_true(headers, AMZ_SNOWBALL_EXTRACT) || header_value_is_true(headers, AMZ_SNOWBALL_EXTRACT_COMPAT) +} + +fn trimmed_header_value(headers: &HeaderMap, key: &str) -> Option { + headers + .get(key) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim().to_string()) +} + +fn is_exact_snowball_meta_key(key: &str, exact_keys: &[&str]) -> bool { + exact_keys.iter().any(|exact_key| key.eq_ignore_ascii_case(exact_key)) +} + +fn snowball_meta_value_by_suffix(headers: &HeaderMap, suffix_lower: &str, exact_keys: &[&str]) -> Option { + for (name, value) in headers { + let key = name.as_str(); + if key.starts_with(AMZ_META_PREFIX_LOWER) + && key.ends_with(suffix_lower) + && !is_exact_snowball_meta_key(key, exact_keys) + && let Ok(parsed) = value.to_str() + { + return Some(parsed.trim().to_string()); + } + } + + None +} + +fn snowball_meta_value(headers: &HeaderMap, exact_keys: &[&str], suffix_lower: &str) -> Option { + for key in exact_keys { + if let Some(value) = trimmed_header_value(headers, key) { + return Some(value); + } + } + + snowball_meta_value_by_suffix(headers, suffix_lower, exact_keys) +} + +fn snowball_meta_flag(headers: &HeaderMap, exact_keys: &[&str], suffix_lower: &str) -> bool { + snowball_meta_value(headers, exact_keys, suffix_lower).is_some_and(|value| value.eq_ignore_ascii_case("true")) +} + +/// Validates that an archive entry path does not escape the target bucket. +/// +/// Delegates to [`rustfs_utils::path::validate_extract_relative_path`] and wraps +/// the result as an S3 error on failure. +pub fn validate_extract_relative_path(path: &str) -> S3Result<()> { + rustfs_utils::path::validate_extract_relative_path(path).map_err(|msg| s3_error!(InvalidArgument, "{msg}")) +} + +fn normalize_snowball_prefix(prefix: &str) -> S3Result> { + let normalized = prefix.trim().trim_matches('/'); + if normalized.is_empty() { + return Ok(None); + } + + validate_extract_relative_path(normalized)?; + + Ok(Some(normalized.to_string())) +} + +/// Normalizes an archive entry key by applying a prefix, trimming slashes, +/// and ensuring directory entries end with `/`. +/// +/// Delegates to [`rustfs_utils::path::normalize_extract_entry_key`] and wraps +/// the result as an S3 error on failure. +pub fn normalize_extract_entry_key(path: &str, prefix: Option<&str>, is_dir: bool) -> S3Result { + rustfs_utils::path::normalize_extract_entry_key(path, prefix, is_dir).map_err(|msg| s3_error!(InvalidArgument, "{msg}")) +} + +fn map_extract_archive_error(err: impl std::fmt::Display) -> S3Error { + s3_error!(InvalidArgument, "Failed to process archive entry: {}", err) +} + +#[derive(Debug, Default)] +struct ExtractEntryPaxAuthorization { + headers: HeaderMap, + object_lock_legal_hold_status: Option, + object_lock_mode: Option, + object_lock_retain_until_date: Option, +} + +async fn apply_extract_entry_pax_extensions( + entry: &mut tokio_tar::Entry>, + bucket: &str, + object_name: &str, + object_lock_config_state: &metadata_sys::ObjectLockConfigState, + metadata: &mut HashMap, + opts: &mut ObjectOptions, +) -> S3Result +where + R: AsyncRead + Send + Unpin + 'static, +{ + let Some(extensions) = entry.pax_extensions().await.map_err(map_extract_archive_error)? else { + return Ok(ExtractEntryPaxAuthorization::default()); + }; + + let mut pax_headers = HeaderMap::new(); + let mut pax_version_id = None; + for ext in extensions { + let ext = ext.map_err(map_extract_archive_error)?; + let key = ext.key().map_err(map_extract_archive_error)?; + let value = ext.value().map_err(map_extract_archive_error)?; + + if let Some(meta_key) = key.strip_prefix("minio.metadata.") { + if !meta_key.is_empty() { + let name = http::HeaderName::from_bytes(meta_key.as_bytes()) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata header"))?; + let header_value = HeaderValue::from_str(value) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata value"))?; + preserve_unclassified_user_metadata(metadata, name.as_str(), value); + pax_headers.insert(name, header_value); + } + continue; + } + + if key == "minio.versionId" && !value.is_empty() { + if Uuid::parse_str(value).is_err() { + return Err(s3_error!(InvalidArgument, "Invalid Snowball PAX version ID")); + } + pax_version_id = Some(value.to_string()); + } + } + + let has_replica_status = pax_headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS); + if let Some(value) = pax_headers.get(AMZ_BUCKET_REPLICATION_STATUS) { + let status = value + .to_str() + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball replication status"))?; + if !status.eq_ignore_ascii_case(ReplicationStatusType::Replica.as_str()) { + return Err(s3_error!(InvalidArgument, "Invalid Snowball replication status")); + } + pax_headers.insert(AMZ_BUCKET_REPLICATION_STATUS, HeaderValue::from_static("REPLICA")); + } + + let authorization_headers = pax_headers.clone(); + + if let Some(value) = pax_headers.remove("x-amz-tagging") { + let value = value + .to_str() + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball object tagging value"))?; + metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), value.to_owned()); + } + + let object_lock_mode = pax_headers + .remove(AMZ_OBJECT_LOCK_MODE_LOWER) + .map(|value| { + value + .to_str() + .map(|value| ObjectLockMode::from(value.to_string())) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock mode")) + }) + .transpose()?; + let object_lock_retain_until_date = pax_headers + .remove(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) + .map(|value| { + let value = value + .to_str() + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date"))?; + OffsetDateTime::parse(value, &Rfc3339) + .map(Timestamp::from) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date")) + }) + .transpose()?; + let object_lock_legal_hold_status = pax_headers + .remove(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) + .map(|value| { + value + .to_str() + .map(|value| ObjectLockLegalHoldStatus::from(value.to_string())) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock legal-hold status")) + }) + .transpose()?; + opts.version_id = pax_version_id; + + extract_metadata_from_mime_with_object_name(&pax_headers, metadata, false, Some(object_name)); + if has_replica_status { + metadata.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)); + metadata.insert( + AMZ_BUCKET_REPLICATION_STATUS.to_string(), + ReplicationStatusType::Replica.as_str().to_string(), + ); + } + if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( + bucket, + object_lock_config_state, + object_lock_legal_hold_status.clone(), + object_lock_mode.clone(), + object_lock_retain_until_date.clone(), + )? { + metadata.extend(object_lock_metadata); + } + + Ok(ExtractEntryPaxAuthorization { + headers: authorization_headers, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + }) +} + +fn resolve_put_object_extract_options(headers: &HeaderMap) -> S3Result { + let prefix = snowball_meta_value(headers, SNOWBALL_PREFIX_HEADER_KEYS, SNOWBALL_PREFIX_SUFFIX_LOWER) + .map(|value| normalize_snowball_prefix(&value)) + .transpose()? + .flatten(); + let ignore_dirs = snowball_meta_flag(headers, SNOWBALL_IGNORE_DIRS_HEADER_KEYS, SNOWBALL_IGNORE_DIRS_SUFFIX_LOWER); + let ignore_errors = snowball_meta_flag(headers, SNOWBALL_IGNORE_ERRORS_HEADER_KEYS, SNOWBALL_IGNORE_ERRORS_SUFFIX_LOWER); + + Ok(PutObjectExtractOptions { + prefix, + ignore_dirs, + ignore_errors, + }) +} + +fn put_object_extract_limits() -> ArchiveLimits { + ArchiveLimits::default() +} + +fn validate_put_object_extract_entry_count(count: usize, limits: ArchiveLimits) -> S3Result<()> { + if count > limits.max_entries { + return Err(s3_error!( + InvalidArgument, + "Archive entry count exceeds limit: count={}, limit={}", + count, + limits.max_entries + )); + } + Ok(()) +} + +fn validate_put_object_extract_entry_size(path: &str, size: u64, limits: ArchiveLimits) -> S3Result<()> { + if size > limits.max_entry_size { + return Err(s3_error!( + InvalidArgument, + "Archive entry size exceeds limit for {}: size={}, limit={}", + path, + size, + limits.max_entry_size + )); + } + Ok(()) +} + +fn validate_put_object_extract_total_size(total_size: u64, limits: ArchiveLimits) -> S3Result<()> { + if total_size > limits.max_total_unpacked_size { + return Err(s3_error!( + InvalidArgument, + "Archive total unpacked size exceeds limit: size={}, limit={}", + total_size, + limits.max_total_unpacked_size + )); + } + Ok(()) +} + +fn validate_put_object_extract_entry_path(path: &str, limits: ArchiveLimits) -> S3Result<()> { + if path.len() > limits.max_path_length { + return Err(s3_error!( + InvalidArgument, + "Archive entry path exceeds limit for {}: length={}, limit={}", + path, + path.len(), + limits.max_path_length + )); + } + Ok(()) +} + +impl DefaultObjectUsecase { + #[instrument(level = "debug", skip(self, req))] + #[hotpath::measure(impl_type = "DefaultObjectUsecase")] + pub async fn execute_put_object_extract(&self, req: S3Request) -> S3Result> { + self.execute_put_object_extract_boxed(req).await + } + + fn execute_put_object_extract_boxed( + &self, + req: S3Request, + ) -> impl std::future::Future>> + Send + '_ { + Box::pin(self.execute_put_object_extract_inner(req)) + } + + async fn execute_put_object_extract_inner(&self, req: S3Request) -> S3Result> { + let helper = OperationHelper::new(&req, EventName::ObjectCreatedPut, S3Operation::PutObject).suppress_event(); + let request_context = helper.request_context_or_from_request(&req); + let auth_method = req.method.clone(); + let auth_uri = req.uri.clone(); + let auth_headers = req.headers.clone(); + let auth_extensions = req.extensions.clone(); + let auth_credentials = req.credentials.clone(); + let auth_region = req.region.clone(); + let auth_service = req.service.clone(); + let auth_trailing_headers = req.trailing_headers.clone(); + // Extract uploads reject SSE-KMS before reaching the SSE layer, so the principal is + // only carried for the day that restriction lifts; the NotImplemented answer below + // deliberately stays ahead of any key authorization. + let extract_principal = SseKmsPrincipal::from_request(&req); + if is_sse_kms_requested(&req.input, &req.headers) { + return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for extract uploads")); + } + let replication_authorized = replication_request_authorized(&req); + let mut bucket_generation_opts = ObjectOptions::default(); + apply_bucket_generation_guard(&req, &req.input.bucket, &mut bucket_generation_opts)?; + let expected_bucket_incarnation_id = bucket_generation_opts.expected_bucket_incarnation_id; + let input = req.input; + + let PutObjectInput { + body, + bucket, + key, + version_id, + cache_control, + content_disposition, + content_encoding, + content_length, + content_language, + content_type, + content_md5, + expires, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key, + sse_customer_key_md5, + ssekms_key_id, + storage_class, + tagging, + website_redirect_location, + .. + } = input; + + let event_version_id = version_id; + let (h_algo, h_key, h_md5) = extract_ssec_params_from_headers(&req.headers)?; + let sse_customer_algorithm = sse_customer_algorithm.or(h_algo); + let sse_customer_key = sse_customer_key.or(h_key); + let sse_customer_key_md5 = sse_customer_key_md5.or(h_md5); + + let original_sse = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?); + let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); + let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( + bucket_sse_config.as_ref().map(|(config, _timestamp)| config), + original_sse, + ssekms_key_id, + false, + ); + if effective_sse + .as_ref() + .is_some_and(|sse| sse.as_str().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) + { + return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for extract uploads")); + } + validate_sse_headers_for_write( + effective_sse.as_ref(), + effective_kms_key_id.as_ref(), + extract_ssekms_context_from_headers(&req.headers)?.as_ref(), + sse_customer_algorithm.as_ref(), + sse_customer_key.as_ref(), + sse_customer_key_md5.as_ref(), + true, + )?; + let Some(body) = body else { return Err(s3_error!(IncompleteBody)) }; + + let size = match content_length { + Some(c) => c, + None => { + if let Some(val) = req.headers.get(AMZ_DECODED_CONTENT_LENGTH) { + match atoi::atoi::(val.as_bytes()) { + Some(x) => x, + None => return Err(s3_error!(UnexpectedContent)), + } + } else { + return Err(s3_error!(UnexpectedContent)); + } + } + }; + if size < 0 { + return Err(s3_error!(UnexpectedContent)); + } + validate_object_key(&key, "PUT")?; + validate_table_catalog_object_mutation(&bucket, &key).await?; + let _ = self + .check_bucket_quota( + &bucket, + QuotaOperation::PutObject, + u64::try_from(size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + ) + .await?; + + // Apply adaptive buffer sizing based on file size for optimal streaming performance. + // Uses workload profile configuration (enabled by default) to select appropriate buffer size. + // Buffer sizes range from 32KB to 4MB depending on file size and configured workload profile. + let buffer_size = get_buffer_size_opt_in(size); + let body = + tokio::io::BufReader::with_capacity(buffer_size, StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io)))); + + let Some(ext) = Path::new(&key).extension().and_then(|s| s.to_str()) else { + return Err(s3_error!(InvalidArgument, "key extension not found")); + }; + + let ext = ext.to_owned(); + + let md5hex = if let Some(base64_md5) = content_md5 { + let md5 = base64_simd::STANDARD + .decode_to_vec(base64_md5.as_bytes()) + .map_err(|e| ApiError::from(StorageError::other(format!("Invalid content MD5: {e}"))))?; + Some(hex_simd::encode_to_string(&md5, hex_simd::AsciiCase::Lower)) + } else { + None + }; + + let sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); + let actual_size = size; + + let mut archive_reader = + HashReader::from_stream(body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)?; + + if let Err(err) = archive_reader.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { + return Err(ApiError::from(err).into()); + } + + let archive_etag = Arc::new(Mutex::new(None)); + let decoder = CompressionFormat::from_extension(&ext) + .get_decoder(ExtractArchiveEtagReader::new(archive_reader, archive_etag.clone())) + .map_err(|e| { + error!(error = ?e, "Archive decoder creation failed"); + s3_error!(InvalidArgument, "get_decoder err") + })?; + + let mut ar = Archive::new(decoder); + let mut entries = ar.entries().map_err(|e| { + error!(error = ?e, "Archive entry listing failed"); + s3_error!(InvalidArgument, "get entries err") + })?; + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + + let extract_options = resolve_put_object_extract_options(&req.headers)?; + let extract_limits = put_object_extract_limits(); + let extract_quota_check = if let Some(metadata_sys) = self.bucket_metadata_sys() { + let quota_checker = QuotaChecker::new(metadata_sys); + let check_result = + map_quota_check_outcome(&bucket, quota_checker.check_quota(&bucket, QuotaOperation::PutObject, 0).await)?; + Some(check_result) + } else { + None + }; + let extract_quota_enabled = extract_quota_check + .as_ref() + .is_some_and(|result| result.quota_limit.is_some()); + let version_id = match event_version_id { + Some(v) => v.to_string(), + None => String::new(), + }; + + let notify = current_notify_interface_for_context(self.context.as_deref()); + let req_params = rustfs_targets::extract_params_header(&req.headers); + let host = get_request_host(&req.headers); + let port = get_request_port(&req.headers); + let user_agent = get_request_user_agent(&req.headers); + let mut wrote_any_entry = false; + let mut extracted_entry_count = 0usize; + let mut total_unpacked_size = 0u64; + let object_lock_config_snapshot = store.object_lock_config_snapshot(&bucket).await.map_err(ApiError::from)?; + let object_lock_config_state = object_lock_config_snapshot.state(); + + while let Some(entry) = entries.next().await { + let mut f = match entry { + Ok(f) => f, + Err(e) => { + if extract_options.ignore_errors { + warn!(error = %e, "Archive entry read skipped due to ignore-errors"); + continue; + } + error!(error = %e, "Archive entry read failed"); + return Err(s3_error!(InvalidArgument, "Failed to read archive entry: {:?}", e)); + } + }; + extracted_entry_count = extracted_entry_count.saturating_add(1); + validate_put_object_extract_entry_count(extracted_entry_count, extract_limits)?; + + let fpath = match f.path() { + Ok(path) => path, + Err(e) => { + if extract_options.ignore_errors { + warn!(error = %e, "Archive path decode skipped due to ignore-errors"); + continue; + } + return Err(s3_error!(InvalidArgument, "Failed to decode archive entry path")); + } + }; + + let is_dir = f.header().entry_type().is_dir(); + let fpath = match normalize_extract_entry_key(&fpath.to_string_lossy(), extract_options.prefix.as_deref(), is_dir) { + Ok(fpath) => fpath, + Err(err) => { + if extract_options.ignore_errors { + warn!(error = %err, "Unsafe archive path skipped due to ignore-errors"); + continue; + } + return Err(err); + } + }; + validate_put_object_extract_entry_path(&fpath, extract_limits)?; + validate_table_catalog_object_mutation(&bucket, &fpath).await?; + + let mut auth_req = S3Request { + input: PutObjectInput::default(), + method: auth_method.clone(), + uri: auth_uri.clone(), + headers: auth_headers.clone(), + extensions: auth_extensions.clone(), + credentials: auth_credentials.clone(), + region: auth_region.clone(), + service: auth_service.clone(), + trailing_headers: auth_trailing_headers.clone(), + }; + { + let req_info = req_info_mut(&mut auth_req)?; + req_info.bucket = Some(bucket.clone()); + req_info.object = Some(fpath.clone()); + req_info.version_id = None; + } + let entry_size = f.header().size().unwrap_or_default(); + validate_put_object_extract_entry_size(&fpath, entry_size, extract_limits)?; + total_unpacked_size = total_unpacked_size + .checked_add(entry_size) + .ok_or_else(|| s3_error!(InvalidArgument, "Archive total unpacked size overflowed while processing entries"))?; + validate_put_object_extract_total_size(total_unpacked_size, extract_limits)?; + if let Some(quota_check) = extract_quota_check.as_ref() { + ensure_legacy_archive_size_within_quota(quota_check, total_unpacked_size)?; + } + let mut size = + i64::try_from(entry_size).map_err(|_| s3_error!(InvalidArgument, "Archive entry size does not fit into i64"))?; + // mtime 0 means "unset" in tar headers, and xl.meta cannot represent an + // epoch mod_time anyway (0 nanos decodes as no-mod_time, making the version + // unreadable — rustfs#4842), so fall back to the upload time instead. + let archive_entry_mod_time = f + .header() + .mtime() + .ok() + .filter(|&modified_at_secs| modified_at_secs > 0) + .and_then(|modified_at_secs| OffsetDateTime::from_unix_timestamp(modified_at_secs as i64).ok()); + let mut metadata = HashMap::new(); + let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); + apply_put_request_metadata( + &mut metadata, + &req.headers, + &fpath, + cache_control.clone(), + content_disposition.clone(), + content_encoding.clone(), + content_language.clone(), + content_type.clone(), + expires.clone(), + website_redirect_location.clone(), + tagging.clone(), + storage_class.clone(), + )?; + apply_bucket_default_lock_retention( + &bucket, + object_lock_config_state, + &mut metadata, + has_explicit_object_lock_retention, + )?; + let mut opts = put_opts_with_replication_authorization( + &bucket, + &fpath, + None, + &req.headers, + metadata.clone(), + replication_authorized, + ) + .await + .map_err(ApiError::from)?; + if let Some(quota_check) = extract_quota_check.as_ref() { + apply_quota_admission(&mut opts, quota_check)?; + } + opts.expected_bucket_incarnation_id = expected_bucket_incarnation_id; + opts.object_lock_config_snapshot = Some(Arc::clone(&object_lock_config_snapshot)); + let pax_authorization = + apply_extract_entry_pax_extensions(&mut f, &bucket, &fpath, object_lock_config_state, &mut metadata, &mut opts) + .await?; + for (name, value) in &pax_authorization.headers { + auth_req.headers.insert(name.clone(), value.clone()); + } + if let Some(version_id) = opts.version_id.as_ref() { + req_info_mut(&mut auth_req)?.version_id = Some(version_id.clone()); + } + authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectAction)).await?; + if pax_authorization.object_lock_mode.is_some() || pax_authorization.object_lock_retain_until_date.is_some() { + authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?; + } + if pax_authorization.object_lock_legal_hold_status.is_some() { + authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await?; + } + if opts.version_id.is_some() || pax_authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS) { + authorize_request(&mut auth_req, Action::S3Action(S3Action::ReplicateObjectAction)).await?; + } + let effective_object_lock_legal_hold_status = pax_authorization + .object_lock_legal_hold_status + .clone() + .or_else(|| object_lock_legal_hold_status.clone()); + let (effective_object_lock_mode, effective_object_lock_retain_until_date) = + if pax_authorization.object_lock_mode.is_some() || pax_authorization.object_lock_retain_until_date.is_some() { + ( + pax_authorization.object_lock_mode.clone(), + pax_authorization.object_lock_retain_until_date.clone(), + ) + } else { + (object_lock_mode.clone(), object_lock_retain_until_date.clone()) + }; + if archive_entry_mod_time.is_some() { + opts.mod_time = archive_entry_mod_time; + } + + debug!("Extracting file: {}, size: {} bytes", fpath, size); + + if is_dir { + if extract_options.ignore_dirs { + debug!("Skipping directory entry during archive extract: {}", fpath); + continue; + } + size = 0; + } + + let actual_size = size; + + let should_compress = + !is_dir && is_disk_compressible(&HeaderMap::new(), &fpath) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64; + + let mut write_plan = WritePlan::new(); + let mut hrd = if is_dir { + HashReader::from_stream(std::io::Cursor::new(Vec::new()), size, actual_size, None, None, false) + .map_err(ApiError::from)? + } else if should_compress { + let algorithm = CompressionAlgorithm::default(); + insert_str(&mut metadata, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); + insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, size.to_string()); + + let hrd = HashReader::from_stream(f, size, actual_size, None, None, false).map_err(ApiError::from)?; + write_plan = write_plan.with_compression(algorithm); + hrd + } else { + HashReader::from_stream(f, size, actual_size, None, None, false).map_err(ApiError::from)? + }; + apply_put_request_object_lock_opts( + &bucket, + object_lock_config_state, + effective_object_lock_legal_hold_status, + effective_object_lock_mode, + effective_object_lock_retain_until_date, + &mut opts, + )?; + if let Some(material) = sse_encryption(EncryptionRequest { + bucket: &bucket, + key: &fpath, + server_side_encryption: effective_sse.clone(), + ssekms_key_id: effective_kms_key_id.clone(), + ssekms_context: extract_ssekms_context_from_headers(&req.headers)?, + sse_customer_algorithm: sse_customer_algorithm.clone(), + sse_customer_key: sse_customer_key.clone(), + sse_customer_key_md5: sse_customer_key_md5.clone(), + content_size: actual_size, + principal: extract_principal.as_ref(), + }) + .await? + { + effective_sse = Some(material.server_side_encryption.clone()); + effective_kms_key_id = material.kms_key_id.clone(); + + write_plan = write_plan.with_encryption(material.write_encryption(None)); + + let encryption_metadata = encryption_material_to_metadata(&material)?; + metadata.extend(encryption_metadata.clone()); + opts.user_defined.extend(encryption_metadata); + } + hrd = write_plan.apply(hrd, actual_size).map_err(ApiError::from)?; + opts.user_defined.extend(metadata); + + // Each extracted member is an independent user write and joins + // bucket replication like a regular PUT (MinIO PutObjectExtract + // parity). One immutable decision drives both the pending metadata + // and the post-commit schedule below, same contract as the PUT path + // (https://github.com/rustfs/backlog/issues/1320); inbound replica + // writes are declined inside `must_replicate_object`. + let dsc = must_replicate_object( + &bucket, + &fpath, + &opts.user_defined, + "".to_string(), + opts.delete_marker_replication_status(), + opts.clone(), + ) + .await; + if dsc.replicate_any() { + insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); + insert_str( + &mut opts.user_defined, + SUFFIX_REPLICATION_STATUS, + dsc.pending_status().unwrap_or_default(), + ); + } + + let mut reader = PutObjReader::new(hrd); + let cache_adapter = self.object_data_cache(); + let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &fpath).await; + + let (obj_info, backfilled_old_current_size) = match store + .put_object_with_old_current_size(&bucket, &fpath, &mut reader, &opts) + .await + { + Ok(result) => result, + Err(e) => { + if extract_options.ignore_errors { + warn!(error = %e, "Archive object write skipped due to ignore-errors"); + continue; + } + return Err(ApiError::from(e).into()); + } + }; + let committed_size = quota_accounting_object_size(&obj_info, extract_quota_enabled)?; + let extract_versioned = BucketVersioningSys::prefix_enabled(&bucket, &fpath).await; + match previous_current_size_from_backfill(backfilled_old_current_size) { + Some(previous_current_size) => { + if extract_versioned { + record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; + } else { + record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; + } + } + None => { + record_bucket_object_write_unknown_previous_memory(&bucket, committed_size, extract_versioned).await; + } + } + let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &fpath).await; + + // Reuse the per-entry pre-commit decision (see `dsc` above) so the + // persisted pending marker and the schedule always agree. + if dsc.replicate_any() { + schedule_object_replication(obj_info.clone(), store.clone(), dsc).await; + } + + if !wrote_any_entry { + rustfs_scanner::record_dirty_usage_bucket(&bucket); + wrote_any_entry = true; + } + + let _manager = get_concurrency_manager(); + let _fpath_clone = fpath.clone(); + let _bucket_clone = bucket.clone(); + let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag)); + + let output = PutObjectOutput { + e_tag, + ..Default::default() + }; + + let event_args = rustfs_notify::EventArgs { + event_name: put_event_name_for_post_object(false), + bucket_name: bucket.clone(), + object: convert_ecstore_object_info(obj_info.clone()), + req_params: req_params.clone(), + resp_elements: build_event_resp_elements(&S3Response::new(output.clone()), &request_context.request_id), + version_id: version_id.clone(), + host: host.clone(), + port, + user_agent: user_agent.clone(), + }; + + let notify = notify.clone(); + spawn_background_with_context(Some(request_context.clone()), async move { + notify.notify(event_args).await; + }); + } + + let mut checksums = PutObjectChecksums { + crc32: input.checksum_crc32, + crc32c: input.checksum_crc32c, + sha1: input.checksum_sha1, + sha256: input.checksum_sha256, + crc64nvme: input.checksum_crc64nvme, + }; + apply_trailing_checksums( + input.checksum_algorithm.as_ref().map(|a| a.as_str()), + &req.trailing_headers, + &mut checksums, + ); + + warn!( + "put object extract checksum_crc32={:?}, checksum_crc32c={:?}, checksum_sha1={:?}, checksum_sha256={:?}, checksum_crc64nvme={:?}", + checksums.crc32, checksums.crc32c, checksums.sha1, checksums.sha256, checksums.crc64nvme, + ); + + drop(entries); + let mut decoder = match ar.into_inner() { + Ok(decoder) => decoder, + Err(_) => return Err(s3_error!(InvalidArgument, "Failed to finalize archive reader")), + }; + tokio::io::copy(&mut decoder, &mut tokio::io::sink()) + .await + .map_err(map_extract_archive_error)?; + let archive_etag = archive_etag + .lock() + .ok() + .and_then(|etag| etag.clone()) + .map(|etag| to_s3s_etag(&etag)); + + let output = PutObjectOutput { + e_tag: archive_etag, + checksum_crc32: checksums.crc32, + checksum_crc32c: checksums.crc32c, + checksum_sha1: checksums.sha1, + checksum_sha256: checksums.sha256, + checksum_crc64nvme: checksums.crc64nvme, + ..Default::default() + }; + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderName, HeaderValue}; + use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled}; + use tokio_tar::{Builder, EntryType, Header}; + + fn pax_record(key: &str, value: &[u8]) -> Vec { + let body_len = 1 + key.len() + 1 + value.len() + 1; + let mut len = body_len + 1; + loop { + let actual_len = len.to_string().len() + body_len; + if actual_len == len { + break; + } + len = actual_len; + } + + let mut record = format!("{len} {key}=").into_bytes(); + record.extend_from_slice(value); + record.push(b'\n'); + assert_eq!(record.len(), len); + record + } + + #[tokio::test] + async fn snowball_pax_rejects_unpaired_object_lock_retention() { + let record = pax_record("minio.metadata.x-amz-object-lock-mode", b"GOVERNANCE"); + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + let mut metadata = HashMap::from([ + (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), + ]); + let mut opts = ObjectOptions::default(); + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + let err = apply_extract_entry_pax_extensions(&mut entry, "bucket", "object", &state, &mut metadata, &mut opts) + .await + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("COMPLIANCE")); + } + + #[tokio::test] + async fn snowball_pax_privileged_fields_require_independent_authorization() { + let mut retention = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"GOVERNANCE"); + retention.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); + let cases = [ + ("retention", retention, (true, false, false)), + ( + "legal-hold", + pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON"), + (false, true, false), + ), + ( + "version-id", + pax_record("minio.versionId", Uuid::nil().to_string().as_bytes()), + (false, false, true), + ), + ( + "replication-status", + pax_record("minio.metadata.x-amz-replication-status", b"REPLICA"), + (false, false, true), + ), + ]; + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + for (case, record, expected) in cases { + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + + let mut opts = ObjectOptions::default(); + let authorization = + apply_extract_entry_pax_extensions(&mut entry, "bucket", "object", &state, &mut HashMap::new(), &mut opts) + .await + .unwrap(); + + assert_eq!( + ( + authorization.object_lock_mode.is_some() || authorization.object_lock_retain_until_date.is_some(), + authorization.object_lock_legal_hold_status.is_some(), + opts.version_id.is_some() || authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS), + ), + expected, + "{case} must request only its own additional authorization" + ); + match case { + "retention" => { + assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); + assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); + } + "legal-hold" => assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)), + "version-id" => assert_eq!(opts.version_id.as_deref(), Some(Uuid::nil().to_string().as_str())), + "replication-status" => assert_eq!( + authorization + .headers + .get(AMZ_BUCKET_REPLICATION_STATUS) + .and_then(|value| value.to_str().ok()), + Some("REPLICA") + ), + _ => unreachable!(), + } + } + } + + #[tokio::test] + async fn snowball_pax_rejects_invalid_retention_and_replication_values() { + let mut invalid_mode = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"INVALID"); + invalid_mode.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); + let mut invalid_date = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"COMPLIANCE"); + invalid_date.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"not-a-date")); + let cases = [ + ("invalid-mode", invalid_mode), + ("invalid-date", invalid_date), + ( + "invalid-replication-status", + pax_record("minio.metadata.x-amz-replication-status", b"INVALID"), + ), + ("invalid-version-id", pax_record("minio.versionId", b"not-a-uuid")), + ]; + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + for (case, record) in cases { + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + + let err = apply_extract_entry_pax_extensions( + &mut entry, + "bucket", + "object", + &state, + &mut HashMap::new(), + &mut ObjectOptions::default(), + ) + .await + .unwrap_err(); + + assert!( + err.code() == &S3ErrorCode::InvalidArgument || err.code() == &S3ErrorCode::MalformedXML, + "{case}" + ); + } + } + + #[tokio::test] + async fn snowball_pax_preserves_canonical_minio_metadata_and_valid_retention() { + let mut record = pax_record("minio.metadata.Content-Type", b"text/plain"); + record.extend(pax_record("minio.metadata.X-Amz-Meta-Owner", b"alice")); + record.extend(pax_record("minio.metadata.project", b"alpha-demo")); + record.extend(pax_record("minio.metadata.x-amz-tagging", b"classification=public")); + record.extend(pax_record("minio.versionId", Uuid::nil().to_string().as_bytes())); + record.extend(pax_record("minio.metadata.x-amz-replication-status", b"REPLICA")); + record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"GOVERNANCE")); + record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); + record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON")); + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object.txt", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + let mut metadata = HashMap::from([ + (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), + ]); + let mut opts = ObjectOptions::default(); + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + let authorization = + apply_extract_entry_pax_extensions(&mut entry, "bucket", "object.txt", &state, &mut metadata, &mut opts) + .await + .unwrap(); + + assert_eq!(metadata.get("content-type").map(String::as_str), Some("text/plain")); + assert_eq!(metadata.get("owner").map(String::as_str), Some("alice")); + assert_eq!(metadata.get("project").map(String::as_str), Some("alpha-demo")); + assert_eq!(metadata.get(AMZ_OBJECT_TAGGING).map(String::as_str), Some("classification=public")); + assert!(!metadata.contains_key("x-amz-tagging")); + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("GOVERNANCE")); + assert_eq!( + metadata.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER).map(String::as_str), + Some("2099-01-01T00:00:00Z") + ); + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).map(String::as_str), Some("ON")); + assert!(metadata.contains_key("x-rustfs-internal-objectlock-legalhold-timestamp")); + assert!(metadata.contains_key("x-minio-internal-objectlock-legalhold-timestamp")); + assert_eq!(metadata.get(AMZ_BUCKET_REPLICATION_STATUS).map(String::as_str), Some("REPLICA")); + assert_eq!(opts.version_id.as_deref(), Some("00000000-0000-0000-0000-000000000000")); + assert!(authorization.object_lock_mode.is_some()); + assert!(authorization.object_lock_retain_until_date.is_some()); + assert!(authorization.object_lock_legal_hold_status.is_some()); + assert!(opts.version_id.is_some()); + assert!(authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS)); + } + + #[tokio::test] + async fn snowball_pax_rejects_legal_hold_without_bucket_object_lock() { + let record = pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON"); + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + let mut metadata = HashMap::new(); + + let err = apply_extract_entry_pax_extensions( + &mut entry, + "bucket", + "object", + &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, + &mut metadata, + &mut ObjectOptions::default(), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)); + } + + #[tokio::test] + async fn snowball_pax_rejects_invalid_legal_hold_status() { + let record = pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"INVALID"); + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + let mut metadata = HashMap::new(); + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + let err = apply_extract_entry_pax_extensions( + &mut entry, + "bucket", + "object", + &state, + &mut metadata, + &mut ObjectOptions::default(), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::MalformedXML); + assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)); + } + + #[test] + fn is_put_object_extract_requested_accepts_meta_header() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); + + assert!(is_put_object_extract_requested(&headers)); + } + + #[test] + fn is_put_object_extract_requested_accepts_compat_header_case_insensitive() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_EXTRACT_COMPAT, HeaderValue::from_static(" TRUE ")); + + assert!(is_put_object_extract_requested(&headers)); + } + + #[test] + fn is_put_object_extract_requested_rejects_missing_or_false_value() { + let mut headers = HeaderMap::new(); + assert!(!is_put_object_extract_requested(&headers)); + + headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("false")); + assert!(!is_put_object_extract_requested(&headers)); + } + + #[test] + fn normalize_snowball_prefix_trims_slashes_and_whitespace() { + assert_eq!( + normalize_snowball_prefix(" /batch/incoming/ ").unwrap(), + Some("batch/incoming".to_string()) + ); + assert_eq!(normalize_snowball_prefix("///").unwrap(), None); + } + + #[test] + fn normalize_snowball_prefix_rejects_parent_dir_components() { + assert!(normalize_snowball_prefix("../victim-bucket").is_err()); + assert!(normalize_snowball_prefix("safe/../../victim-bucket").is_err()); + assert!(normalize_snowball_prefix("safe\\..\\victim-bucket").is_err()); + } + + #[test] + fn normalize_extract_entry_key_applies_prefix_and_directory_suffix() { + assert_eq!( + normalize_extract_entry_key("nested/path.txt", Some("imports"), false).unwrap(), + "imports/nested/path.txt" + ); + assert_eq!( + normalize_extract_entry_key("nested/dir/", Some("imports"), true).unwrap(), + "imports/nested/dir/" + ); + assert_eq!(normalize_extract_entry_key("top-level", None, false).unwrap(), "top-level"); + } + + #[test] + fn normalize_extract_entry_key_rejects_bucket_escape_paths() { + assert!(normalize_extract_entry_key("../victim-bucket/evil.txt", None, false).is_err()); + assert!(normalize_extract_entry_key("safe/../../victim-bucket/evil.txt", None, false).is_err()); + assert!(normalize_extract_entry_key("safe\\..\\victim-bucket\\evil.txt", None, false).is_err()); + assert!(normalize_extract_entry_key("evil.txt", Some("../victim-bucket"), false).is_err()); + } + + #[test] + fn resolve_put_object_extract_options_defaults_when_headers_missing() { + let headers = HeaderMap::new(); + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!( + options, + PutObjectExtractOptions { + prefix: None, + ignore_dirs: false, + ignore_errors: false + } + ); + } + + #[test] + fn resolve_put_object_extract_options_accepts_internal_headers() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_PREFIX_INTERNAL, HeaderValue::from_static("/internal/prefix/")); + headers.insert(AMZ_SNOWBALL_IGNORE_DIRS_INTERNAL, HeaderValue::from_static("true")); + headers.insert(AMZ_SNOWBALL_IGNORE_ERRORS_INTERNAL, HeaderValue::from_static("TRUE")); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!(options.prefix.as_deref(), Some("internal/prefix")); + assert!(options.ignore_dirs); + assert!(options.ignore_errors); + } + + #[test] + fn resolve_put_object_extract_options_accepts_standard_headers() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static(" /standard/prefix/ ")); + headers.insert(AMZ_SNOWBALL_IGNORE_DIRS, HeaderValue::from_static(" true ")); + headers.insert(AMZ_SNOWBALL_IGNORE_ERRORS, HeaderValue::from_static("TRUE")); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!(options.prefix.as_deref(), Some("standard/prefix")); + assert!(options.ignore_dirs); + assert!(options.ignore_errors); + } + + #[test] + fn resolve_put_object_extract_options_accepts_suffix_compatible_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-amz-meta-acme-snowball-prefix"), + HeaderValue::from_static(" /partner/import "), + ); + headers.insert( + HeaderName::from_static("x-amz-meta-acme-snowball-ignore-dirs"), + HeaderValue::from_static(" true "), + ); + headers.insert( + HeaderName::from_static("x-amz-meta-acme-snowball-ignore-errors"), + HeaderValue::from_static("TRUE"), + ); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!(options.prefix.as_deref(), Some("partner/import")); + assert!(options.ignore_dirs); + assert!(options.ignore_errors); + } + + #[test] + fn resolve_put_object_extract_options_prefers_exact_headers_over_suffix_fallback() { + let mut headers = HeaderMap::new(); + headers.insert("x-amz-meta-acme-snowball-prefix", HeaderValue::from_static("/fallback/prefix/")); + headers.insert(AMZ_RUSTFS_SNOWBALL_PREFIX, HeaderValue::from_static("/internal/prefix/")); + headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static("/standard/prefix/")); + headers.insert(AMZ_MINIO_SNOWBALL_PREFIX, HeaderValue::from_static("/minio/prefix/")); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!(options.prefix.as_deref(), Some("minio/prefix")); + } + + #[test] + fn resolve_put_object_extract_options_exact_flags_override_suffix_fallback() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_IGNORE_DIRS, HeaderValue::from_static("false")); + headers.insert("x-amz-meta-acme-snowball-ignore-dirs", HeaderValue::from_static("true")); + headers.insert(AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, HeaderValue::from_static("false")); + headers.insert("x-amz-meta-acme-snowball-ignore-errors", HeaderValue::from_static("true")); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert!(!options.ignore_dirs); + assert!(!options.ignore_errors); + } + + #[test] + fn resolve_put_object_extract_options_rejects_unsafe_prefix_header() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static("../victim-bucket")); + + assert!(resolve_put_object_extract_options(&headers).is_err()); + } + + #[test] + fn validate_put_object_extract_entry_count_rejects_limit_overflow() { + let limits = ArchiveLimits { + max_entries: 1, + ..ArchiveLimits::default() + }; + + let err = validate_put_object_extract_entry_count(2, limits).unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn validate_put_object_extract_entry_size_rejects_oversized_entry() { + let limits = ArchiveLimits { + max_entry_size: 8, + ..ArchiveLimits::default() + }; + + let err = validate_put_object_extract_entry_size("payload.bin", 9, limits).unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn validate_put_object_extract_total_size_rejects_cumulative_overflow() { + let limits = ArchiveLimits { + max_total_unpacked_size: 16, + ..ArchiveLimits::default() + }; + + let err = validate_put_object_extract_total_size(17, limits).unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn validate_put_object_extract_entry_path_rejects_overlong_path() { + let limits = ArchiveLimits { + max_path_length: 8, + ..ArchiveLimits::default() + }; + + let err = validate_put_object_extract_entry_path("toolong-path", limits).unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn legacy_archive_quota_rejects_cumulative_size_and_overflow() { + let legacy = QuotaCheckResult { + allowed: true, + current_usage: Some(4), + quota_limit: Some(5), + operation_size: 0, + remaining: Some(1), + uses_durable_reservations: false, + }; + assert!(ensure_legacy_archive_size_within_quota(&legacy, 2).is_err()); + assert!(ensure_legacy_archive_size_within_quota(&legacy, 1).is_ok()); + + let maxed = QuotaCheckResult { + current_usage: Some(u64::MAX), + quota_limit: Some(u64::MAX), + ..legacy + }; + assert!(ensure_legacy_archive_size_within_quota(&maxed, 1).is_err()); + } +} diff --git a/rustfs/src/app/object/get.rs b/rustfs/src/app/object/get.rs new file mode 100644 index 000000000..ccac5587c --- /dev/null +++ b/rustfs/src/app/object/get.rs @@ -0,0 +1,9265 @@ +// 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. + +//! GetObject / GetObjectAttributes read path: cold fill, resume, stream tuning. + +use super::*; + +struct ColdFillDiskPermitMetric { + owner: ColdFillDiskPermitOwner, + metric_recorded: bool, +} + +#[cfg(test)] +static COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST: AtomicU64 = AtomicU64::new(0); + +#[cfg(test)] +struct ColdFillPublicationBarrier { + reached: tokio::sync::Semaphore, + release: tokio::sync::Semaphore, +} + +#[cfg(test)] +type ColdFillPublicationBarrierState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc)>; + +#[cfg(test)] +static COLD_FILL_PUBLICATION_BARRIER: OnceLock> = OnceLock::new(); + +#[cfg(test)] +type ColdFillReaderOpenProbeState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc)>; + +#[cfg(test)] +static COLD_FILL_READER_OPEN_PROBE: OnceLock> = OnceLock::new(); + +fn adjust_cold_fill_disk_permit_metric(owner: ColdFillDiskPermitOwner, acquired: bool) { + macro_rules! adjust_gauge { + ($name:literal) => {{ + #[cfg(not(test))] + let gauge = { + static HANDLE: std::sync::LazyLock = std::sync::LazyLock::new(|| metrics::gauge!($name)); + &*HANDLE + }; + #[cfg(test)] + let gauge = metrics::gauge!($name); + if acquired { + gauge.increment(1.0); + } else { + gauge.decrement(1.0); + } + }}; + } + + match owner { + ColdFillDiskPermitOwner::Producer => { + adjust_gauge!("rustfs_object_data_cache_cold_fill_producer_disk_permits"); + } + ColdFillDiskPermitOwner::Follower => { + adjust_gauge!("rustfs_object_data_cache_cold_fill_follower_disk_permits"); + } + } +} + +#[cfg(test)] +async fn wait_cold_fill_publication_barrier(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) { + let Some(key) = plan.key() else { + return; + }; + let barrier = COLD_FILL_PUBLICATION_BARRIER + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .filter(|(barrier_key, _)| barrier_key == key) + .map(|(_, barrier)| Arc::clone(barrier)); + if let Some(barrier) = barrier { + barrier.reached.add_permits(1); + if let Ok(permit) = barrier.release.acquire().await { + permit.forget(); + } + } +} + +#[cfg(test)] +fn record_cold_fill_reader_open_for_test(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) { + let Some(key) = plan.key() else { + return; + }; + let probe = COLD_FILL_READER_OPEN_PROBE + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .filter(|(probe_key, _)| probe_key == key) + .map(|(_, count)| Arc::clone(count)); + if let Some(count) = probe { + count.fetch_add(1, Ordering::Relaxed); + } +} + +impl ColdFillDiskPermitMetric { + fn new(owner: ColdFillDiskPermitOwner) -> Self { + let metric_recorded = rustfs_io_metrics::metrics_enabled(); + if metric_recorded { + adjust_cold_fill_disk_permit_metric(owner, true); + } + #[cfg(test)] + if matches!(owner, ColdFillDiskPermitOwner::Follower) { + COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_add(1, Ordering::Relaxed); + } + Self { owner, metric_recorded } + } +} + +impl Drop for ColdFillDiskPermitMetric { + fn drop(&mut self) { + if self.metric_recorded { + adjust_cold_fill_disk_permit_metric(self.owner, false); + } + #[cfg(test)] + if matches!(self.owner, ColdFillDiskPermitOwner::Follower) { + COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_sub(1, Ordering::Relaxed); + } + } +} + +struct GetObjectDiskPermit { + permit: Option, + metric: Option, +} + +impl GetObjectDiskPermit { + fn new(permit: OwnedSemaphorePermit) -> Self { + Self { + permit: Some(permit), + metric: current_cold_fill_disk_permit_owner().map(ColdFillDiskPermitMetric::new), + } + } + + fn release(&mut self) { + self.permit.take(); + self.metric.take(); + } +} + +impl From for GetObjectDiskPermit { + fn from(permit: OwnedSemaphorePermit) -> Self { + Self::new(permit) + } +} + +impl Drop for GetObjectDiskPermit { + fn drop(&mut self) { + self.release(); + } +} + +const COLD_FILL_HARD_MAX_DURATION: Duration = Duration::from_secs(10 * 60); + +pub(crate) const MAX_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 64 * 1024 * 1024; + +const MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 8 * 1024 * 1024; + +const HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 4 * 1024 * 1024; + +const VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 1024 * 1024; + +const EVENT_GET_OBJECT_STREAM_BODY: &str = "get_object_stream_body"; + +const GET_OBJECT_STAGE_PATH_S3_HANDLER: &str = "s3_handler"; + +const GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT: &str = "request_ingress_to_context"; + +const GET_OBJECT_STAGE_OUTPUT_STRATEGY: &str = "output_strategy"; + +const GET_OBJECT_STAGE_BODY_BUILD: &str = "body_build"; + +const GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ: &str = "body_encrypted_buffer_read"; + +const GET_OBJECT_STAGE_BODY_MEMORY_BLOB: &str = "body_memory_blob"; + +const GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ: &str = "body_seek_buffer_read"; + +const GET_OBJECT_STAGE_BODY_STREAM_STRATEGY: &str = "body_stream_strategy"; + +const GET_OBJECT_STAGE_BODY_STREAMING_BLOB: &str = "body_streaming_blob"; + +const GET_OBJECT_STAGE_CHECKSUM_HEADERS: &str = "checksum_headers"; + +const GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION: &str = "lifecycle_expiration"; + +const GET_OBJECT_STAGE_METADATA_FILTER: &str = "metadata_filter"; + +const GET_OBJECT_STREAM_WARN_THRESHOLD: Duration = Duration::from_secs(5); + +static GET_OBJECT_BUFFER_THRESHOLD_WARNED: AtomicBool = AtomicBool::new(false); + +fn record_get_object_s3_handler_stage_duration(stage: &'static str, start: Option) { + if let Some(start) = start { + rustfs_io_metrics::record_get_object_stage_duration( + GET_OBJECT_STAGE_PATH_S3_HANDLER, + stage, + start.elapsed().as_secs_f64(), + ); + } +} + +struct GetObjectBootstrap { + timeout_config: GetObjectTimeoutPolicy, + wrapper: RequestTimeoutWrapper, + request_start: std::time::Instant, + request_guard: GetObjectGuard, + _deadlock_request_guard: Option, + concurrent_requests: usize, +} + +struct GetObjectIoPlanning { + /// `None` when inline fast path skips disk I/O semaphore. + disk_permit: Option, + permit_wait_duration: Duration, + queue_status: concurrency::IoQueueStatus, + queue_utilization: f64, +} + +#[derive(Clone, Copy)] +struct GetObjectRequestTimeout<'a> { + wrapper: &'a RequestTimeoutWrapper, + policy: &'a GetObjectTimeoutPolicy, +} + +struct GetObjectRequestContext { + bucket: String, + key: String, + version_id_for_event: String, + part_number: Option, + rs: Option, + opts: ObjectOptions, +} + +/// Request fields that passed the cheap GET validations, ready for the +/// bucket-metadata work in [`DefaultObjectUsecase::prepare_get_object_request_context`]. +struct GetObjectValidatedRequest { + bucket: String, + key: String, + version_id: Option, + part_number: Option, + rs: Option, +} + +struct GetObjectReadSetup { + info: ObjectInfo, + final_stream: DynReader, + buffered_body: Option, + /// ODC-16: `buffered_body` is the body the ecstore cache hook served, so the + /// app layer serves it as the object-data-cache source without a re-lookup. + cache_hook_served: bool, + /// ODC-16: the cache hook probed this read (served or missed), so the app + /// layer must skip its own lookup. + cache_hook_probed: bool, + cache_fill_allowed: bool, + rs: Option, + content_type: Option, + last_modified: Option, + response_content_length: i64, + content_range: Option, + server_side_encryption: Option, + sse_customer_algorithm: Option, + sse_customer_key_md5: Option, + ssekms_key_id: Option, + encryption_applied: bool, + /// Resolved plaintext start offset of the committed response body + /// (`get_offset_length` output; 0 for a full-object read). Feeds the + /// mid-stream resume offset. + resume_range_start: i64, + /// Resolved inclusive plaintext end offset of the committed response body; + /// -1 when the committed body runs to the end of the object. + resume_range_end: i64, +} + +struct GetObjectPreparedRead { + io_planning: GetObjectIoPlanning, + read_setup: GetObjectReadSetup, +} + +struct GetObjectStrategyContext { + #[allow(dead_code, reason = "written but never read back (backlog#1823)")] + io_strategy: concurrency::IoStrategy, + optimal_buffer_size: usize, + enable_readahead: bool, +} + +struct GetObjectOutputContext { + output: GetObjectOutput, + event_info: Option, + response_content_length: i64, + optimal_buffer_size: usize, + extra_checksum_headers: Vec<(&'static str, String)>, +} + +enum GetObjectTimeoutStage { + BeforeProcessing, + DiskPermitWait { permit_wait_duration: Duration }, + BeforeRead, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GetObjectStreamStrategy { + Standard, + LargeSequentialReadahead, +} + +impl GetObjectStreamStrategy { + fn as_str(self) -> &'static str { + match self { + Self::Standard => "standard", + Self::LargeSequentialReadahead => "large_sequential_readahead", + } + } +} + +const LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES: i64 = 1024 * 1024 * 1024; + +const LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES: usize = 4 * MI_B; + +const LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER: usize = 2; + +const LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = MI_B; + +const LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = 4 * MI_B as i64; + +const MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = 512 * 1024; + +const MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = MI_B as i64; + +const ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE: &str = "RUSTFS_GET_SEEK_BUFFER_ENABLE"; + +const ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE: &str = "RUSTFS_GET_READER_STREAM_BUFFER_SIZE"; + +const ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE: &str = "RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE"; + +const ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE: &str = "RUSTFS_GET_SMALL_BODY_ONCE_ENABLE"; + +const GET_READER_STREAM_BUFFER_SOURCE_SELECTED: &str = "selected"; + +const GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE: &str = "env_override"; + +const GET_READER_STREAM_POLL_PENDING: &str = "pending"; + +const GET_READER_STREAM_POLL_READY_DATA: &str = "ready_data"; + +const GET_READER_STREAM_POLL_READY_EMPTY: &str = "ready_empty"; + +const GET_READER_STREAM_POLL_READY_ERROR: &str = "ready_error"; + +const GET_STREAMING_BODY_FAILURE_STAGE_READER_STREAM: &str = "reader_stream"; + +const GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR: &str = "reader_error"; + +const GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF: &str = "short_eof"; + +const GET_MEMORY_BODY_SOURCE_BUFFERED_BODY: &str = "buffered_body"; + +const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE: &str = "object_data_cache"; + +const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED: &str = "object_data_cache_materialized"; + +const GET_MEMORY_BODY_SOURCE_SEEK_BUFFER: &str = "seek_buffer"; + +const GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER: &str = "encrypted_buffer"; + +const GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ: &str = "body_cache_materialize_read"; + +fn get_reader_stream_buffer_size_override() -> Option { + static GET_READER_STREAM_BUFFER_SIZE_OVERRIDE: OnceLock> = OnceLock::new(); + *GET_READER_STREAM_BUFFER_SIZE_OVERRIDE.get_or_init(|| { + std::env::var(ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + }) +} + +fn is_get_output_handoff_attribution_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE, false)) +} + +fn is_get_small_body_once_enabled() -> bool { + #[cfg(test)] + { + rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false) + } + #[cfg(not(test))] + { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false)) + } +} + +fn is_get_seek_buffer_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE, false)) +} + +fn resolve_reader_stream_buffer_size(selected_size: usize, override_size: Option) -> (usize, &'static str) { + if let Some(override_size) = override_size.filter(|value| *value > 0) { + return (override_size, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE); + } + + (selected_size.max(1), GET_READER_STREAM_BUFFER_SOURCE_SELECTED) +} + +fn tune_reader_stream_buffer_size( + selected_size: usize, + response_content_length: i64, + stream_strategy: GetObjectStreamStrategy, +) -> usize { + if stream_strategy == GetObjectStreamStrategy::Standard + && response_content_length >= LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES + { + return selected_size.max(LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES); + } + + if stream_strategy == GetObjectStreamStrategy::Standard + && response_content_length >= MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES + { + return selected_size.max(MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES); + } + + selected_size +} + +fn get_object_stream_size_bucket(expected: usize) -> &'static str { + rustfs_io_metrics::get_object_size_bucket(i64::try_from(expected).unwrap_or(i64::MAX)) +} + +fn classify_get_object_stream_read_error(err: &std::io::Error) -> &'static str { + if let Some(inner) = err.get_ref() { + if inner.is::() { + return "short_eof"; + } + + if inner.is::() { + return "bitrot"; + } + + let error_msg = inner.to_string().to_lowercase(); + if error_msg.contains("bitrot") { + return "bitrot"; + } + if error_msg.contains("read quorum") || error_msg.contains("insufficient read quorum") || error_msg.contains("erasure") { + return "read_quorum"; + } + } + + match err.kind() { + std::io::ErrorKind::UnexpectedEof => "short_eof", + std::io::ErrorKind::TimedOut => "timeout", + std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => "range_or_length_invalid", + _ => "io", + } +} + +fn get_object_stream_failure_reason(error_class: &'static str) -> &'static str { + if error_class == "short_eof" { + GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF + } else { + GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR + } +} + +fn record_get_object_reader_stream_failure( + reason: &'static str, + error_class: &'static str, + strategy: &'static str, + buffer_source: &'static str, + expected: usize, + emitted: usize, + remaining: usize, +) { + rustfs_io_metrics::record_get_object_streaming_body_failure(rustfs_io_metrics::GetObjectStreamingBodyFailure { + stage: GET_STREAMING_BODY_FAILURE_STAGE_READER_STREAM, + reason, + error_class, + strategy, + buffer_source, + size_bucket: get_object_stream_size_bucket(expected), + emitted_bytes: emitted, + remaining_bytes: remaining, + }); +} + +struct MemoryTrackedBytesStream { + bytes: Option, + emitted: bool, + completed: bool, + expected: usize, + /// Set when the materialized buffer length disagrees with the declared + /// content length. Such a body would be truncated (short) or over-long + /// relative to the already-committed `Content-Length`, so the stream must + /// surface an error instead of a clean short/over-long body. See #1324. + length_mismatch: bool, + started: std::time::Instant, + source: &'static str, + _guard: Option, + lifecycle: GetObjectBodyLifecycle, +} + +struct MemoryOnceBodyOwner { + bytes: Bytes, + _guard: Option, + // Body::Once has no poll hook, so this opt-in path only holds the request + // guard until the bytes are dropped; the result status remains unknown. + _lifecycle: GetObjectBodyLifecycle, +} + +impl MemoryOnceBodyOwner { + fn new(bytes: Bytes, guard: Option, lifecycle: GetObjectBodyLifecycle) -> Self { + Self { + bytes, + _guard: guard, + _lifecycle: lifecycle, + } + } +} + +impl AsRef<[u8]> for MemoryOnceBodyOwner { + fn as_ref(&self) -> &[u8] { + self.bytes.as_ref() + } +} + +#[derive(Default)] +struct GetObjectBodyLifecycle { + request_guard: Option, +} + +impl GetObjectBodyLifecycle { + fn tracked(request_guard: GetObjectGuard) -> Self { + Self { + request_guard: Some(request_guard), + } + } + + #[cfg(test)] + fn disabled() -> Self { + Self { request_guard: None } + } + + fn is_finished(&self) -> bool { + self.request_guard.is_none() + } + + fn finish_ok(&mut self) { + if let Some(mut request_guard) = self.request_guard.take() { + request_guard.finish_ok(); + } + } + + fn finish_err(&mut self) { + if let Some(mut request_guard) = self.request_guard.take() { + request_guard.finish_err(); + } + } +} + +pin_project! { + // Keep the disk-read admission permit tied to the response body. This is + // intentionally conservative backpressure: a streaming GET should occupy a + // read slot until the client drains or drops the body. + struct DiskReadPermitReader { + #[pin] + inner: R, + disk_permit: Option, + } +} + +impl DiskReadPermitReader { + fn new(inner: R, disk_permit: GetObjectDiskPermit) -> Self { + Self { + inner, + disk_permit: Some(disk_permit), + } + } +} + +impl AsyncRead for DiskReadPermitReader +where + R: AsyncRead, +{ + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let this = self.project(); + let had_capacity = buf.remaining() > 0; + let filled_before = buf.filled().len(); + let poll = this.inner.poll_read(cx, buf); + // EOF: no more disk reads can happen through this stream, so release + // the permit instead of holding it until the client drops the body. + if had_capacity + && matches!(poll, Poll::Ready(Ok(()))) + && buf.filled().len() == filled_before + && let Some(mut disk_permit) = this.disk_permit.take() + { + disk_permit.release(); + } + poll + } +} + +pin_project! { + struct GetObjectReaderStream { + #[pin] + reader: Option, + capacity: usize, + strategy: &'static str, + buffer_source: &'static str, + remaining: usize, + emitted: usize, + expected: usize, + // Diagnostic-only identity for the body this stream is serving. Unset in + // unit tests that drive the stream over a bare reader; every production + // body carries it via `with_diagnostics`. + diagnostics: GetObjectReaderStreamDiagnostics, + } +} + +/// Object identity carried alongside a streaming GET body purely so a +/// mid-stream failure names the object it happened on. +#[derive(Clone, Default)] +struct GetObjectReaderStreamDiagnostics { + bucket: String, + object: String, + request_id: String, +} + +impl MemoryTrackedBytesStream { + fn new( + bytes: Bytes, + expected: usize, + source: &'static str, + guard: Option, + lifecycle: GetObjectBodyLifecycle, + ) -> Self { + let length_mismatch = bytes.len() != expected; + Self { + bytes: Some(bytes), + emitted: false, + completed: !length_mismatch && expected == 0, + expected, + length_mismatch, + started: std::time::Instant::now(), + source, + _guard: guard, + lifecycle, + } + } + + fn finish_ok(&mut self) { + self.completed = true; + self.lifecycle.finish_ok(); + } + + fn finish_err(&mut self) { + self.lifecycle.finish_err(); + } +} + +impl GetObjectReaderStream +where + R: AsyncRead, +{ + fn new(reader: R, capacity: usize, remaining: usize, strategy: &'static str, buffer_source: &'static str) -> Self { + if is_get_output_handoff_attribution_enabled() { + rustfs_io_metrics::record_get_object_reader_stream_buffer_size(strategy, buffer_source, capacity); + } + Self { + reader: Some(reader), + capacity, + strategy, + buffer_source, + remaining, + emitted: 0, + expected: remaining, + diagnostics: GetObjectReaderStreamDiagnostics::default(), + } + } + + /// Attach the object identity a failed body should be reported against. + fn with_diagnostics(mut self, bucket: &str, object: &str, request_id: &str) -> Self { + self.diagnostics = GetObjectReaderStreamDiagnostics { + bucket: bucket.to_string(), + object: object.to_string(), + request_id: request_id.to_string(), + }; + self + } +} + +impl futures::Stream for MemoryTrackedBytesStream { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + let poll_start = is_get_output_handoff_attribution_enabled().then(std::time::Instant::now); + if this.emitted { + if let Some(poll_start) = poll_start { + rustfs_io_metrics::record_get_object_memory_body_stream_poll( + this.source, + GET_READER_STREAM_POLL_READY_EMPTY, + 0, + poll_start.elapsed().as_secs_f64(), + ); + } + return Poll::Ready(None); + } + + // Strict materialization guard (#1324): a body whose length disagrees + // with the declared content length must fail the transfer rather than be + // delivered as a clean short body (truncation) or an over-long body + // (protocol violation). The HTTP layer has already committed to + // `Content-Length == expected`, so there is no safe way to serve a + // differently sized body. This is a defense-in-depth backstop; the + // buffered/cache callers reject the mismatch before headers are sent. + if this.length_mismatch { + let actual = this.bytes.as_ref().map_or(0, Bytes::len); + this.emitted = true; + this.finish_err(); + return Poll::Ready(Some(Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("materialized GET body length mismatch: expected {}, got {}", this.expected, actual), + ) + .into()))); + } + + let Some(bytes) = this.bytes.take() else { + return Poll::Ready(None); + }; + let bytes_len = bytes.len(); + let first_byte_elapsed = (!bytes.is_empty()).then(|| this.started.elapsed()); + this.emitted = true; + if let Some(elapsed) = first_byte_elapsed { + rustfs_io_metrics::record_get_object_first_byte_latency(GET_OBJECT_STAGE_PATH_S3_HANDLER, elapsed.as_secs_f64()); + } + if bytes_len >= this.expected { + this.finish_ok(); + } + if let Some(poll_start) = poll_start { + rustfs_io_metrics::record_get_object_memory_body_stream_poll( + this.source, + GET_READER_STREAM_POLL_READY_DATA, + bytes_len, + poll_start.elapsed().as_secs_f64(), + ); + } + Poll::Ready(Some(Ok(bytes))) + } +} + +impl ByteStream for MemoryTrackedBytesStream { + fn remaining_length(&self) -> RemainingLength { + if self.emitted || self.bytes.is_none() { + RemainingLength::new_exact(0) + } else { + RemainingLength::new_exact(self.expected) + } + } +} + +impl Drop for MemoryTrackedBytesStream { + fn drop(&mut self) { + if self.lifecycle.is_finished() { + return; + } + + if self.completed { + self.finish_ok(); + } else { + self.finish_err(); + } + } +} + +/// Failure modes of strictly materializing an object body into memory (#1324). +#[derive(Debug)] +enum StrictMaterializeError { + /// The reader produced a different number of bytes than the declared content + /// length (short or over-long). The response has already committed to + /// `Content-Length == expected`, so any other length is an unrecoverable, + /// broken HTTP response and must fail before headers are sent. + LengthMismatch { expected: usize, actual: usize }, + /// A read error occurred after `consumed` bytes were already drained from the + /// reader. The caller MUST NOT fall back to streaming the same reader: the + /// drained prefix is gone, so streaming would ship a body missing its prefix + /// (the seek-buffer prefix-misalignment bug this issue closes). + Read { consumed: usize, source: std::io::Error }, +} + +impl std::fmt::Display for StrictMaterializeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::LengthMismatch { expected, actual, .. } => { + write!(f, "materialized length mismatch: expected {expected}, got {actual}") + } + Self::Read { consumed, source } => { + write!(f, "read failed after {consumed} bytes: {source}") + } + } + } +} + +impl StrictMaterializeError { + fn into_storage_error(self) -> StorageError { + match self { + Self::LengthMismatch { expected, actual, .. } if actual < expected => StorageError::LessData, + Self::LengthMismatch { .. } => StorageError::MoreData, + Self::Read { source, .. } if source.kind() == std::io::ErrorKind::TimedOut => StorageError::Timeout, + Self::Read { source, .. } => StorageError::Io(std::io::Error::new(source.kind(), "object body read failed")), + } + } + + fn into_s3_error(self, _response_content_length: i64) -> S3Error { + ApiError::from(self.into_storage_error()).into() + } +} + +/// Strictly materialize an object body into memory, enforcing an exact-length +/// contract (#1324). +/// +/// Reads at most `expected + 1` bytes so an over-long stream is detected without +/// buffering it unbounded, then requires `bytes_read == expected`. A short read +/// (clean EOF before `expected`), an over-long read, or a mid-stream read error +/// all return an error; only an exact-length read yields the buffer. Because the +/// HTTP response commits to `Content-Length == expected` before the body is +/// produced, this mirrors the streaming path (which already fails a short read +/// with `UnexpectedEof`) and the ODC materialize-fill path, closing the +/// warn-and-serve holes in the encrypted, seek, and cache memory branches. +/// +/// On error the reader has already been (partially) consumed, so callers must +/// propagate the error rather than fall back to streaming the same reader. +async fn strict_materialize_object_body( + reader: R, + expected: usize, + stage: &'static str, +) -> Result, StrictMaterializeError> +where + R: AsyncRead + Unpin, +{ + // Stop filling before the Vec reaches capacity. Calling `read_to_end` on a + // bounded reader can still reserve beyond `expected` before observing EOF. + // The over-long probe below stays outside this Vec so the admitted body + // allocation remains exactly `expected` bytes. + let mut buf = Vec::with_capacity(expected); + let mut reader = reader; + let read_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let read_result = loop { + if buf.len() == expected { + break Ok(()); + } + match tokio::io::AsyncReadExt::read_buf(&mut reader, &mut buf).await { + Ok(0) => break Ok(()), + Ok(_) => {} + Err(source) => break Err(source), + } + }; + let actual = buf.len(); + let probe_result = if read_result.is_ok() && actual == expected { + let mut probe = [0_u8; 1]; + tokio::io::AsyncReadExt::read(&mut reader, &mut probe).await + } else { + Ok(0) + }; + record_get_object_s3_handler_stage_duration(stage, read_start); + match (read_result, probe_result) { + (Ok(_), Ok(extra)) => { + let actual = actual.saturating_add(extra); + if actual == expected { + Ok(buf) + } else { + Err(StrictMaterializeError::LengthMismatch { expected, actual }) + } + } + (Err(source), _) | (_, Err(source)) => Err(StrictMaterializeError::Read { + consumed: actual, + source, + }), + } +} + +struct ColdFillProducerExecution { + expected: usize, + deadline: Option, + adapter: Arc, + engine_plan: rustfs_object_data_cache::ObjectDataCacheGetPlan, +} + +enum ColdFillStartupWaitError { + Cancelled, + DeadlineExceeded, +} + +async fn await_cold_fill_startup( + future: F, + cancellation: &tokio_util::sync::CancellationToken, + deadline: Option, +) -> Result +where + F: Future, +{ + tokio::pin!(future); + match deadline { + Some(deadline) => { + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled), + result = tokio::time::timeout_at(deadline, &mut future) => { + result.map_err(|_| ColdFillStartupWaitError::DeadlineExceeded) + } + } + } + None => { + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled), + result = &mut future => Ok(result), + } + } + } +} + +async fn start_cold_fill_producer( + producer: ColdFillProducer, + reservation: Option, + acquire_io: AcquireIo, + open_reader: OpenReader, + execution: ColdFillProducerExecution, +) where + AcquireIo: FnOnce() -> AcquireIoFuture, + AcquireIoFuture: Future>, + OpenReader: FnOnce() -> OpenReaderFuture, + OpenReaderFuture: Future>, +{ + let ColdFillProducerExecution { + expected, + deadline, + adapter, + engine_plan, + } = execution; + let hard_deadline = tokio::time::Instant::now() + COLD_FILL_HARD_MAX_DURATION; + let deadline = deadline.map_or(hard_deadline, |request_deadline| request_deadline.min(hard_deadline)); + let cancellation = producer.cancellation_token(); + let Some(reservation) = reservation else { + producer.bypass(); + return; + }; + let acquire = acquire_io(); + tokio::pin!(acquire); + let producer_io = tokio::select! { + _ = cancellation.cancelled() => { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + result = tokio::time::timeout_at(deadline, &mut acquire) => match result { + Ok(result) => result, + Err(_) => { + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + return; + } + } + }; + let producer_io = match producer_io { + Ok(io) => io, + Err(err) => { + producer.relinquish_or_finish(err); + return; + } + }; + + let open = open_reader(); + tokio::pin!(open); + let reader = match tokio::select! { + _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), + result = tokio::time::timeout_at(deadline, &mut open) => { + result.unwrap_or(Err(StorageError::Timeout)) + } + } { + Ok(reader) => reader, + Err(err) => { + producer.relinquish_or_finish(ColdFillError::Storage(err)); + return; + } + }; + producer.mark_reader_started(); + let materialize = async move { + let GetObjectReader { + stream, buffered_body, .. + } = reader; + let body = if let Some(body) = buffered_body { + if body.len() == expected { + body + } else { + return Err(StorageError::other(format!( + "cold-fill buffered body length mismatch: expected {expected}, got {}", + body.len() + ))); + } + } else { + let stream = if let Some(permit) = producer_io.disk_permit { + wrap_reader(DiskReadPermitReader::new(stream, permit)) + } else { + stream + }; + Bytes::from( + strict_materialize_object_body(stream, expected, GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ) + .await + .map_err(StrictMaterializeError::into_storage_error)?, + ) + }; + Ok::<_, StorageError>((body, reservation)) + }; + let materialized = tokio::select! { + _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), + result = tokio::time::timeout_at(deadline, materialize) => { + result.unwrap_or(Err(StorageError::Timeout)) + } + }; + let result = match materialized { + Ok((body, reservation)) => { + if cancellation.is_cancelled() { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + if deadline <= tokio::time::Instant::now() { + producer.finish(Err(StorageError::Timeout)); + return; + } + let reserved = reservation.wrap_bytes(body); + let shared = reserved.bytes(); + let publish = async { + #[cfg(test)] + wait_cold_fill_publication_barrier(&engine_plan).await; + adapter.fill_reserved_body(&engine_plan, reserved).await + }; + tokio::pin!(publish); + tokio::select! { + _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), + _ = tokio::time::sleep_until(deadline) => { + Err(StorageError::Timeout) + } + _ = &mut publish => Ok(shared), + } + } + Err(err) => Err(err), + }; + producer.finish(result); +} + +fn cold_fill_deadline( + wrapper: &RequestTimeoutWrapper, + timeout_config: &GetObjectTimeoutPolicy, + response_size: u64, +) -> Option { + if !timeout_config.is_timeout_enabled() { + return None; + } + Some(tokio::time::Instant::now() + wrapper.remaining_time_for_size(Some(response_size)).unwrap_or(Duration::ZERO)) +} + +fn cold_fill_producer_deadline(timeout_config: &GetObjectTimeoutPolicy, response_size: u64) -> tokio::time::Instant { + let now = tokio::time::Instant::now(); + let hard_deadline = now + COLD_FILL_HARD_MAX_DURATION; + if timeout_config.is_timeout_enabled() { + (now + timeout_config.calculate_timeout_for_size(response_size)).min(hard_deadline) + } else { + hard_deadline + } +} + +async fn lookup_cold_fill_second_chance( + adapter: &ObjectDataCacheAdapter, + plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan, +) -> Option { + match adapter.peek_body_untracked(plan).await { + rustfs_object_data_cache::ObjectDataCacheLookup::Hit(body) => Some(body), + _ => None, + } +} + +fn retain_cold_fill_producer_for_matching_plan( + producer: ColdFillProducer, + current: &GetObjectBodyCachePlan, + expected: &rustfs_object_data_cache::ObjectDataCacheGetPlan, +) -> Option { + if current == &GetObjectBodyCachePlan::Cacheable(expected.clone()) { + Some(producer) + } else { + producer.bypass(); + None + } +} + +impl futures::Stream for GetObjectReaderStream +where + R: AsyncRead, +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + if *this.remaining == 0 { + return Poll::Ready(None); + } + + let remaining_before = *this.remaining; + let attribution_enabled = is_get_output_handoff_attribution_enabled(); + let poll_start = attribution_enabled.then(std::time::Instant::now); + let reader = match this.reader.as_mut().as_pin_mut() { + Some(reader) => reader, + None => return Poll::Ready(None), + }; + let read_capacity = (*this.capacity).min(*this.remaining); + let mut buf = BytesMut::with_capacity(read_capacity); + let poll_read = poll_read_buf(reader, cx, &mut buf); + + let result: Poll> = match poll_read { + Poll::Ready(Ok(bytes_read)) if bytes_read > 0 => { + let bytes = buf.freeze(); + *this.remaining -= bytes.len(); + *this.emitted += bytes.len(); + #[cfg(feature = "tracing-chunk-debug")] + { + tracing::debug!( + emitted = *this.emitted, + expected = *this.expected, + chunk_len = bytes.len(), + "GetObject ReaderStream emitted bytes" + ); + } + if bytes.is_empty() { + Poll::Ready(None) + } else { + Poll::Ready(Some(Ok(bytes))) + } + } + Poll::Ready(Ok(_)) => { + this.reader.set(None); + let remaining = i64::try_from(*this.remaining).unwrap_or(i64::MAX); + let err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, rustfs_rio::IncompleteBody { remaining }); + record_get_object_reader_stream_failure( + GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF, + "short_eof", + this.strategy, + this.buffer_source, + *this.expected, + *this.emitted, + *this.remaining, + ); + // The inner GetObjectStreamingReader is what normally reports a + // short body, so reaching this arm means the reader signalled a + // clean EOF while this layer still owed bytes against an + // already-committed Content-Length. That disagreement is a data + // plane fault, not chunk noise: log it unconditionally so the + // truncated object is named in the operator's log rather than + // only in a metric counter (issue #4784). + error!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %this.diagnostics.bucket, + object = %this.diagnostics.object, + request_id = %this.diagnostics.request_id, + size_bucket = get_object_stream_size_bucket(*this.expected), + expected = *this.expected, + emitted = *this.emitted, + remaining = *this.remaining, + strategy = this.strategy, + buffer_source = this.buffer_source, + state = "reader_stream_short_eof", + error = %err, + "GetObject reader stream ended before the committed content length" + ); + Poll::Ready(Some(Err(Box::new(err) as S3StdError))) + } + Poll::Ready(Err(err)) => { + this.reader.set(None); + let error_class = classify_get_object_stream_read_error(&err); + record_get_object_reader_stream_failure( + get_object_stream_failure_reason(error_class), + error_class, + this.strategy, + this.buffer_source, + *this.expected, + *this.emitted, + *this.remaining, + ); + // Deliberately not logged at warn here: every production body + // wraps a GetObjectStreamingReader, and that layer already + // reports this same error once with `state = "read_failed"` and + // the object identity. A second unconditional line per failed + // GET would read as two distinct faults. The chunk-debug build + // still gets this layer's view of the same error. + #[cfg(feature = "tracing-chunk-debug")] + tracing::error!( + emitted = *this.emitted, + expected = *this.expected, + error_class = error_class, + error = %err, + "GetObject ReaderStream returned error" + ); + Poll::Ready(Some(Err(Box::new(err) as S3StdError))) + } + Poll::Pending => Poll::Pending, + }; + + let emitted_bytes = match &result { + Poll::Ready(Some(Ok(bytes))) => bytes.len(), + _ => 0, + }; + let outcome = match &result { + Poll::Ready(Some(Ok(bytes))) if !bytes.is_empty() => GET_READER_STREAM_POLL_READY_DATA, + Poll::Ready(Some(Ok(_))) | Poll::Ready(None) => GET_READER_STREAM_POLL_READY_EMPTY, + Poll::Ready(Some(Err(_))) => GET_READER_STREAM_POLL_READY_ERROR, + Poll::Pending => GET_READER_STREAM_POLL_PENDING, + }; + if attribution_enabled { + rustfs_io_metrics::record_get_object_reader_stream_poll( + this.strategy, + this.buffer_source, + outcome, + remaining_before, + emitted_bytes, + poll_start.map_or(0.0, |start| start.elapsed().as_secs_f64()), + ); + } + + result + } + + fn size_hint(&self) -> (usize, Option) { + if self.remaining == 0 || self.reader.is_none() { + (0, Some(0)) + } else { + (1, None) + } + } +} + +impl ByteStream for GetObjectReaderStream +where + R: AsyncRead, +{ + fn remaining_length(&self) -> RemainingLength { + RemainingLength::new_exact(self.remaining) + } +} + +struct GetObjectStreamingReader { + inner: Option, + // bucket/object + request_id + optional content_range are only used for diagnostic + // correlation and failure bucketing; they do not alter stream behavior. The object + // identity is what turns a mid-stream failure into an actionable report: a request_id + // alone cannot tell an operator which object reads short (issue #4784). + bucket: String, + object: String, + request_id: String, + content_range: Option, + expected: usize, + emitted: usize, + timeout: Duration, + timer: Option>>, + started: std::time::Instant, + first_byte_reported: bool, + completed: bool, + lifecycle: GetObjectBodyLifecycle, + resume: Option>, + _foreground_read_guard: rustfs_scanner::ForegroundReadGuard, +} + +impl GetObjectStreamingReader { + #[allow(clippy::too_many_arguments)] + fn new( + inner: R, + bucket: &str, + key: &str, + request_id: &str, + content_range: Option, + expected: usize, + timeout: Duration, + lifecycle: GetObjectBodyLifecycle, + resume: Option>, + ) -> Self { + Self { + inner: Some(inner), + bucket: bucket.to_string(), + object: key.to_string(), + request_id: request_id.to_string(), + content_range, + expected, + emitted: 0, + timeout, + timer: None, + started: std::time::Instant::now(), + first_byte_reported: false, + completed: expected == 0, + lifecycle, + resume, + _foreground_read_guard: rustfs_scanner::ForegroundReadGuard::new(), + } + } + + fn elapsed(&self) -> Duration { + self.started.elapsed() + } + + // Classify transport/read failures before logging so operators can quickly + // distinguish truncated upstream bodies, corruption, quorum issues, and + // genuine downstream-close disconnects. + fn classify_read_error(err: &std::io::Error) -> &'static str { + classify_get_object_stream_read_error(err) + } + + fn finish_ok(&mut self) { + self.completed = true; + self.lifecycle.finish_ok(); + } + + fn finish_err(&mut self) { + self.lifecycle.finish_err(); + } + + fn resume_in_flight(&self) -> bool { + matches!( + self.resume.as_ref().map(|resume| &resume.stage), + Some(GetObjectResumeStage::Backoff | GetObjectResumeStage::Reopening(_)) + ) + } + + fn begin_resume(&mut self, error: std::io::Error) { + let Some(resume) = self.resume.as_mut() else { + return; + }; + self.inner.take(); + resume.begin(error); + } + + // Drive the armed resume flow: backoff ticks gate each reopen attempt, and + // a successful reopen swaps the failed stream out for the replacement. + fn poll_resume(&mut self, cx: &mut Context<'_>) -> GetObjectResumePoll { + let Some(mut resume) = self.resume.take() else { + // resume_in_flight guards every call site. + unreachable!("poll_resume requires an armed resume control"); + }; + let outcome = loop { + let stage = std::mem::replace(&mut resume.stage, GetObjectResumeStage::Idle); + match stage { + GetObjectResumeStage::Idle => unreachable!("resume control is only polled while armed"), + GetObjectResumeStage::Backoff => match Pin::new(&mut resume.timer).poll_next(cx) { + Poll::Ready(Some(())) => { + resume.attempts += 1; + resume.stage = GetObjectResumeStage::Reopening(Mutex::new((resume.reopen)(self.emitted))); + } + Poll::Ready(None) => { + let error = resume.take_trigger_error(); + break GetObjectResumePoll::Failed { + error, + attempts: resume.attempts, + }; + } + Poll::Pending => { + resume.stage = GetObjectResumeStage::Backoff; + break GetObjectResumePoll::Pending; + } + }, + GetObjectResumeStage::Reopening(reopening) => { + let poll = match reopening.try_lock() { + Ok(mut reopening) => reopening.as_mut().poll(cx), + // Only reachable when a poll of the reopen future + // panicked and poisoned the mutex: fail closed with the + // original trigger error instead of polling it again. + Err(_) => { + let error = resume.take_trigger_error(); + break GetObjectResumePoll::Failed { + error, + attempts: resume.attempts, + }; + } + }; + match poll { + Poll::Ready(Ok(reader)) => { + self.inner = Some(reader); + break GetObjectResumePoll::Resumed { + attempts: resume.attempts, + }; + } + Poll::Ready(Err(GetObjectResumeFailure::Retryable)) => { + resume.stage = GetObjectResumeStage::Backoff; + } + Poll::Ready(Err(GetObjectResumeFailure::Fatal)) => { + let error = resume.take_trigger_error(); + break GetObjectResumePoll::Failed { + error, + attempts: resume.attempts, + }; + } + Poll::Pending => { + resume.stage = GetObjectResumeStage::Reopening(reopening); + break GetObjectResumePoll::Pending; + } + } + } + } + }; + if matches!(outcome, GetObjectResumePoll::Resumed { .. } | GetObjectResumePoll::Pending) { + self.resume = Some(resume); + } + outcome + } + + fn poll_stall_timeout(&mut self, cx: &mut Context<'_>) -> Poll> { + if self.timeout.is_zero() { + return Poll::Pending; + } + + if self.timer.is_none() { + self.timer = Some(Box::pin(tokio::time::sleep(self.timeout))); + } + + if let Some(timer) = self.timer.as_mut() + && std::future::Future::poll(timer.as_mut(), cx).is_ready() + { + self.timer = None; + warn!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + timeout_ms = self.timeout.as_millis(), + state = "stall_timeout", + "GetObject streaming body stalled" + ); + self.finish_err(); + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "get object streaming body stall timeout", + ))); + } + + Poll::Pending + } +} + +impl AsyncRead for GetObjectStreamingReader { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let filled_before = buf.filled().len(); + + loop { + // An armed resume owns the reader until it swaps in a reopened + // stream or exhausts its budget; the failed inner stream is never + // polled again. + if self.resume_in_flight() { + match self.poll_resume(cx) { + GetObjectResumePoll::Resumed { attempts } => { + debug!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + resume_attempts = attempts, + state = "resumed", + "GetObject streaming body resumed from a reopened object read" + ); + // The replacement stream starts a fresh stall window. + self.timer = None; + continue; + } + GetObjectResumePoll::Pending => return self.poll_stall_timeout(cx), + GetObjectResumePoll::Failed { error, attempts } => { + self.timer = None; + let failure_reason = Self::classify_read_error(&error); + self.finish_err(); + error!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + state = "read_failed", + failure_reason = failure_reason, + resume_attempts = attempts, + error = %error, + "GetObject streaming body read failed; mid-stream resume did not recover" + ); + return Poll::Ready(Err(error)); + } + } + } + + let Some(inner) = self.inner.as_mut() else { + self.finish_err(); + return Poll::Ready(Err(std::io::Error::other( + "get object streaming reader lost its active read outside resume", + ))); + }; + match Pin::new(inner).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + self.timer = None; + let produced = buf.filled().len().saturating_sub(filled_before); + if produced > 0 { + self.emitted = self.emitted.saturating_add(produced); + if !self.first_byte_reported { + self.first_byte_reported = true; + let elapsed = self.elapsed(); + rustfs_io_metrics::record_get_object_first_byte_latency( + GET_OBJECT_STAGE_PATH_S3_HANDLER, + elapsed.as_secs_f64(), + ); + if elapsed >= GET_OBJECT_STREAM_WARN_THRESHOLD { + warn!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = elapsed.as_millis(), + state = "first_byte_slow", + "GetObject streaming body first byte was slow" + ); + } + } + if self.emitted >= self.expected { + self.completed = true; + self.finish_ok(); + } + return Poll::Ready(Ok(())); + } + + if self.emitted < self.expected { + // The inner reader signalled a clean EOF before delivering the full + // Content-Length. Returning Ok here would hand the client a truncated body + // under a full Content-Length: the peer treats the short body as complete + // (e.g. `mc mirror` writes a short file and considers it done — the + // "incomplete data mirroring" in issue #2955). Surface an error instead so + // the transfer fails loudly and the client retries rather than persisting + // truncated data. + let error = std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + rustfs_rio::IncompleteBody { + remaining: self.expected.saturating_sub(self.emitted) as i64, + }, + ); + // A premature EOF is also how the legacy duplex read path + // surfaces the object data vanishing mid-stream (typed + // errors do not survive that pump), so arm the resume + // flow before failing loudly when one is attached. + if self.resume.is_some() { + self.begin_resume(error); + continue; + } + error!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + state = "short_eof", + "GetObject streaming body ended before expected length" + ); + self.finish_err(); + return Poll::Ready(Err(error)); + } + + self.completed = true; + self.finish_ok(); + return Poll::Ready(Ok(())); + } + Poll::Ready(Err(err)) => { + // Typed relocation errors (the codec read path delivers them + // in-band) mean rebalance/decommission removed the pinned + // object data mid-stream: reopen and continue instead of + // failing the download. The error is only intercepted before + // the committed body length has been fully delivered. + if self.emitted < self.expected && is_object_relocation_error(&err) && self.resume.is_some() { + self.begin_resume(err); + continue; + } + let failure_reason = Self::classify_read_error(&err); + self.timer = None; + self.finish_err(); + error!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + state = "read_failed", + failure_reason = failure_reason, + error = %err, + "GetObject streaming body read failed" + ); + return Poll::Ready(Err(err)); + } + Poll::Pending => return self.poll_stall_timeout(cx), + } + } + } +} + +impl Drop for GetObjectStreamingReader { + fn drop(&mut self) { + if self.lifecycle.is_finished() { + return; + } + + if self.expected == 0 || self.completed || self.emitted >= self.expected { + self.finish_ok(); + return; + } + + self.finish_err(); + warn!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + state = "dropped_incomplete", + "GetObject streaming body dropped before expected length" + ); + } +} + +/// Reopen budget for a single GetObject body. Three attempts against the +/// jittered 200ms/400ms RetryTimer schedule (~600ms worst case) bound the +/// metadata fan-out a storm of relocated downloads can multiply. +const GET_OBJECT_RESUME_MAX_ATTEMPTS: i64 = 3; + +type GetObjectResumeFuture = Pin> + Send>>; + +type GetObjectReopen = Box GetObjectResumeFuture + Send + Sync>; + +enum GetObjectResumePoll { + Resumed { attempts: usize }, + Pending, + Failed { error: std::io::Error, attempts: usize }, +} + +/// Why a single resume attempt did not produce a replacement stream. +#[derive(Debug)] +enum GetObjectResumeFailure { + /// Reopen/admission failure that may clear on the next attempt. + Retryable, + /// The reopened object is not the version this response committed to (or + /// admission is permanently unavailable): continuing would splice two + /// versions into one 200 response, so fail with the original error. + Fatal, +} + +enum GetObjectResumeStage { + Idle, + Backoff, + // The store's boxed read futures are Send but not Sync, while the + // streaming body requires the reader to be Sync, so the in-flight reopen + // future is stored behind a mutex. It is only ever locked under `&mut + // self` in `poll_resume`, so the lock never contends. + Reopening(Mutex>), +} + +/// Mid-stream resume machinery for [`GetObjectStreamingReader`]: when the +/// pinned object data vanishes mid-body (rebalance/decommission copies the +/// version elsewhere, then deletes the source), reopen the object at the +/// emitted offset and continue instead of failing the download. +struct GetObjectResumeControl { + reopen: GetObjectReopen, + timer: RetryTimer, + stage: GetObjectResumeStage, + original_error: Option, + attempts: usize, +} + +impl GetObjectResumeControl { + fn new(reopen: GetObjectReopen, timer: RetryTimer) -> Self { + Self { + reopen, + timer, + stage: GetObjectResumeStage::Idle, + original_error: None, + attempts: 0, + } + } + + fn begin(&mut self, error: std::io::Error) { + self.original_error = Some(error); + self.stage = GetObjectResumeStage::Backoff; + } + + // The trigger error is always recorded by `begin`; the fallback is a + // fail-closed internal error, never a fabricated success. + fn take_trigger_error(&mut self) -> std::io::Error { + self.original_error + .take() + .unwrap_or_else(|| std::io::Error::other("get object resume lost its trigger error")) + } +} + +/// Object-version identity captured when the response committed to a body. A +/// resumed read must serve exactly this version; `data_dir` is deliberately +/// excluded because rebalance regenerates it for the same version. +struct GetObjectResumeIdentity { + version_id: Option, + mod_time: Option, + size: i64, + etag: Option, + // The store rewrites a read's `object_info.size` to the per-read delivered + // length for encrypted and compressed objects (readers.rs Encrypted / + // Compressed transforms), so a reopened subrange reports `size - emitted` + // while a plain read reports the range-invariant `oi.size`. The flag only + // chooses the comparison arithmetic; a transform change that no longer + // matches it fails the identity check, which is the closed direction. + range_dependent_size: bool, +} + +impl GetObjectResumeIdentity { + fn matches(&self, info: &ObjectInfo, emitted: usize) -> bool { + let expected_size = if self.range_dependent_size { + self.size - emitted as i64 + } else { + self.size + }; + self.version_id == info.version_id + && self.mod_time == info.mod_time + && expected_size == info.size + && self.etag == info.etag + } +} + +/// Reopen parameters for a mid-stream resume. Only the SSE-C headers the store +/// read path consumes are retained: the store-level `get_object_reader` spans +/// record their header argument at debug level, so retaining the full request +/// headers would re-log credentials on every attempt. +struct GetObjectResumeContext { + store: Arc, + bucket: String, + key: String, + opts: ObjectOptions, + ssec_headers: HeaderMap, + // Resolved plaintext offsets of the committed response body, captured + // after `HTTPRangeSpec::get_offset_length`: suffix ranges and partNumber + // GETs are already resolved to absolute offsets at that point, so the + // resume offset is `range_start + emitted` regardless of request shape. + range_start: i64, + range_end: i64, + identity: GetObjectResumeIdentity, +} + +impl GetObjectResumeContext { + #[allow(clippy::too_many_arguments)] + fn new( + store: Arc, + bucket: &str, + key: &str, + mut opts: ObjectOptions, + request_headers: &HeaderMap, + info: &ObjectInfo, + range_start: i64, + range_end: i64, + ) -> Self { + if opts.version_id.is_none() + && let Some(version_id) = info.version_id + { + opts.version_id = Some(version_id.to_string()); + } + // Store spans record their header argument at debug level. Retain only + // the SSE-C inputs needed to reopen the reader and keep them redacted. + let ssec_headers = project_ssec_transport_headers(request_headers); + Self { + store, + bucket: bucket.to_string(), + key: key.to_string(), + opts, + ssec_headers, + range_start, + range_end, + identity: GetObjectResumeIdentity { + version_id: info.version_id, + mod_time: info.mod_time, + size: info.size, + etag: info.etag.clone(), + range_dependent_size: info.is_encrypted() || info.is_compressed(), + }, + } + } + + fn resume_range(range_start: i64, range_end: i64, emitted: usize) -> Option { + let start = range_start + emitted as i64; + if start == 0 && range_end < 0 { + // Nothing was emitted from a full-object read: reopen without a + // range so the replacement stream keeps the codec fast path + // instead of the duplex fallback a synthesized range forces. + return None; + } + Some(HTTPRangeSpec { + is_suffix_length: false, + start, + end: range_end, + }) + } + + async fn reopen(&self, emitted: usize) -> Result { + #[cfg(test)] + GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.fetch_add(1, Ordering::Relaxed); + + // A resumed read must hold disk-read admission just like the initial + // read; otherwise recovery reads bypass the concurrency caps exactly + // while rebalance is stressing the pool. + let disk_permit = DefaultObjectUsecase::admit_get_object_disk_read(get_concurrency_manager(), &self.bucket, &self.key) + .await + .map_err(|err| { + if err.code() == &S3ErrorCode::SlowDown { + GetObjectResumeFailure::Retryable + } else { + GetObjectResumeFailure::Fatal + } + })?; + let range = Self::resume_range(self.range_start, self.range_end, emitted); + let reader = self + .store + .get_object_reader(&self.bucket, &self.key, range, self.ssec_headers.clone(), &self.opts) + .await + .map_err(|err| { + debug!( + bucket = %self.bucket, + object = %self.key, + error = %err, + "GetObject mid-stream resume reopen failed" + ); + GetObjectResumeFailure::Retryable + })?; + if !self.identity.matches(&reader.object_info, emitted) { + warn!( + bucket = %self.bucket, + object = %self.key, + "GetObject mid-stream resume resolved a different object version; refusing to splice content" + ); + return Err(GetObjectResumeFailure::Fatal); + } + let stream = wrap_reader(reader.stream); + Ok(match disk_permit { + Some(disk_permit) => wrap_reader(DiskReadPermitReader::new(stream, disk_permit)), + None => stream, + }) + } +} + +#[cfg(test)] +static GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST: AtomicUsize = AtomicUsize::new(0); + +fn get_object_resume_control(ctx: GetObjectResumeContext) -> GetObjectResumeControl { + use rand::RngExt as _; + let ctx = Arc::new(ctx); + let reopen: GetObjectReopen = Box::new(move |emitted| { + let ctx = Arc::clone(&ctx); + Box::pin(async move { ctx.reopen(emitted).await }) + }); + GetObjectResumeControl::new( + reopen, + RetryTimer::new( + GET_OBJECT_RESUME_MAX_ATTEMPTS, + DEFAULT_RETRY_UNIT, + DEFAULT_RETRY_CAP, + MAX_JITTER, + rand::rng().random_range(10..=50), + ), + ) +} + +/// Mid-stream errors that mean the pinned object data is gone (rebalance or +/// decommission removed it after copying the version elsewhere). Only typed +/// `StorageError`s qualify; generic I/O errors and string-matched "not enough +/// disks" failures keep the existing fail-loud behavior. +fn is_object_relocation_error(err: &std::io::Error) -> bool { + let Some(inner) = err.get_ref() else { return false }; + match inner.downcast_ref::() { + Some(StorageError::FileNotFound | StorageError::ObjectNotFound(..) | StorageError::InsufficientReadQuorum(..)) => true, + Some(StorageError::Io(source)) => source.kind() == std::io::ErrorKind::NotFound, + _ => false, + } +} + +pub(crate) fn object_seek_support_threshold() -> usize { + static OBJECT_SEEK_SUPPORT_THRESHOLD: OnceLock = OnceLock::new(); + *OBJECT_SEEK_SUPPORT_THRESHOLD.get_or_init(|| { + rustfs_utils::get_env_usize( + rustfs_config::ENV_OBJECT_SEEK_SUPPORT_THRESHOLD, + rustfs_config::DEFAULT_OBJECT_SEEK_SUPPORT_THRESHOLD, + ) + }) +} + +fn object_seek_support_concurrency_thresholds() -> (usize, usize) { + static OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS: OnceLock<(usize, usize)> = OnceLock::new(); + *OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS.get_or_init(|| { + let medium = rustfs_utils::get_env_usize( + rustfs_config::ENV_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD, + rustfs_config::DEFAULT_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD, + ) + .max(1); + let high = rustfs_utils::get_env_usize( + rustfs_config::ENV_OBJECT_HIGH_CONCURRENCY_THRESHOLD, + rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD, + ) + .max(medium + 1); + (medium, high) + }) +} + +fn concurrency_aware_seek_support_threshold(configured_threshold: i64, concurrent_requests: usize) -> i64 { + let (medium_threshold, high_threshold) = object_seek_support_concurrency_thresholds(); + let effective_threshold = configured_threshold.min(MAX_GET_OBJECT_MEMORY_BUFFER_BYTES); + + if concurrent_requests >= high_threshold.saturating_mul(2) { + return effective_threshold.min(VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); + } + if concurrent_requests >= high_threshold { + return effective_threshold.min(HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); + } + if concurrent_requests >= medium_threshold { + return effective_threshold.min(MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); + } + + effective_threshold +} + +fn should_buffer_get_object_in_memory( + info: &ObjectInfo, + response_content_length: i64, + part_number: Option, + has_range: bool, + concurrent_requests: usize, +) -> bool { + let configured_threshold = object_seek_support_threshold() as i64; + should_buffer_get_object_in_memory_with_threshold( + info, + response_content_length, + part_number, + has_range, + configured_threshold, + concurrent_requests, + is_get_seek_buffer_enabled(), + ) +} + +fn should_materialize_get_object_body_for_cache( + info: &ObjectInfo, + response_content_length: i64, + part_number: Option, + has_range: bool, + concurrent_requests: usize, +) -> bool { + let configured_threshold = object_seek_support_threshold() as i64; + should_buffer_get_object_in_memory_with_threshold( + info, + response_content_length, + part_number, + has_range, + configured_threshold, + concurrent_requests, + true, + ) +} + +fn should_buffer_get_object_in_memory_with_threshold( + _info: &ObjectInfo, + response_content_length: i64, + part_number: Option, + has_range: bool, + configured_threshold: i64, + concurrent_requests: usize, + seek_buffer_enabled: bool, +) -> bool { + if !seek_buffer_enabled || part_number.is_some() || has_range || response_content_length <= 0 || configured_threshold <= 0 { + return false; + } + if usize::try_from(response_content_length).is_err() { + return false; + } + + let effective_threshold = concurrency_aware_seek_support_threshold(configured_threshold, concurrent_requests); + if configured_threshold > MAX_GET_OBJECT_MEMORY_BUFFER_BYTES + && GET_OBJECT_BUFFER_THRESHOLD_WARNED + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + warn!( + configured_threshold_bytes = configured_threshold, + hard_limit_bytes = MAX_GET_OBJECT_MEMORY_BUFFER_BYTES, + "RUSTFS_OBJECT_SEEK_SUPPORT_THRESHOLD exceeds safety cap; using capped in-memory buffer threshold" + ); + } + + if response_content_length > effective_threshold { + return false; + } + + true +} + +impl DefaultObjectUsecase { + fn build_memory_bytes_blob( + bytes: Bytes, + response_content_length: i64, + source: &'static str, + lifecycle: GetObjectBodyLifecycle, + ) -> StreamingBlob { + let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled(); + let memory_blob_start = get_stage_metrics_enabled.then(std::time::Instant::now); + let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now); + let bytes_len = bytes.len(); + let guard = rustfs_io_metrics::track_get_object_buffered_bytes(bytes_len); + let remaining = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + let blob = if is_get_small_body_once_enabled() && bytes_len == remaining { + let owner = MemoryOnceBodyOwner::new(bytes, guard, lifecycle); + StreamingBlob::from_bytes(Bytes::from_owner(owner)) + } else { + StreamingBlob::new(MemoryTrackedBytesStream::new(bytes, remaining, source, guard, lifecycle)) + }; + if let Some(handoff_start) = handoff_start { + rustfs_io_metrics::record_get_object_response_handoff( + "single_chunk", + source, + bytes_len, + response_content_length, + handoff_start.elapsed().as_secs_f64(), + ); + } + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_MEMORY_BLOB, memory_blob_start); + blob + } + + fn build_memory_blob( + buf: Vec, + response_content_length: i64, + source: &'static str, + lifecycle: GetObjectBodyLifecycle, + ) -> StreamingBlob { + Self::build_memory_bytes_blob(Bytes::from(buf), response_content_length, source, lifecycle) + } + + fn select_stream_buffer_strategy( + response_content_length: i64, + optimal_buffer_size: usize, + enable_readahead: bool, + has_range: bool, + ) -> (usize, GetObjectStreamStrategy) { + if enable_readahead && !has_range && response_content_length >= LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES { + let expanded_buffer_size = optimal_buffer_size + .saturating_mul(LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER) + .min(LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES) + .max(optimal_buffer_size); + return (expanded_buffer_size, GetObjectStreamStrategy::LargeSequentialReadahead); + } + + (optimal_buffer_size, GetObjectStreamStrategy::Standard) + } + + #[allow(clippy::too_many_arguments)] + fn build_reader_blob( + reader: R, + response_content_length: i64, + request_id: &str, + content_range: Option<&str>, + stream_buffer_size: usize, + stream_strategy: GetObjectStreamStrategy, + bucket: &str, + key: &str, + lifecycle: GetObjectBodyLifecycle, + resume: Option>, + ) -> StreamingBlob + where + R: AsyncRead + Send + Sync + Unpin + 'static, + { + let streaming_blob_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + let tuned_stream_buffer_size = + tune_reader_stream_buffer_size(stream_buffer_size, response_content_length, stream_strategy); + let (stream_buffer_size, buffer_source) = + resolve_reader_stream_buffer_size(tuned_stream_buffer_size, get_reader_stream_buffer_size_override()); + let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled(); + if get_stage_metrics_enabled { + rustfs_io_metrics::record_get_object_stream_strategy( + stream_strategy.as_str(), + stream_buffer_size, + response_content_length, + ); + } + let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now); + let reader = GetObjectStreamingReader::new( + reader, + bucket, + key, + request_id, + content_range.map(|content_range| content_range.to_string()), + expected, + get_object_disk_read_timeout(), + lifecycle, + resume, + ); + let stream = GetObjectReaderStream::new(reader, stream_buffer_size, expected, stream_strategy.as_str(), buffer_source) + .with_diagnostics(bucket, key, request_id); + let blob = StreamingBlob::new(stream); + if let Some(handoff_start) = handoff_start { + rustfs_io_metrics::record_get_object_response_handoff( + stream_strategy.as_str(), + buffer_source, + stream_buffer_size, + response_content_length, + handoff_start.elapsed().as_secs_f64(), + ); + } + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAMING_BLOB, streaming_blob_start); + blob + } + + fn init_get_object_bootstrap(&self, bucket: &str, key: &str, request_id: &str) -> S3Result { + #[cfg(test)] + let timeout_config = self + .get_object_timeout_policy + .clone() + .unwrap_or_else(GetObjectTimeoutPolicy::cached_from_env); + #[cfg(not(test))] + let timeout_config = GetObjectTimeoutPolicy::cached_from_env(); + let wrapper = RequestTimeoutWrapper::with_request_id(timeout_config.clone(), request_id.to_string()); + let request_start = std::time::Instant::now(); + let request_guard = ConcurrencyManager::track_request(); + let concurrent_requests = GetObjectGuard::concurrent_requests(); + + let deadlock_detector = deadlock_detector::get_deadlock_detector(); + let deadlock_request_guard = DeadlockRequestGuard::register_if_enabled(deadlock_detector, wrapper.request_id(), || { + format!("GetObject {bucket}/{key}") + }); + + Self::ensure_get_object_not_timed_out(&wrapper, &timeout_config, bucket, key, GetObjectTimeoutStage::BeforeProcessing)?; + + debug!( + "GetObject request started with {} concurrent requests, timeout={:?}", + concurrent_requests, timeout_config.get_object_timeout + ); + + Ok(GetObjectBootstrap { + timeout_config, + wrapper, + request_start, + request_guard, + _deadlock_request_guard: deadlock_request_guard, + concurrent_requests, + }) + } + + fn validate_get_object_part_number(part_number: Option, info: &ObjectInfo) -> S3Result<()> { + if let Some(part_number) = part_number + && part_number > 1 + && !info.parts.iter().any(|part| part.number == part_number) + { + return Err(s3_error!(InvalidPart)); + } + Ok(()) + } + + fn validate_get_object_before_cold_fill(headers: &HeaderMap, part_number: Option, info: &ObjectInfo) -> S3Result<()> { + check_preconditions(headers, info)?; + Self::validate_get_object_part_number(part_number, info) + } + + /// How long a GET waits for a disk read permit before degrading to a + /// permit-less read. Cached: consulted per GET. Zero disables the bound. + fn disk_permit_wait_timeout() -> Duration { + static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); + *CACHED.get_or_init(|| { + Duration::from_secs(rustfs_utils::get_env_u64( + rustfs_config::ENV_OBJECT_DISK_PERMIT_WAIT_TIMEOUT, + rustfs_config::DEFAULT_OBJECT_DISK_PERMIT_WAIT_TIMEOUT, + )) + }) + } + + async fn acquire_get_object_io_planning( + manager: &ConcurrencyManager, + request_timeout: Option>, + bucket: &str, + key: &str, + ) -> S3Result { + let permit_wait_start = std::time::Instant::now(); + let disk_permit = Self::admit_get_object_disk_read(manager, bucket, key).await?; + let permit_wait_duration = permit_wait_start.elapsed(); + + if let Some(timeout) = request_timeout { + Self::ensure_get_object_not_timed_out( + timeout.wrapper, + timeout.policy, + bucket, + key, + GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration }, + )?; + } + + let queue_status = manager.io_queue_status(); + let queue_snapshot = GetObjectQueueSnapshot::from_available_permits( + queue_status.total_permits, + queue_status.total_permits.saturating_sub(queue_status.permits_in_use), + ); + let queue_utilization = queue_snapshot.utilization_percent(); + + if queue_snapshot.is_congested(80.0) { + // Metrics count every congested request; only the WARN is rate + // limited, because under saturation every GET crosses the + // threshold and per-request WARNs flood the log. + rustfs_io_metrics::record_io_queue_congestion(); + + if let Some(suppressed_warns) = IO_QUEUE_CONGESTION_WARN_THROTTLE.claim(IoQueueCongestionWarnThrottle::now_ms()) { + warn!( + bucket = %bucket, + key = %key, + queue_utilization = format!("{:.1}%", queue_utilization), + permits_in_use = queue_status.permits_in_use, + total_permits = queue_status.total_permits, + suppressed_warns, + "I/O queue congestion detected" + ); + } + } + + if let Some(timeout) = request_timeout { + Self::ensure_get_object_not_timed_out( + timeout.wrapper, + timeout.policy, + bucket, + key, + GetObjectTimeoutStage::BeforeRead, + )?; + } + + Ok(GetObjectIoPlanning { + disk_permit, + permit_wait_duration, + queue_status, + queue_utilization, + }) + } + + // Shared by the initial read path and the mid-stream resume reopen, which + // must hold the same admission token before touching disks. The permit + // wait inside is bounded by the primary-pool timeout. + async fn admit_get_object_disk_read( + manager: &ConcurrencyManager, + bucket: &str, + key: &str, + ) -> S3Result> { + let permit_wait_start = std::time::Instant::now(); + let permit_wait_timeout = Self::disk_permit_wait_timeout(); + // Permits are held for the whole body transfer, so slow clients can pin + // all of them while disks are idle. Bound the wait on the primary pool + // and, on timeout, admit from a bounded degraded overflow lane. Total + // concurrent disk-active GETs are hard-capped at + // `primary_cap + degraded_cap`; once that cap is reached we reject with + // `SlowDown` instead of reading without any admission token. Never + // proceed permit-less. + let disk_permit = match manager + .admit_disk_read(permit_wait_timeout) + .await + .map_err(|_| s3_error!(InternalError, "disk read semaphore closed"))? + { + DiskReadAdmission::Primary(permit) => Some(permit), + // Throttling disabled by config (primary cap 0): proceed without an + // admission token. Not a saturation bypass. + DiskReadAdmission::Unbounded => None, + DiskReadAdmission::Degraded(permit) => { + metrics::counter!("rustfs.get_object.disk_permit.degraded.total").increment(1); + warn!( + bucket = %bucket, + key = %key, + wait_ms = permit_wait_start.elapsed().as_millis() as u64, + "GetObject admitted into bounded degraded disk-read lane after primary pool saturation" + ); + Some(permit) + } + DiskReadAdmission::Rejected => { + metrics::counter!("rustfs.get_object.disk_permit.hard_reject.total").increment(1); + warn!( + bucket = %bucket, + key = %key, + wait_ms = permit_wait_start.elapsed().as_millis() as u64, + "GetObject rejected: disk-read hard concurrency cap reached" + ); + return Err(s3_error!( + SlowDown, + "disk read concurrency limit reached, please reduce your request rate" + )); + } + }; + Ok(disk_permit.map(GetObjectDiskPermit::new)) + } + + async fn acquire_cold_fill_io_planning( + manager: &'static ConcurrencyManager, + bucket: &str, + key: &str, + ) -> Result { + match Self::acquire_get_object_io_planning(manager, None, bucket, key).await { + Ok(io) => Ok(io), + Err(err) if err.code() == &S3ErrorCode::SlowDown => Err(ColdFillError::Storage(StorageError::SlowDown)), + Err(_) => Err(ColdFillError::DiskAdmissionClosed), + } + } + + fn get_object_io_planning_without_disk(manager: &ConcurrencyManager) -> GetObjectIoPlanning { + let queue_status = manager.io_queue_status(); + let queue_snapshot = GetObjectQueueSnapshot::from_available_permits( + queue_status.total_permits, + queue_status.total_permits.saturating_sub(queue_status.permits_in_use), + ); + GetObjectIoPlanning { + disk_permit: None, + permit_wait_duration: Duration::ZERO, + queue_utilization: queue_snapshot.utilization_percent(), + queue_status, + } + } + + /// Cheap request-shape validations, run before the bucket-existence store + /// lookup so invalid requests keep their InvalidArgument precedence. + fn validate_get_object_request(req: &S3Request) -> S3Result { + // Clone only the fields this path needs instead of the whole input. + let bucket = req.input.bucket.clone(); + let key = req.input.key.clone(); + let version_id = req.input.version_id.clone(); + let part_number = req.input.part_number; + let range = req.input.range; + + validate_object_key(&key, "GET")?; + + let part_number = parse_part_number_i32_to_usize(part_number, "GET")?; + + let rs = range.map(range_to_http_range_spec).transpose()?; + + if rs.is_some() && part_number.is_some() { + return Err(s3_error!(InvalidArgument, "range and part_number invalid")); + } + + Ok(GetObjectValidatedRequest { + bucket, + key, + version_id, + part_number, + rs, + }) + } + + async fn prepare_get_object_request_context( + validated: GetObjectValidatedRequest, + headers: &HeaderMap, + ) -> S3Result { + let GetObjectValidatedRequest { + bucket, + key, + version_id, + part_number, + rs, + } = validated; + + let opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), part_number, headers) + .await + .map_err(ApiError::from)?; + + Ok(GetObjectRequestContext { + version_id_for_event: version_id.unwrap_or_default(), + bucket, + key, + part_number, + rs, + opts, + }) + } + + #[allow(clippy::too_many_arguments)] + async fn prepare_get_object_read_execution( + &self, + req: &S3Request, + manager: &'static ConcurrencyManager, + store: Arc, + wrapper: &RequestTimeoutWrapper, + timeout_config: &GetObjectTimeoutPolicy, + bucket: &str, + key: &str, + rs: Option, + opts: &ObjectOptions, + part_number: Option, + object_traffic_health: Option>, + ) -> S3Result { + let read_start = std::time::Instant::now(); + let read_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then_some(read_start); + let store_headers = project_ssec_transport_headers(&req.headers); + let cache_adapter = self.object_data_cache(); + if cache_adapter.is_disabled() || !cache_adapter.materialize_fill_enabled() { + let io_planning = Self::acquire_get_object_io_planning( + manager, + Some(GetObjectRequestTimeout { + wrapper, + policy: timeout_config, + }), + bucket, + key, + ) + .await?; + let reader = track_object_read_setup( + object_traffic_health.as_deref(), + store.get_object_reader(bucket, key, rs.clone(), store_headers, opts), + ) + .await + .map_err(map_get_object_reader_error)?; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; + return Ok(GetObjectPreparedRead { io_planning, read_setup }); + } + + // Preserve the legacy metadata-fanout bound without making followers + // hold a body-transfer permit while they wait on the cold-fill session. + let mut metadata_admission = Some( + Self::acquire_get_object_io_planning( + manager, + Some(GetObjectRequestTimeout { + wrapper, + policy: timeout_config, + }), + bucket, + key, + ) + .await?, + ); + let mut prepared = Some( + track_object_read_setup( + object_traffic_health.as_deref(), + store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts), + ) + .await + .map_err(map_get_object_reader_error)?, + ); + let mut cache_fill_allowed = true; + let mut legacy_hook_missed = false; + 'snapshot: { + let info = prepared + .as_ref() + .ok_or_else(|| s3_error!(InternalError, "prepared metadata snapshot is unavailable"))? + .object_info(); + // Preconditions, cache planning, and the authoritative hook lookup all + // run against one namespace-locked metadata snapshot. Cacheable misses + // release both the lock and short admission before joining cold fill. + let Some(response_content_length) = get_object_body_cache_plaintext_len(&rs, opts, info) else { + break 'snapshot; + }; + let cache_plan = build_get_object_body_cache_plan( + &cache_adapter, + GetObjectBodyCacheRequest { + bucket, + key, + info, + response_content_length, + has_range: rs.is_some(), + part_number, + encryption_applied: info.is_encrypted(), + }, + ); + + // The legacy hook is evaluated once, before cold-fill coordination. + // In-session producer retries never re-enter this snapshot block. + let legacy_probe = lookup_preplanned_get_object_body_cache_hook( + Arc::clone(&cache_adapter), + cache_plan.clone(), + bucket, + key, + &rs, + opts, + info, + ) + .await; + if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Ineligible) { + break 'snapshot; + } + Self::validate_get_object_before_cold_fill(&req.headers, part_number, info)?; + if let GetObjectBodyCacheHookLookup::Hit(body) = legacy_probe { + drop(metadata_admission.take()); + let info = prepared + .take() + .ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))? + .into_object_info(); + let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; + return Ok(GetObjectPreparedRead { + io_planning: Self::get_object_io_planning_without_disk(manager), + read_setup, + }); + } + if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Miss) { + legacy_hook_missed = true; + } + if !legacy_hook_missed + && let GetObjectBodyCacheLookup::Hit(body) = lookup_get_object_body_cache_hit(&cache_adapter, &cache_plan).await + { + drop(metadata_admission.take()); + let info = prepared + .take() + .ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))? + .into_object_info(); + let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; + return Ok(GetObjectPreparedRead { + io_planning: Self::get_object_io_planning_without_disk(manager), + read_setup, + }); + } + + let GetObjectBodyCachePlan::Cacheable(engine_plan) = &cache_plan else { + break 'snapshot; + }; + let Some(cache_key) = cache_plan.key().cloned() else { + break 'snapshot; + }; + let expected = usize::try_from(response_content_length) + .map_err(|_| s3_error!(InternalError, "cold-fill body length is not representable"))?; + let response_size = u64::try_from(response_content_length) + .map_err(|_| s3_error!(InternalError, "cold-fill body length is negative"))?; + let waiter_deadline = cold_fill_deadline(wrapper, timeout_config, response_size); + let proposed_producer_deadline = cold_fill_producer_deadline(timeout_config, response_size); + let coordinator = cache_adapter.cold_fill_coordinator(); + let info = prepared + .take() + .ok_or_else(|| s3_error!(InternalError, "prepared cold-fill reader is unavailable"))? + .into_object_info(); + drop(metadata_admission.take()); + let outcome = coordinate_cold_fill(&coordinator, cache_key, waiter_deadline, Some(proposed_producer_deadline), { + let adapter = &cache_adapter; + let headers = &store_headers; + let store = &store; + let range = &rs; + let object_traffic_health = &object_traffic_health; + move |producer| { + let adapter = Arc::clone(adapter); + let engine_plan = engine_plan.clone(); + let h = headers.clone(); + let store = Arc::clone(store); + let range = range.clone(); + let bucket = bucket.to_owned(); + let key = key.to_owned(); + let opts = opts.clone(); + let object_traffic_health = object_traffic_health.as_ref().map(Arc::clone); + async move { + let producer_deadline = producer.deadline(); + let cancellation = producer.cancellation_token(); + let second_chance = match await_cold_fill_startup( + lookup_cold_fill_second_chance(&adapter, &engine_plan), + &cancellation, + producer_deadline, + ) + .await + { + Ok(body) => body, + Err(ColdFillStartupWaitError::Cancelled) => { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + Err(ColdFillStartupWaitError::DeadlineExceeded) => { + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + return; + } + }; + if let Some(body) = second_chance { + producer.finish_shared(Ok(body)); + return; + } + + let acquire = Self::acquire_cold_fill_io_planning(manager, &bucket, &key); + let producer_io = match await_cold_fill_startup(acquire, &cancellation, producer_deadline).await { + Ok(result) => result, + Err(ColdFillStartupWaitError::Cancelled) => { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + Err(ColdFillStartupWaitError::DeadlineExceeded) => { + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + return; + } + }; + let producer_io = match producer_io { + Ok(io) => io, + Err(err) => { + producer.finish_shared(Err(err)); + return; + } + }; + + let prepare = track_object_read_setup( + object_traffic_health.as_deref(), + store.prepare_get_object_reader(&bucket, &key, range.clone(), HeaderMap::new(), &opts), + ); + let prepared = match match await_cold_fill_startup(prepare, &cancellation, producer_deadline).await { + Ok(result) => result, + Err(ColdFillStartupWaitError::Cancelled) => { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + Err(ColdFillStartupWaitError::DeadlineExceeded) => { + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + return; + } + } { + Ok(prepared) => prepared, + Err(err) => { + producer.relinquish_or_finish(ColdFillError::Storage(err)); + return; + } + }; + let current_info = prepared.object_info(); + let current_length = match current_info.get_actual_size() { + Ok(length) => length, + Err(err) => { + let _ = err; + producer.finish_shared(Err(ColdFillError::Storage(StorageError::FileCorrupt))); + return; + } + }; + let current_plan = build_get_object_body_cache_plan_for_revalidation( + &adapter, + GetObjectBodyCacheRequest { + bucket: &bucket, + key: &key, + info: current_info, + response_content_length: current_length, + has_range: range.is_some(), + part_number, + encryption_applied: current_info.is_encrypted(), + }, + ); + let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, ¤t_plan, &engine_plan) + else { + return; + }; + + let reservation = adapter.reserve_body(&engine_plan); + #[cfg(test)] + let reader_open_plan = engine_plan.clone(); + start_cold_fill_producer( + producer, + reservation, + || async move { Ok(producer_io) }, + || { + #[cfg(test)] + record_cold_fill_reader_open_for_test(&reader_open_plan); + let open_reader = prepared.with_headers(h).into_reader(); + async move { track_object_read_setup(object_traffic_health.as_deref(), open_reader).await } + }, + ColdFillProducerExecution { + expected, + deadline: producer_deadline, + adapter, + engine_plan, + }, + ) + .await; + } + } + }) + .await; + + match outcome { + ColdFillCoordinateOutcome::Ready(result) => { + let body = match result { + Ok(body) => body, + Err(ColdFillError::Storage(err)) => return Err(map_get_object_reader_error(err).into()), + Err(ColdFillError::DiskAdmissionClosed) => { + return Err(s3_error!(InternalError, "disk read semaphore closed")); + } + }; + let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true) + .await?; + return Ok(GetObjectPreparedRead { + io_planning: Self::get_object_io_planning_without_disk(manager), + read_setup, + }); + } + ColdFillCoordinateOutcome::Bypass => { + cache_fill_allowed = false; + break 'snapshot; + } + ColdFillCoordinateOutcome::Rejected => return Err(ApiError::from(StorageError::SlowDown).into()), + } + } + + let (io_planning, reader) = if let Some(prepared) = prepared.take() { + let io_planning = metadata_admission + .take() + .ok_or_else(|| s3_error!(InternalError, "prepared metadata admission is unavailable"))?; + let reader = + track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader()) + .await + .map_err(map_get_object_reader_error)?; + (io_planning, reader) + } else { + let io_planning = Self::acquire_get_object_io_planning( + manager, + Some(GetObjectRequestTimeout { + wrapper, + policy: timeout_config, + }), + bucket, + key, + ) + .await?; + let reader = if legacy_hook_missed { + let prepared = track_object_read_setup( + object_traffic_health.as_deref(), + store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts), + ) + .await + .map_err(map_get_object_reader_error)?; + track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader()) + .await + .map_err(map_get_object_reader_error)? + } else { + track_object_read_setup( + object_traffic_health.as_deref(), + store.get_object_reader(bucket, key, rs.clone(), store_headers, opts), + ) + .await + .map_err(map_get_object_reader_error)? + }; + (io_planning, reader) + }; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, cache_fill_allowed) + .await?; + if let Some(read_stage_start) = read_stage_start { + rustfs_io_metrics::record_get_object_stage_duration( + "s3_handler", + "store_reader_setup", + read_stage_start.elapsed().as_secs_f64(), + ); + } + Ok(GetObjectPreparedRead { io_planning, read_setup }) + } + + #[allow(clippy::too_many_arguments)] + async fn finish_get_object_read( + req: &S3Request, + manager: &ConcurrencyManager, + bucket: &str, + key: &str, + mut rs: Option, + part_number: Option, + read_start: std::time::Instant, + reader: GetObjectReader, + cache_fill_allowed: bool, + ) -> S3Result { + // ODC-16: capture whether the ecstore cache hook already probed this + // read, so the app layer does not repeat the lookup it ran after fresh + // metadata resolution. + let cache_hook_served = reader.is_cache_hook_served(); + let cache_hook_probed = reader.cache_hook_probed(); + let info = reader.object_info; + let stream = reader.stream; + let buffered_body = reader.buffered_body; + + let read_duration = read_start.elapsed(); + + // Conditional metrics recording to reduce overhead + if rustfs_io_metrics::get_stage_metrics_enabled() { + use rustfs_io_metrics::record_zero_copy_read; + record_zero_copy_read(info.size as usize, read_duration.as_secs_f64() * 1000.0); + manager.record_disk_operation(info.size as u64, read_duration, true).await; + } + + check_preconditions(&req.headers, &info)?; + Self::validate_get_object_part_number(part_number, &info)?; + + debug!(object_size = info.size, part_count = info.parts.len(), "GET object metadata snapshot"); + for part in info.parts.iter() { + debug!( + part_number = part.number, + part_size = part.size, + part_actual_size = part.actual_size, + "GET object part details" + ); + } + + let content_type = if let Some(content_type) = &info.content_type { + match ContentType::from_str(content_type) { + Ok(res) => Some(res), + Err(err) => { + error!(content_type, error = ?err, "GET object content-type parse failed"); + None + } + } + } else { + None + }; + let last_modified = info.mod_time.map(Timestamp::from); + + if let Some(part_number) = part_number + && rs.is_none() + { + rs = HTTPRangeSpec::from_part_sizes( + info.size, + part_number, + info.parts.iter().map(|part| { + if part.actual_size > 0 { + part.actual_size + } else { + i64::try_from(part.size).unwrap_or(i64::MAX) + } + }), + ); + } + + validate_sse_headers_for_read(&info.user_defined, &req.headers)?; + + let mut content_length = info.get_actual_size().map_err(ApiError::from)?; + let (resume_range_start, resume_range_end, content_range) = if let Some(rs) = &rs { + let total_size = content_length; + let (start, length) = rs.get_offset_length(total_size).map_err(ApiError::from)?; + content_length = length; + let start = start as i64; + // Inclusive end of the committed body; may precede `start` when a + // zero-length range was requested, in which case the body completes + // immediately and the resume range is never consulted. + ( + start, + start + length - 1, + Some(format!("bytes {}-{}/{}", start, start + length - 1, total_size)), + ) + } else { + (0, -1, None) + }; + + debug!( + "GET object metadata check: parts={}, provided_sse_key={:?}", + info.parts.len(), + req.input.sse_customer_key.is_some() + ); + + let read_principal = SseKmsPrincipal::from_request(req); + let decryption_request = DecryptionRequest { + bucket, + key, + metadata: &info.user_defined, + sse_customer_key: req.input.sse_customer_key.as_ref(), + sse_customer_key_md5: req.input.sse_customer_key_md5.as_ref(), + principal: read_principal.as_ref(), + }; + + let response_content_length = content_length; + + let ( + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + encryption_applied, + final_stream, + buffered_body, + ) = match classify_sse_read_response(decryption_request).await? { + // The stream is already decrypted by the object layer's encryption + // resolver; only the response headers, authorization and audit + // summary are derived here, without a second KMS unwrap. + Some(headers) => ( + Some(headers.server_side_encryption), + headers.sse_customer_algorithm, + headers.sse_customer_key_md5, + headers.ssekms_key_id, + true, + wrap_reader(stream), + None, + ), + None => (None, None, None, None, false, wrap_reader(stream), buffered_body), + }; + + Ok(GetObjectReadSetup { + info, + final_stream, + buffered_body, + cache_hook_served, + cache_hook_probed, + cache_fill_allowed, + rs, + content_type, + last_modified, + response_content_length, + content_range, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + encryption_applied, + resume_range_start, + resume_range_end, + }) + } + + #[allow(clippy::too_many_arguments)] + fn finalize_get_object_strategy( + &self, + manager: &ConcurrencyManager, + bucket: &str, + key: &str, + info: &ObjectInfo, + rs: Option<&HTTPRangeSpec>, + response_content_length: i64, + permit_wait_duration: Duration, + queue_utilization: f64, + queue_status: &concurrency::IoQueueStatus, + concurrent_requests: usize, + ) -> GetObjectStrategyContext { + let base_buffer_size = if response_content_length > 0 { + get_buffer_size_opt_in(response_content_length) + } else { + self.base_buffer_size() + }; + + let is_sequential_hint = if rs.is_none() { + true + } else if let Some(range_spec) = rs { + range_spec.start == 0 && !range_spec.is_suffix_length + } else { + false + }; + + // Conditional metrics recording to reduce overhead + if rustfs_io_metrics::get_stage_metrics_enabled() { + if let Some(range_spec) = rs + && range_spec.start >= 0 + { + manager.record_access(range_spec.start as u64, response_content_length as u64); + } + + if response_content_length > 0 { + manager.record_transfer(response_content_length as u64, permit_wait_duration); + } + } + + let io_strategy = + manager.calculate_io_strategy_with_context(info.size, base_buffer_size, permit_wait_duration, is_sequential_hint); + + debug!( + wait_ms = permit_wait_duration.as_millis() as u64, + load_level = ?io_strategy.load_level, + buffer_size = io_strategy.buffer_size, + buffer_multiplier = io_strategy.buffer_multiplier, + readahead = io_strategy.enable_readahead, + storage_media = ?io_strategy.storage_media, + access_pattern = ?io_strategy.access_pattern, + bandwidth_tier = ?io_strategy.bandwidth_tier, + concurrent_requests = io_strategy.concurrent_requests, + file_size = info.size, + is_sequential = is_sequential_hint, + "Enhanced multi-factor I/O strategy calculated" + ); + + let io_priority = manager.get_io_priority(response_content_length); + + if manager.is_priority_scheduling_enabled() { + debug!( + bucket = %bucket, + key = %key, + priority = %io_priority, + request_size = response_content_length, + "I/O priority assigned (based on actual request size)" + ); + } + + rustfs_io_metrics::record_get_object_io_state( + permit_wait_duration.as_secs_f64(), + queue_utilization, + queue_status.permits_in_use, + queue_status.total_permits.saturating_sub(queue_status.permits_in_use), + io_strategy.load_level.as_str(), + io_strategy.buffer_multiplier, + ); + rustfs_io_metrics::record_io_priority_assignment(io_priority.as_str()); + + debug!( + actual_request_size = response_content_length, + priority = %io_priority.as_str(), + "I/O priority finalized with actual request size" + ); + + let optimal_buffer_size = if io_strategy.buffer_size > 0 { + io_strategy.buffer_size + } else { + get_concurrency_aware_buffer_size(response_content_length, base_buffer_size) + }; + + debug!( + "GetObject buffer sizing: file_size={}, base={}, optimal={}, concurrent_requests={}, io_strategy={:?}", + response_content_length, base_buffer_size, optimal_buffer_size, concurrent_requests, io_strategy.load_level + ); + let enable_readahead = io_strategy.enable_readahead; + + GetObjectStrategyContext { + io_strategy, + optimal_buffer_size, + enable_readahead, + } + } + + fn build_get_object_checksums( + info: &ObjectInfo, + headers: &HeaderMap, + part_number: Option, + rs: Option<&HTTPRangeSpec>, + ) -> S3Result { + if let Some(checksum_mode) = headers.get(AMZ_CHECKSUM_MODE) + && checksum_mode.to_str().unwrap_or_default() == "ENABLED" + && rs.is_none() + { + let (decrypted_checksums, is_multipart) = info.decrypt_checksums(part_number.unwrap_or(0), headers).map_err(|e| { + error!(error = %e, "GetObject checksum decryption failed"); + ApiError::from(e) + })?; + + return Ok(classify_response_checksums(decrypted_checksums, is_multipart)); + } + + Ok(ResponseChecksums::default()) + } + + #[allow(clippy::too_many_arguments)] + async fn build_get_object_body( + final_stream: R, + info: &ObjectInfo, + response_content_length: i64, + request_id: &str, + content_range: Option<&str>, + optimal_buffer_size: usize, + enable_readahead: bool, + concurrent_requests: usize, + part_number: Option, + has_range: bool, + encryption_applied: bool, + buffered_body: Option, + bucket: &str, + key: &str, + mut lifecycle: GetObjectBodyLifecycle, + resume: F, + ) -> S3Result + where + R: AsyncRead + Send + Sync + Unpin + 'static, + F: FnOnce(&ObjectInfo) -> Option>, + { + if encryption_applied { + let should_buffer_encrypted_object = + should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests); + + if should_buffer_encrypted_object { + // Strict materialization (#1324): a decrypted body that is shorter + // or longer than the declared content length must hard-fail before + // headers, not warn-and-serve a truncated/over-long body. + let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ).await { + Ok(buf) => { + return Ok(Self::build_memory_blob( + buf, + response_content_length, + GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER, + lifecycle, + )); + } + Err(e) => { + lifecycle.finish_err(); + error!(error = %e, "GetObject decrypted object strict materialization failed"); + return Err(e.into_s3_error(response_content_length)); + } + } + } + + debug!(buffer_size = optimal_buffer_size, "Encrypted object uses streaming decrypt path"); + let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let (stream_buffer_size, stream_strategy) = + Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range); + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start); + return Ok(Self::build_reader_blob( + final_stream, + response_content_length, + request_id, + content_range, + stream_buffer_size, + stream_strategy, + bucket, + key, + lifecycle, + resume(info), + )); + } + + if let Some(buffered_body) = buffered_body { + // Strict materialization (#1324): the buffered body is the exact + // response payload; a length disagreement means an upstream/cache bug + // and must hard-fail before headers rather than serve a body that does + // not match its committed Content-Length. + let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + if buffered_body.len() != expected { + lifecycle.finish_err(); + error!( + expected = response_content_length, + actual = buffered_body.len(), + "Buffered GetObject body length mismatch" + ); + return Err(ApiError::from(StorageError::other(format!( + "Buffered GetObject body length mismatch: expected {response_content_length}, got {}", + buffered_body.len() + ))) + .into()); + } + + return Ok(Self::build_memory_bytes_blob( + buffered_body, + response_content_length, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + lifecycle, + )); + } + + let should_provide_seek_support = + should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests); + + if should_provide_seek_support { + // Strict materialization (#1324): the previous implementation only + // logged a warning on a length mismatch, and — most dangerously — on a read + // error it fell through to streaming the *same* reader after + // `read_to_end` had already drained K bytes, shipping a body missing + // its prefix (prefix-misaligned data). Both are now hard errors: an + // exact-length read is required, and any read error returns without + // reusing the partially consumed reader. + let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await { + Ok(buf) => { + return Ok(Self::build_memory_blob( + buf, + response_content_length, + GET_MEMORY_BODY_SOURCE_SEEK_BUFFER, + lifecycle, + )); + } + Err(e) => { + lifecycle.finish_err(); + error!( + error = %e, + "GetObject seek-support strict materialization failed; refusing to reuse the partially consumed reader" + ); + return Err(e.into_s3_error(response_content_length)); + } + } + } + + let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let (stream_buffer_size, stream_strategy) = + Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range); + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start); + Ok(Self::build_reader_blob( + final_stream, + response_content_length, + request_id, + content_range, + stream_buffer_size, + stream_strategy, + bucket, + key, + lifecycle, + resume(info), + )) + } + + #[allow(clippy::too_many_arguments)] + async fn build_get_object_body_with_cache( + cache_adapter: &ObjectDataCacheAdapter, + final_stream: R, + info: &ObjectInfo, + response_content_length: i64, + request_id: &str, + content_range: Option<&str>, + optimal_buffer_size: usize, + enable_readahead: bool, + concurrent_requests: usize, + part_number: Option, + has_range: bool, + encryption_applied: bool, + mut buffered_body: Option, + cache_hook_served: bool, + cache_hook_probed: bool, + cache_fill_allowed: bool, + bucket: &str, + key: &str, + mut lifecycle: GetObjectBodyLifecycle, + resume: F, + ) -> S3Result + where + R: AsyncRead + Send + Sync + Unpin + 'static, + F: FnOnce(&ObjectInfo) -> Option>, + { + // ODC-16 (backlog#1121): when the ecstore hook or shared cold fill + // already supplied this body, the request-level plan was built before + // the authoritative lookup. Serve it without planning a second time. + if cache_hook_served && let Some(bytes) = buffered_body.take() { + return Ok(Self::build_memory_bytes_blob( + bytes, + response_content_length, + GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE, + lifecycle, + )); + } + + if !cache_fill_allowed { + return Self::build_get_object_body( + final_stream, + info, + response_content_length, + request_id, + content_range, + optimal_buffer_size, + enable_readahead, + concurrent_requests, + part_number, + has_range, + encryption_applied, + buffered_body, + bucket, + key, + lifecycle, + resume, + ) + .await; + } + + let cache_request = GetObjectBodyCacheRequest { + bucket, + key, + info, + response_content_length, + has_range, + part_number, + encryption_applied, + }; + let cache_plan = build_get_object_body_cache_plan(cache_adapter, cache_request); + + // ODC-16: only look up when the hook did not probe this read. When it did + // probe (a served body handled above, or a miss), its result is + // authoritative because it ran after fresh metadata resolution, so the + // app layer skips its own lookup and only uses the plan to fill. + if !cache_hook_probed { + match lookup_get_object_body_cache_hit(cache_adapter, &cache_plan).await { + GetObjectBodyCacheLookup::Hit(bytes) => { + return Ok(Self::build_memory_bytes_blob( + bytes, + response_content_length, + GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE, + lifecycle, + )); + } + GetObjectBodyCacheLookup::Disabled | GetObjectBodyCacheLookup::Skip | GetObjectBodyCacheLookup::Miss => {} + } + } + + if let Some(buffered_body) = buffered_body { + // ODC-15: the body is already fully in hand, so keep the fill off the + // response's critical path. For a cacheable plan, run the fill in a + // detached task (Bytes is a cheap clone) and return immediately. For + // a non-cacheable plan the fill is a pure metric-only skip with no + // I/O, so record it inline to preserve observability. + if cache_fill_allowed && matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_)) { + let cache_adapter = cache_adapter.clone(); + let cache_plan = cache_plan.clone(); + let fill_bytes = buffered_body.clone(); + tokio::spawn(async move { + let _ = fill_get_object_body_cache_from_buffered_body(&cache_adapter, &cache_plan, &fill_bytes).await; + }); + } else if cache_fill_allowed { + let _ = fill_get_object_body_cache_from_buffered_body(cache_adapter, &cache_plan, &buffered_body).await; + } + + return Ok(Self::build_memory_bytes_blob( + buffered_body, + response_content_length, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + lifecycle, + )); + } + + let should_materialize_for_cache = cache_adapter.materialize_fill_enabled() + && cache_fill_allowed + && matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_)) + && should_materialize_get_object_body_for_cache( + info, + response_content_length, + part_number, + has_range, + concurrent_requests, + ); + + if should_materialize_for_cache { + let Ok(materialized_capacity) = usize::try_from(response_content_length) else { + warn!( + expected = response_content_length, + "GetObject materialize-fill skipped because content length is not representable" + ); + return Self::build_get_object_body( + final_stream, + info, + response_content_length, + request_id, + content_range, + optimal_buffer_size, + enable_readahead, + concurrent_requests, + part_number, + has_range, + encryption_applied, + None, + bucket, + key, + lifecycle, + resume, + ) + .await; + }; + // ODC-07 / #1324: share the strict exact-length materialization gate + // with the encrypted and seek memory branches. The helper bounds the + // read to `capacity + 1` (so an over-long stream is detected without + // buffering it unbounded), rejects short and over-long reads, and on a + // partial-read error refuses to reuse the consumed reader. + match strict_materialize_object_body( + final_stream, + materialized_capacity, + GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ, + ) + .await + { + Ok(buf) => { + let bytes = Bytes::from(buf); + // ODC-15: fill off the response's critical path (see the + // buffered-body branch above). + let cache_adapter = cache_adapter.clone(); + let cache_plan = cache_plan.clone(); + let fill_bytes = bytes.clone(); + tokio::spawn(async move { + let _ = fill_get_object_body_cache_from_materialized_body(&cache_adapter, &cache_plan, &fill_bytes).await; + }); + + return Ok(Self::build_memory_bytes_blob( + bytes, + response_content_length, + GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED, + lifecycle, + )); + } + Err(e) => { + lifecycle.finish_err(); + error!(error = %e, "GetObject materialize-fill strict materialization failed"); + // A short/over-long body would ship a truncated or over-long + // response; a partial-read error leaves the stream consumed so + // falling back to streaming would send a prefix-misaligned + // body. Both fail the request. + return Err(e.into_s3_error(response_content_length)); + } + } + } + + Self::build_get_object_body( + final_stream, + info, + response_content_length, + request_id, + content_range, + optimal_buffer_size, + enable_readahead, + concurrent_requests, + part_number, + has_range, + encryption_applied, + None, + bucket, + key, + lifecycle, + resume, + ) + .await + } + + fn finalize_get_object_completion( + wrapper: &RequestTimeoutWrapper, + timeout_config: &GetObjectTimeoutPolicy, + total_duration: Duration, + response_content_length: i64, + optimal_buffer_size: usize, + ) { + rustfs_io_metrics::record_get_object_completion( + total_duration.as_secs_f64(), + response_content_length, + optimal_buffer_size, + ); + + rustfs_io_metrics::record_get_object(total_duration.as_millis() as f64, response_content_length); + + if wrapper.is_timeout() { + warn!( + "GetObject request exceeded timeout: duration={:?} timeout={:?}", + wrapper.elapsed(), + timeout_config.get_object_timeout + ); + rustfs_io_metrics::record_get_object_timeout(None, Some(wrapper.elapsed().as_secs_f64())); + } + + debug!( + "GetObject completed: size={} duration={:?} buffer={}", + response_content_length, total_duration, optimal_buffer_size + ); + } + + fn ensure_get_object_not_timed_out( + wrapper: &RequestTimeoutWrapper, + timeout_config: &GetObjectTimeoutPolicy, + bucket: &str, + key: &str, + stage: GetObjectTimeoutStage, + ) -> S3Result<()> { + if !wrapper.is_timeout() { + return Ok(()); + } + + let timeout_secs = timeout_config.get_object_timeout.as_secs(); + let elapsed_ms = wrapper.elapsed().as_millis(); + + match stage { + GetObjectTimeoutStage::BeforeProcessing => { + warn!( + bucket = %bucket, + key = %key, + timeout_secs, + elapsed_ms, + "GetObject request timed out before processing" + ); + Err(s3_error!(InternalError, "Request timeout before processing")) + } + GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration } => { + warn!( + bucket = %bucket, + key = %key, + wait_ms = permit_wait_duration.as_millis(), + timeout_secs, + elapsed_ms, + "GetObject request timed out while waiting for disk permit" + ); + rustfs_io_metrics::record_get_object_timeout(Some("disk_permit"), Some(wrapper.elapsed().as_secs_f64())); + Err(s3_error!(InternalError, "Request timeout while waiting for disk permit")) + } + GetObjectTimeoutStage::BeforeRead => { + warn!( + bucket = %bucket, + key = %key, + timeout_secs, + elapsed_ms, + "GetObject request timed out before reading object" + ); + rustfs_io_metrics::record_get_object_timeout(Some("before_read"), Some(wrapper.elapsed().as_secs_f64())); + Err(s3_error!(InternalError, "Request timeout before reading object")) + } + } + } + + #[allow(clippy::too_many_arguments)] + async fn finalize_get_object_response( + helper: OperationHelper, + bucket: &str, + method: &hyper::Method, + headers: &HeaderMap, + event_info: Option, + version_id_for_event: String, + output: GetObjectOutput, + extra_checksum_headers: Vec<(&'static str, String)>, + ) -> S3Result> { + let helper = match event_info { + Some(event_info) => helper.object(event_info), + None => helper, + }; + let helper = helper.version_id(version_id_for_event); + let mut response = wrap_response_with_cors(bucket, method, headers, output).await; + inject_accept_ranges_header(&mut response.headers); + // Emit XXHash3/64/128 and SHA-512 checksums that s3s GetObjectOutput cannot + // carry (#1257). This is the download-side integrity path AWS SDKs verify. + inject_additional_checksum_headers(&mut response.headers, &extra_checksum_headers); + let result = Ok(response); + let _ = helper.complete(&result); + result + } + + #[allow(clippy::too_many_arguments)] + async fn build_get_object_output_context( + &self, + req: &S3Request, + manager: &ConcurrencyManager, + bucket: &str, + key: &str, + info: ObjectInfo, + event_info: Option, + final_stream: DynReader, + buffered_body: Option, + cache_hook_served: bool, + cache_hook_probed: bool, + cache_fill_allowed: bool, + rs: Option, + content_type: Option, + last_modified: Option, + response_content_length: i64, + content_range: Option, + request_id: &str, + server_side_encryption: Option, + sse_customer_algorithm: Option, + sse_customer_key_md5: Option, + ssekms_key_id: Option, + encryption_applied: bool, + permit_wait_duration: Duration, + queue_utilization: f64, + queue_status: &concurrency::IoQueueStatus, + concurrent_requests: usize, + part_number: Option, + versioned: bool, + lifecycle: GetObjectBodyLifecycle, + resume: F, + ) -> S3Result + where + F: FnOnce(&ObjectInfo) -> Option>, + { + let strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let strategy = self.finalize_get_object_strategy( + manager, + bucket, + key, + &info, + rs.as_ref(), + response_content_length, + permit_wait_duration, + queue_utilization, + queue_status, + concurrent_requests, + ); + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_OUTPUT_STRATEGY, strategy_start); + let GetObjectStrategyContext { + io_strategy: _, + optimal_buffer_size, + enable_readahead, + } = strategy; + let cache_adapter = self.object_data_cache(); + + let body_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let body = Self::build_get_object_body_with_cache( + &cache_adapter, + final_stream, + &info, + response_content_length, + request_id, + content_range.as_deref(), + optimal_buffer_size, + enable_readahead, + concurrent_requests, + part_number, + rs.is_some(), + encryption_applied, + buffered_body, + cache_hook_served, + cache_hook_probed, + cache_fill_allowed, + bucket, + key, + lifecycle, + resume, + ) + .await?; + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_BUILD, body_build_start); + + let checksum_headers_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let checksums = Self::build_get_object_checksums(&info, &req.headers, part_number, rs.as_ref())?; + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_CHECKSUM_HEADERS, checksum_headers_start); + + let output_version_id = if versioned { + info.version_id.map(|vid| { + if vid == Uuid::nil() { + "null".to_string() + } else { + vid.to_string() + } + }) + } else { + None + }; + + // x-amz-restore: extract from object metadata + let restore = info.user_defined.get(X_AMZ_RESTORE.as_str()).and_then(|v| { + let rs = parse_restore_obj_status(v).ok()?; + Some(rs.to_string2()) + }); + + // x-amz-expiration: predict from lifecycle configuration + let lifecycle_expiration_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let expiration = resolve_put_object_expiration(bucket, &info).await; + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION, lifecycle_expiration_start); + let storage_class = response_storage_class(&info, &info.user_defined); + let cache_control = info.user_defined.get("cache-control").cloned(); + let content_disposition = info.user_defined.get("content-disposition").cloned(); + + let metadata_filter_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let metadata = filter_object_metadata(&info.user_defined); + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_METADATA_FILTER, metadata_filter_start); + + let output = GetObjectOutput { + body: Some(body), + content_length: Some(response_content_length), + last_modified, + content_type, + content_encoding: info.content_encoding.clone(), + cache_control, + content_disposition, + content_range, + e_tag: info.etag.map(|etag| to_s3s_etag(&etag)), + metadata, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + checksum_crc32: checksums.crc32, + checksum_crc32c: checksums.crc32c, + checksum_sha1: checksums.sha1, + checksum_sha256: checksums.sha256, + checksum_crc64nvme: checksums.crc64nvme, + checksum_type: checksums.checksum_type, + version_id: output_version_id, + restore, + expiration, + storage_class, + ..Default::default() + }; + + Ok(GetObjectOutputContext { + output, + event_info, + response_content_length, + optimal_buffer_size, + extra_checksum_headers: checksums.extra, + }) + } + + /// Serve a GET whose local read failed with not-found by proxying to the + /// bucket's replication targets (MinIO `proxyGetToReplicationTarget`, + /// backlog#1675 P1-5). Returns None when no target can serve the object; + /// the caller then returns the original local error. + async fn proxy_get_object_to_replication_targets( + req: &S3Request, + bucket: &str, + key: &str, + opts: &ObjectOptions, + ) -> Option { + let targets = get_read_proxy_targets(bucket, key, opts).await; + if targets.is_empty() { + return None; + } + let extra_headers = Self::proxy_read_passthrough_headers(&req.headers); + let range = req + .headers + .get(http::header::RANGE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let part_number = req.input.part_number; + + for target in targets { + match target + .get_object( + &target.bucket, + key, + opts.version_id.clone(), + range.clone(), + part_number, + extra_headers.clone(), + ) + .await + { + Ok(remote) => { + // MinIO-aligned accounting: one total per proxy attempt + // (targets were available), one failed when no target + // served it — never per target. + record_replication_proxy(bucket, "GetObject", false).await; + return Some(Self::proxy_sdk_get_output_to_s3s(remote)); + } + Err(err) if Self::proxy_sdk_error_is_not_found(&err) => { + debug!(bucket, key, arn = %target.arn, "read proxy: target does not have the object"); + } + Err(err) => { + warn!(bucket, key, arn = %target.arn, error = %err, "read proxy: GET against replication target failed"); + } + } + } + record_replication_proxy(bucket, "GetObject", true).await; + None + } + + /// Translate a proxied SDK GET response into the s3s output, forwarding + /// the body as a stream (no buffering, no local persistence). + fn proxy_sdk_get_output_to_s3s(remote: aws_sdk_s3::operation::get_object::GetObjectOutput) -> GetObjectOutput { + let body = remote.body; + let body_stream = tokio_util::io::ReaderStream::with_capacity(body.into_async_read(), 64 * 1024); + GetObjectOutput { + body: Some(StreamingBlob::wrap(body_stream)), + content_length: remote.content_length, + content_range: remote.content_range, + content_type: remote.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()), + content_encoding: remote.content_encoding, + content_disposition: remote.content_disposition, + content_language: remote.content_language, + cache_control: remote.cache_control, + e_tag: remote.e_tag.as_deref().and_then(|v| ETag::from_str(v).ok()), + last_modified: remote + .last_modified + .and_then(|dt| OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanos()).ok()) + .map(Timestamp::from), + metadata: remote.metadata, + version_id: remote.version_id, + server_side_encryption: remote + .server_side_encryption + .map(|sse| ServerSideEncryption::from(sse.as_str().to_string())), + sse_customer_algorithm: remote.sse_customer_algorithm, + sse_customer_key_md5: remote.sse_customer_key_md5, + ssekms_key_id: remote.ssekms_key_id, + parts_count: remote.parts_count, + tag_count: remote.tag_count, + storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())), + expiration: remote.expiration, + restore: remote.restore, + checksum_crc32: remote.checksum_crc32, + checksum_crc32c: remote.checksum_crc32_c, + checksum_crc64nvme: remote.checksum_crc64_nvme, + checksum_sha1: remote.checksum_sha1, + checksum_sha256: remote.checksum_sha256, + checksum_type: remote.checksum_type.map(|ct| ChecksumType::from(ct.as_str().to_string())), + ..Default::default() + } + } + + #[instrument(name = "execute_get_object", level = "trace", skip(self, req))] + pub async fn execute_get_object(&self, req: S3Request) -> S3Result> { + self.execute_get_object_boxed(req).await + } + + fn execute_get_object_boxed( + &self, + req: S3Request, + ) -> impl std::future::Future>> + Send + '_ { + Box::pin(self.execute_get_object_inner(req)) + } + + async fn execute_get_object_inner(&self, req: S3Request) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let inbound_request_context = req.extensions.get::(); + let request_id = inbound_request_context + .map(|ctx| ctx.request_id.clone()) + .unwrap_or_else(|| request_context::RequestContext::fallback().request_id); + if rustfs_io_metrics::get_stage_metrics_enabled() + && let Some(context) = inbound_request_context + { + rustfs_io_metrics::record_get_object_stage_duration( + GET_OBJECT_STAGE_PATH_S3_HANDLER, + GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT, + context.start_time.elapsed().as_secs_f64(), + ); + } + let bootstrap = self.init_get_object_bootstrap(&req.input.bucket, &req.input.key, &request_id)?; + let timeout_config = bootstrap.timeout_config; + let wrapper = bootstrap.wrapper; + let request_start = bootstrap.request_start; + let concurrent_requests = bootstrap.concurrent_requests; + let mut lifecycle = GetObjectBodyLifecycle::tracked(bootstrap.request_guard); + + let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event(); + // mc get 3 + + // Cheap request-shape validations run first so invalid requests keep + // their InvalidArgument precedence over bucket existence. + let validated = match Self::validate_get_object_request(&req) { + Ok(validated) => validated, + Err(err) => { + lifecycle.finish_err(); + return Err(err); + } + }; + + // SF05: Store lookup next (5s-TTL bucket-validation cache). Bucket + // existence is established before any bucket-metadata work, so requests + // naming nonexistent buckets fail before the versioning lookup in + // get_opts. The store comes from the request-bound server context + // (backlog#1052 S6), not the process-global handle. + let object_traffic_health = self.object_traffic_health(); + let object_metadata_progress = object_traffic_health + .as_deref() + .and_then(ObjectTrafficHealth::track_read_metadata); + let store_lookup_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let Some(store) = self.object_store() else { + lifecycle.finish_err(); + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + if let Err(err) = validate_bucket_exists(&store, &req.input.bucket).await { + lifecycle.finish_err(); + return Err(err); + } + if let Some(store_lookup_start) = store_lookup_start { + rustfs_io_metrics::record_get_object_stage_duration( + "s3_handler", + "store_lookup", + store_lookup_start.elapsed().as_secs_f64(), + ); + } + + let request_context_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let request_context = match Self::prepare_get_object_request_context(validated, &req.headers).await { + Ok(request_context) => request_context, + Err(err) => { + lifecycle.finish_err(); + return Err(err); + } + }; + if let Some(request_context_start) = request_context_start { + rustfs_io_metrics::record_get_object_stage_duration( + "s3_handler", + "request_context", + request_context_start.elapsed().as_secs_f64(), + ); + } + let GetObjectRequestContext { + bucket, + key, + version_id_for_event, + part_number, + rs, + opts, + } = request_context; + drop(object_metadata_progress); + + let manager = get_concurrency_manager(); + + let prepared_read = match self + .prepare_get_object_read_execution( + &req, + manager, + store.clone(), + &wrapper, + &timeout_config, + &bucket, + &key, + rs, + &opts, + part_number, + object_traffic_health, + ) + .await + { + Ok(prepared_read) => prepared_read, + Err(err) => { + // Active-active replication lag window: an object missing + // locally (and only missing — other errors keep their + // semantics) may still be served by proxying the GET to a + // replication target (backlog#1675 P1-5). + if matches!(*err.code(), S3ErrorCode::NoSuchKey | S3ErrorCode::NoSuchVersion) + && let Some(output) = Self::proxy_get_object_to_replication_targets(&req, &bucket, &key, &opts).await + { + lifecycle.finish_ok(); + let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; + inject_accept_ranges_header(&mut response.headers); + let result = Ok(response); + let _ = helper.version_id(version_id_for_event).complete(&result); + return result; + } + lifecycle.finish_err(); + return Err(err); + } + }; + let GetObjectPreparedRead { io_planning, read_setup } = prepared_read; + let GetObjectIoPlanning { + disk_permit, + permit_wait_duration, + queue_status, + queue_utilization, + } = io_planning; + + let GetObjectReadSetup { + info, + final_stream, + buffered_body, + cache_hook_served, + cache_hook_probed, + cache_fill_allowed, + rs, + content_type, + last_modified, + response_content_length, + content_range, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + encryption_applied, + resume_range_start, + resume_range_end, + } = read_setup; + let final_stream = if let Some(disk_permit) = disk_permit { + wrap_reader(DiskReadPermitReader::new(final_stream, disk_permit)) + } else { + final_stream + }; + + // Clone ObjectInfo for event notification only when an event will + // actually be built — the clone is expensive for multipart objects. + let event_info = helper.wants_object_info().then(|| info.clone()); + + let output_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let output_context = self + .build_get_object_output_context( + &req, + manager, + &bucket, + &key, + info, + event_info, + final_stream, + buffered_body, + cache_hook_served, + cache_hook_probed, + cache_fill_allowed, + rs, + content_type, + last_modified, + response_content_length, + content_range, + &request_id, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + encryption_applied, + permit_wait_duration, + queue_utilization, + &queue_status, + concurrent_requests, + part_number, + opts.versioned, + lifecycle, + |info| { + Some(get_object_resume_control(GetObjectResumeContext::new( + store, + &bucket, + &key, + opts, + &req.headers, + info, + resume_range_start, + resume_range_end, + ))) + }, + ) + .await; + let output_context = match output_context { + Ok(output_context) => output_context, + Err(err) => return Err(err), + }; + if let Some(output_build_start) = output_build_start { + rustfs_io_metrics::record_get_object_stage_duration( + "s3_handler", + "output_build", + output_build_start.elapsed().as_secs_f64(), + ); + } + let GetObjectOutputContext { + output, + event_info, + response_content_length, + optimal_buffer_size, + extra_checksum_headers, + } = output_context; + + let total_duration = request_start.elapsed(); + Self::finalize_get_object_completion( + &wrapper, + &timeout_config, + total_duration, + response_content_length, + optimal_buffer_size, + ); + + Self::finalize_get_object_response( + helper, + &bucket, + &req.method, + &req.headers, + event_info, + version_id_for_event, + output, + extra_checksum_headers, + ) + .await + } + + pub async fn execute_get_object_attributes( + &self, + req: S3Request, + ) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = + OperationHelper::new(&req, EventName::ObjectAccessedAttributes, S3Operation::GetObjectAttributes).suppress_event(); + let GetObjectAttributesInput { + bucket, + key, + max_parts, + object_attributes, + part_number_marker, + version_id, + sse_customer_key, + sse_customer_key_md5, + .. + } = req.input; + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + + let mut opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), None, &req.headers) + .await + .map_err(ApiError::from)?; + opts.include_part_checksums = object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS); + + let info = match store.get_object_info(&bucket, &key, &opts).await { + Ok(info) => info, + Err(err) => { + if is_err_object_not_found(&err) || is_err_version_not_found(&err) { + if is_dir_object(&key) { + let has_children = match probe_prefix_has_children(store, &bucket, &key, false).await { + Ok(has_children) => has_children, + Err(e) => { + error!( + "Failed to probe children for object attributes (bucket: {}, key: {}): {}", + bucket, key, e + ); + false + } + }; + let msg = head_prefix_not_found_message(&bucket, &key, has_children); + return Err(S3Error::with_message(S3ErrorCode::NoSuchKey, msg)); + } + return Err(S3Error::new(S3ErrorCode::NoSuchKey)); + } + return Err(ApiError::from(err).into()); + } + }; + + if info.delete_marker { + if opts.version_id.is_none() { + return Err(S3Error::new(S3ErrorCode::NoSuchKey)); + } + return Err(S3Error::new(S3ErrorCode::MethodNotAllowed)); + } + + validate_ssec_for_read(&info.user_defined, sse_customer_key.as_ref(), sse_customer_key_md5.as_ref())?; + + let metadata_map = info.user_defined.clone(); + debug!( + "GetObjectAttributes raw object_attributes={:?}", + object_attributes.iter().map(|value| value.as_str()).collect::>() + ); + + let requested = |name: &'static str| -> bool { object_attributes_requested(&object_attributes, name) }; + let storage_class = + response_storage_class_for_object_attributes(&info, &metadata_map, requested(ObjectAttributes::STORAGE_CLASS)); + + let e_tag = if requested(ObjectAttributes::ETAG) { + info.etag.as_ref().map(|etag| to_s3s_etag(etag)) + } else { + None + }; + + let object_size = if requested(ObjectAttributes::OBJECT_SIZE) { + Some(info.get_actual_size().map_err(ApiError::from)?) + } else { + None + }; + + let checksum = if requested(ObjectAttributes::CHECKSUM) { + let (checksums, is_multipart) = info.decrypt_checksums(0, &req.headers).map_err(ApiError::from)?; + // GetObjectAttributes returns checksums in the XML body, and s3s's Checksum + // type has no field for the additional algorithms, so `extra` cannot be + // surfaced here (unlike the header-based GET/HEAD paths) — an s3s limitation + // tracked for when it gains typed fields. + let ResponseChecksums { + crc32: checksum_crc32, + crc32c: checksum_crc32c, + sha1: checksum_sha1, + sha256: checksum_sha256, + crc64nvme: checksum_crc64nvme, + checksum_type, + .. + } = classify_response_checksums(checksums, is_multipart); + + Some(Checksum { + checksum_crc32, + checksum_crc32c, + checksum_sha1, + checksum_sha256, + checksum_crc64nvme, + checksum_type, + ..Default::default() + }) + } else { + None + }; + let object_parts = if requested(ObjectAttributes::OBJECT_PARTS) && info.is_multipart() { + let params = parse_list_parts_params(part_number_marker, max_parts)?; + let mut parts = Vec::new(); + let mut marker = params.part_number_marker; + let max_parts = params.max_parts; + let mut start_at = 0usize; + + if let Some(marker_value) = marker { + if let Some(index) = info.parts.iter().position(|part| part.number == marker_value) { + start_at = index + 1; + } else { + marker = None; + } + } + + let max_parts: i32 = max_parts.try_into().map_err(|_| { + S3Error::with_message(S3ErrorCode::InvalidArgument, "max-parts value is out of range".to_string()) + })?; + let end = (start_at + params.max_parts).min(info.parts.len()); + let is_truncated = end < info.parts.len(); + + for part in &info.parts[start_at..end] { + let (checksums, is_multipart) = info.decrypt_checksums(part.number, &req.headers).map_err(ApiError::from)?; + // Additional algorithms cannot be surfaced in the ObjectPart XML body + // (s3s has no field); same limitation as the object-level attributes above. + let ResponseChecksums { + crc32: checksum_crc32, + crc32c: checksum_crc32c, + sha1: checksum_sha1, + sha256: checksum_sha256, + crc64nvme: checksum_crc64nvme, + .. + } = classify_response_checksums(checksums, is_multipart); + + let part_size = if part.actual_size > 0 { + part.actual_size + } else { + part.size.try_into().map_err(|_| { + S3Error::with_message(S3ErrorCode::InvalidArgument, "Part size value is out of range".to_string()) + })? + }; + + parts.push(ObjectPart { + checksum_crc32, + checksum_crc32c, + checksum_sha1, + checksum_sha256, + checksum_crc64nvme, + part_number: i32::try_from(part.number).ok(), + size: Some(part_size), + ..Default::default() + }); + } + + let part_number_marker = marker.and_then(|v| i32::try_from(v).ok()); + let next_part_number_marker = parts.last().and_then(|part| part.part_number); + + Some(GetObjectAttributesParts { + is_truncated: Some(is_truncated), + max_parts: Some(max_parts), + next_part_number_marker, + part_number_marker, + parts: Some(parts), + total_parts_count: Some(i32::try_from(info.parts.len()).map_err(|_| { + S3Error::with_message(S3ErrorCode::InvalidArgument, "Part count is out of range".to_string()) + })?), + }) + } else { + None + }; + + let version_id = if BucketVersioningSys::prefix_enabled(&bucket, &key).await { + info.version_id.map(|vid| { + if vid == Uuid::nil() { + "null".to_string() + } else { + vid.to_string() + } + }) + } else { + None + }; + + let output = GetObjectAttributesOutput { + checksum, + delete_marker: if info.delete_marker { Some(true) } else { None }, + e_tag, + last_modified: info.mod_time.map(Timestamp::from), + object_parts, + object_size, + storage_class, + version_id: version_id.clone(), + ..Default::default() + }; + + helper = helper.object(info).version_id(version_id.unwrap_or_default()); + + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + result + } +} + +fn object_attributes_requested(object_attributes: &[ObjectAttributes], name: &'static str) -> bool { + object_attributes.iter().any(|value| { + value.as_str().split(',').any(|part| { + part.trim_matches(|c: char| c.is_whitespace() || c == '"' || c == '\'') + .eq_ignore_ascii_case(name) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderValue, Method}; + use std::pin::Pin; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + use std::task::{Context, Poll}; + use tokio::io::{AsyncRead, ReadBuf}; + + #[tokio::test(start_paused = true)] + async fn cold_fill_disk_admission_preserves_slow_down() { + let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1))); + let primary = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() { + DiskReadAdmission::Primary(permit) => permit, + other => panic!("expected primary admission, got {other:?}"), + }; + let degraded = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() { + DiskReadAdmission::Degraded(permit) => permit, + other => panic!("expected degraded admission, got {other:?}"), + }; + + let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await; + assert!(matches!(result, Err(ColdFillError::Storage(StorageError::SlowDown)))); + + drop(degraded); + drop(primary); + } + + #[tokio::test] + async fn cold_fill_closed_disk_admission_is_not_slow_down() { + let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1))); + manager.close_disk_read_admission_for_test(); + + let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await; + assert!(matches!(result, Err(ColdFillError::DiskAdmissionClosed))); + } + + #[tokio::test] + async fn finalize_get_object_response_injects_accept_ranges_header() { + let req = build_request(GetObjectInput::default(), Method::GET); + let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event(); + let response = DefaultObjectUsecase::finalize_get_object_response( + helper, + "bucket", + &req.method, + &req.headers, + None, + String::new(), + GetObjectOutput::default(), + Vec::new(), + ) + .await + .expect("finalize response"); + + assert_eq!(response.headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES); + } + + #[test] + fn should_buffer_get_object_in_memory_respects_hard_safety_cap() { + let info = ObjectInfo::default(); + let configured_threshold = 20_i64 * 1024 * 1024 * 1024; + let response_len = 80_i64 * 1024 * 1024; + let should_buffer = + should_buffer_get_object_in_memory_with_threshold(&info, response_len, None, false, configured_threshold, 1, true); + + assert!( + !should_buffer, + "64MiB hard cap must force streaming when response exceeds cap even if configured threshold is much higher" + ); + } + + #[test] + fn should_buffer_get_object_in_memory_allows_small_non_range_requests() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(should_buffer_get_object_in_memory_with_threshold( + &info, + 1024 * 1024, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + 1024 * 1024, + Some(1), + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + 1024 * 1024, + None, + true, + configured_threshold, + 1, + true + )); + } + + #[test] + fn should_buffer_get_object_in_memory_requires_seek_buffer_opt_in() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + 1024, + None, + false, + configured_threshold, + 1, + false + )); + } + + #[test] + fn should_buffer_get_object_in_memory_respects_configured_threshold_below_cap() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(should_buffer_get_object_in_memory_with_threshold( + &info, + configured_threshold, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + configured_threshold + 1, + None, + false, + configured_threshold, + 1, + true + )); + } + + #[test] + fn should_buffer_get_object_in_memory_rejects_unknown_lengths_and_disabled_thresholds() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + 0, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + -1, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold(&info, 1024, None, false, 0, 1, true)); + } + + #[test] + fn should_buffer_get_object_in_memory_reduces_threshold_under_concurrency() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(should_buffer_get_object_in_memory_with_threshold( + &info, + configured_threshold, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + configured_threshold, + None, + false, + configured_threshold, + 32, + true + )); + assert!(should_buffer_get_object_in_memory_with_threshold( + &info, + 4_i64 * 1024 * 1024, + None, + false, + configured_threshold, + rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD, + true + )); + } + + /// Polls the cache until the detached fill (ODC-15) populates the entry, so + /// a follow-up GET is a deterministic hit rather than racing the fill task. + async fn wait_for_cache_hit( + adapter: &crate::app::object_data_cache::ObjectDataCacheAdapter, + bucket: &str, + object: &str, + etag: &str, + size: u64, + ) { + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket, + object, + version_id: None, + etag, + size, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + for _ in 0..400 { + if matches!(adapter.lookup_body(&plan).await, rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_)) { + return; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!("detached fill did not populate the cache within the timeout"); + } + + struct ReadProbeReader { + reads: Arc, + } + + impl AsyncRead for ReadProbeReader { + fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll> { + self.reads.fetch_add(1, AtomicOrdering::Relaxed); + Poll::Ready(Ok(())) + } + } + + struct DataProbeReader { + reads: Arc, + data: std::io::Cursor>, + } + + struct ColdFillMatrixReader { + inner: tokio::io::DuplexStream, + first_poll_recorded: bool, + completion_recorded: bool, + first_polls: Arc, + completed: Arc, + bytes_read: Arc, + } + + impl AsyncRead for ColdFillMatrixReader { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if !self.first_poll_recorded { + self.first_poll_recorded = true; + self.first_polls.fetch_add(1, AtomicOrdering::Relaxed); + } + let before = buf.filled().len(); + match Pin::new(&mut self.inner).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + let read = buf.filled().len().saturating_sub(before); + self.bytes_read.fetch_add(read, AtomicOrdering::Relaxed); + if read == 0 && !self.completion_recorded { + self.completion_recorded = true; + self.completed.fetch_add(1, AtomicOrdering::Relaxed); + } + Poll::Ready(Ok(())) + } + other => other, + } + } + } + + impl AsyncRead for DataProbeReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + self.reads.fetch_add(1, AtomicOrdering::Relaxed); + + let remaining = buf.remaining(); + if remaining == 0 { + return Poll::Ready(Ok(())); + } + + let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); + let source = self.data.get_ref(); + if position >= source.len() { + return Poll::Ready(Ok(())); + } + + let end = position.saturating_add(remaining).min(source.len()); + buf.put_slice(&source[position..end]); + self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX)); + Poll::Ready(Ok(())) + } + } + + struct PendingReader; + + impl AsyncRead for PendingReader { + fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll> { + Poll::Pending + } + } + + // Emits `fail_after` bytes from `data`, then returns a hard read error. Used + // to inject the "read K bytes then Err" partial-read case (#1324). + struct ErrAfterReader { + data: std::io::Cursor>, + fail_after: usize, + emitted: usize, + } + + impl AsyncRead for ErrAfterReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if self.emitted >= self.fail_after { + return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error"))); + } + let remaining = buf.remaining(); + if remaining == 0 { + return Poll::Ready(Ok(())); + } + let want = (self.fail_after - self.emitted).min(remaining); + let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); + let source = self.data.get_ref(); + let end = position.saturating_add(want).min(source.len()); + if end <= position { + return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error"))); + } + let chunk_len = end - position; + buf.put_slice(&source[position..end]); + self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX)); + self.emitted += chunk_len; + Poll::Ready(Ok(())) + } + } + + fn cursor_reader(bytes: &[u8]) -> std::io::Cursor> { + std::io::Cursor::new(bytes.to_vec()) + } + + // #1324: the strict materialization helper is the shared exact-length gate + // for the encrypted, seek, and cache memory branches. For a declared length N + // only an exact N-byte read succeeds; a short read (N-1), an over-long read + // (N+1), and a mid-stream read error all hard-fail. This is the reversal + // guard for every one of those sources at once: restoring WARN-and-serve or a + // partial fallback would flip the short/over-long/error assertions to Ok. + #[tokio::test] + async fn strict_materialize_object_body_requires_exact_length() { + // Exact length: the only accepted outcome. + let buf = strict_materialize_object_body(cursor_reader(b"hello"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ) + .await + .expect("exact-length read must materialize"); + assert_eq!(buf, b"hello"); + assert_eq!(buf.capacity(), 5, "exact materialization must allocate only the declared body length"); + + let exact_large = vec![7_u8; 64 * 1024]; + let buf = strict_materialize_object_body( + std::io::Cursor::new(exact_large.clone()), + exact_large.len(), + GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ, + ) + .await + .expect("64 KiB exact-length read must materialize"); + assert_eq!(buf.capacity(), exact_large.len()); + + let mut overlong_large = exact_large; + overlong_large.push(9); + let overlong = strict_materialize_object_body( + std::io::Cursor::new(overlong_large), + 64 * 1024, + GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ, + ) + .await; + assert!(matches!( + overlong, + Err(StrictMaterializeError::LengthMismatch { + expected: 65_536, + actual: 65_537 + }) + )); + + // Short read (actual = expected - 1): a clean EOF before the declared + // length must be a hard error, never a truncated served body. + let short = strict_materialize_object_body(cursor_reader(b"hell"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; + assert!( + matches!( + short, + Err(StrictMaterializeError::LengthMismatch { + expected: 5, + actual: 4, + .. + }) + ), + "short read must fail with a length mismatch, got {short:?}", + short = short.as_ref().map(|b| b.len()) + ); + + // Over-long read (actual = expected + 1): must fail rather than silently + // truncate to the committed Content-Length. + let long = strict_materialize_object_body(cursor_reader(b"hello!"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; + assert!( + matches!(long, Err(StrictMaterializeError::LengthMismatch { expected: 5, actual: 6 })), + "over-long read must fail with a length mismatch, got {long:?}", + long = long.as_ref().map(|b| b.len()) + ); + + // Read K bytes then Err: must surface the read error and never return the + // partially consumed buffer (which the caller could otherwise re-stream). + let reader = ErrAfterReader { + data: cursor_reader(b"hello"), + fail_after: 3, + emitted: 0, + }; + let errored = strict_materialize_object_body(reader, 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; + assert!( + matches!(errored, Err(StrictMaterializeError::Read { consumed: 3, .. })), + "a mid-stream read error must be reported as a read failure" + ); + } + + #[test] + fn cold_fill_zero_timeout_policy_disables_deadline() { + let policy = GetObjectTimeoutPolicy { + get_object_timeout: Duration::ZERO, + ..GetObjectTimeoutPolicy::default() + }; + let wrapper = RequestTimeoutWrapper::with_request_id(policy.clone(), "cold-fill-zero-timeout"); + assert!(cold_fill_deadline(&wrapper, &policy, 1).is_none()); + } + + #[tokio::test(start_paused = true)] + async fn cold_fill_producer_deadline_is_capped_at_ten_minutes() { + let disabled = GetObjectTimeoutPolicy { + get_object_timeout: Duration::ZERO, + ..GetObjectTimeoutPolicy::default() + }; + let now = tokio::time::Instant::now(); + assert_eq!(cold_fill_producer_deadline(&disabled, 1) - now, Duration::from_secs(600)); + + let long = GetObjectTimeoutPolicy { + get_object_timeout: Duration::from_secs(3600), + enable_dynamic_timeout: false, + ..GetObjectTimeoutPolicy::default() + }; + let now = tokio::time::Instant::now(); + assert_eq!(cold_fill_producer_deadline(&long, 1) - now, Duration::from_secs(600)); + } + + #[tokio::test] + async fn cold_fill_startup_wait_stops_when_last_consumer_cancels() { + let cancellation = tokio_util::sync::CancellationToken::new(); + let waiting = tokio::spawn({ + let cancellation = cancellation.clone(); + async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, None).await } + }); + tokio::task::yield_now().await; + + cancellation.cancel(); + + let result = tokio::time::timeout(Duration::from_secs(1), waiting) + .await + .expect("startup wait must observe cancellation") + .expect("startup wait task must not panic"); + assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled))); + } + + #[tokio::test(start_paused = true)] + async fn cold_fill_startup_wait_with_deadline_still_observes_cancellation() { + let cancellation = tokio_util::sync::CancellationToken::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + let waiting = tokio::spawn({ + let cancellation = cancellation.clone(); + async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await } + }); + tokio::task::yield_now().await; + + cancellation.cancel(); + + let result = waiting.await.expect("startup wait task must not panic"); + assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled))); + } + + #[tokio::test(start_paused = true)] + async fn cold_fill_startup_wait_reports_deadline_exceeded() { + let cancellation = tokio_util::sync::CancellationToken::new(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(1); + + let result = await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await; + + assert!(matches!(result, Err(ColdFillStartupWaitError::DeadlineExceeded))); + } + + #[tokio::test] + async fn cold_fill_late_miss_second_chance_hits_without_reader() { + let adapter = ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("second-chance cache config must be valid"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "late-bucket", + object: "late-object", + version_id: None, + etag: "late-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + assert!(matches!( + adapter.lookup_body(&plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Miss + )); + let request_lookups = adapter.cache().stats().lookups; + assert_eq!(request_lookups, 1, "the authoritative request lookup must be counted once"); + + let reservation = adapter.reserve_body(&plan).expect("late producer must reserve"); + let reserved = reservation.wrap_bytes(Bytes::from_static(b"body")); + let _ = adapter.fill_reserved_body(&plan, reserved).await; + let coordinator = adapter.cold_fill_coordinator(); + let cache_key = plan.key().cloned().expect("late plan must be cacheable"); + let adapter = Arc::new(adapter); + let readers = Arc::new(AtomicUsize::new(0)); + let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, { + let adapter = Arc::clone(&adapter); + let readers = Arc::clone(&readers); + move |producer| { + let adapter = Arc::clone(&adapter); + let plan = plan.clone(); + let readers = Arc::clone(&readers); + async move { + if let Some(body) = lookup_cold_fill_second_chance(&adapter, &plan).await { + producer.finish_shared(Ok(body)); + return; + } + readers.fetch_add(1, AtomicOrdering::Relaxed); + producer.bypass(); + } + } + }) + .await; + let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { + panic!("late request must observe the completed fill, got {outcome:?}"); + }; + assert_eq!(body, Bytes::from_static(b"body")); + assert_eq!( + adapter.cache().stats().lookups, + request_lookups, + "the producer second chance must not count another request lookup" + ); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 0); + } + + #[tokio::test] + async fn cold_fill_timeout_is_shared_and_releases_resources() { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("timeout cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "timeout-bucket", + object: "timeout-object", + version_id: None, + etag: "timeout-etag", + size: 1, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let key = plan.key().cloned().expect("timeout body must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else { + panic!("first timeout request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let disk_permits = Arc::new(tokio::sync::Semaphore::new(1)); + let disk_gate = Arc::clone(&disk_permits); + let readers = Arc::new(AtomicUsize::new(0)); + let reader_count = Arc::clone(&readers); + let producer_task = tokio::spawn(start_cold_fill_producer( + producer, + reservation, + move || async move { + let permit = disk_gate + .acquire_owned() + .await + .map_err(|_| ColdFillError::DiskAdmissionClosed)?; + let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); + io.disk_permit = Some(permit.into()); + Ok(io) + }, + move || async move { + reader_count.fetch_add(1, AtomicOrdering::Relaxed); + Ok(GetObjectReader { + stream: Box::new(PendingReader), + object_info: ObjectInfo { + size: 1, + actual_size: 1, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 1, + deadline: Some(tokio::time::Instant::now() + Duration::from_millis(20)), + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + )); + tokio::time::timeout(Duration::from_secs(1), async { + while readers.load(AtomicOrdering::Relaxed) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("producer reader must open"); + let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else { + panic!("second timeout request must follow"); + }; + + let (leader_result, follower_result) = + tokio::time::timeout(Duration::from_secs(2), async { tokio::join!(leader.wait(), follower.wait()) }) + .await + .expect("typed timeout must wake all waiters"); + assert!(matches!( + leader_result, + ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + assert!(matches!( + follower_result, + ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); + assert_eq!(disk_permits.available_permits(), 1); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + assert!(matches!( + adapter.lookup_body(&plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Miss + )); + producer_task.await.expect("producer task must join"); + assert!(adapter.reserve_body(&plan).is_some(), "timeout must release the body reservation"); + let ColdFillRole::Produce(successor) = coordinator.join(key) else { + panic!("timeout must release the session for a successor"); + }; + drop(successor); + } + + #[tokio::test] + async fn cold_fill_survives_leader_request_cancellation_without_second_producer() { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("cancellation cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "cancel-bucket", + object: "cancel-object", + version_id: None, + etag: "cancel-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let key = plan.key().cloned().expect("cancellation body must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else { + panic!("first cancellation request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let readers = Arc::new(AtomicUsize::new(0)); + let reader_count = Arc::clone(&readers); + let writer_slot = Arc::new(Mutex::new(None)); + let writer_output = Arc::clone(&writer_slot); + let producer_task = tokio::spawn(start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + move || async move { + reader_count.fetch_add(1, AtomicOrdering::Relaxed); + let (writer, reader) = tokio::io::duplex(16); + *writer_output.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(writer); + Ok(GetObjectReader { + stream: Box::new(reader), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + )); + tokio::time::timeout(Duration::from_secs(1), async { + while readers.load(AtomicOrdering::Relaxed) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("cancellation producer reader must open"); + let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else { + panic!("second cancellation request must follow"); + }; + drop(leader); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); + let ColdFillRole::Wait(late) = coordinator.join(key) else { + panic!("leader cancellation must not open a successor session"); + }; + drop(late); + + let mut writer = writer_slot + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + .expect("reader factory must publish writer"); + tokio::io::AsyncWriteExt::write_all(&mut writer, b"body") + .await + .expect("body write must succeed"); + tokio::io::AsyncWriteExt::shutdown(&mut writer) + .await + .expect("body writer must close"); + let ColdFillWaitOutcome::Ready(result) = follower.wait().await else { + panic!("follower must receive producer result"); + }; + assert_eq!(result.expect("surviving producer must succeed"), Bytes::from_static(b"body")); + producer_task.await.expect("producer task must join"); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); + } + + #[tokio::test] + async fn cold_fill_reservation_rejection_streams_without_materializing() { + let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default()); + let plan = rustfs_object_data_cache::ObjectDataCacheGetPlan::Disabled; + let ColdFillRole::Produce(mut producer) = coordinator.join(rustfs_object_data_cache::ObjectDataCacheKey::new( + "bucket", + "object", + None, + "etag", + 4, + rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + )) else { + panic!("first rejected reservation request must produce"); + }; + let leader = producer.waiter(); + let permits = Arc::new(AtomicUsize::new(0)); + let readers = Arc::new(AtomicUsize::new(0)); + let permit_count = Arc::clone(&permits); + let reader_count = Arc::clone(&readers); + start_cold_fill_producer( + producer, + None, + move || async move { + permit_count.fetch_add(1, AtomicOrdering::Relaxed); + Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) + }, + move || async move { + reader_count.fetch_add(1, AtomicOrdering::Relaxed); + Err(StorageError::other("reader must not open")) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter: Arc::new(ObjectDataCacheAdapter::disabled()), + engine_plan: plan, + }, + ) + .await; + assert!(matches!(leader.wait().await, ColdFillWaitOutcome::Bypass)); + assert_eq!(permits.load(AtomicOrdering::Relaxed), 0); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 0); + + let fallback_reads = Arc::new(AtomicUsize::new(0)); + let fallback_reader = DataProbeReader { + reads: Arc::clone(&fallback_reads), + data: std::io::Cursor::new(b"body".to_vec()), + }; + let info = ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }; + let mut fallback_body = DefaultObjectUsecase::build_get_object_body( + fallback_reader, + &info, + 4, + "req-cold-fill", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + "bucket", + "object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("reservation bypass must construct the normal streaming fallback"); + let chunk = fallback_body + .next() + .await + .expect("fallback stream must yield a body chunk") + .expect("fallback stream must not fail"); + assert_eq!(chunk, Bytes::from_static(b"body")); + assert!(fallback_reads.load(AtomicOrdering::Relaxed) > 0); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 0, "cold-fill materialization must remain unopened"); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_internal_movement_and_restore_reads_never_join_sessions() { + let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default()); + let info = ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }; + let mut restore = ObjectOptions::default(); + restore.transition.restore_request.days = Some(1); + let cases = [ + ObjectOptions { + raw_data_movement_read: true, + ..Default::default() + }, + ObjectOptions { + data_movement: true, + ..Default::default() + }, + restore, + ]; + + for opts in &cases { + assert!(matches!( + lookup_get_object_body_cache_hook("bucket", "object", &None, opts, &info).await, + GetObjectBodyCacheHookLookup::Ineligible + )); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + let delete_marker = ObjectInfo { + delete_marker: true, + etag: Some("delete-marker-etag".to_string()), + ..Default::default() + }; + let delete_marker_part = ObjectOptions { + part_number: Some(2), + ..Default::default() + }; + assert!(matches!( + lookup_get_object_body_cache_hook("bucket", "object", &None, &delete_marker_part, &delete_marker).await, + GetObjectBodyCacheHookLookup::Ineligible + )); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_generation_change_bypasses_before_opening_body() { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("generation retry cache config must be valid"), + ); + let request = |data_dir_u128| rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "generation-bucket", + object: "generation-object", + version_id: None, + etag: "generation-etag", + size: 4, + data_dir_u128: Some(data_dir_u128), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }; + let initial_plan = adapter.plan_get(request(1)); + let changed_plan = GetObjectBodyCachePlan::Cacheable(adapter.plan_get(request(2))); + let cache_key = initial_plan.key().cloned().expect("initial generation must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let body_opens = Arc::new(AtomicUsize::new(0)); + let producer_attempts = Arc::new(AtomicUsize::new(0)); + + let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, { + let body_opens = Arc::clone(&body_opens); + let producer_attempts = Arc::clone(&producer_attempts); + move |producer| { + let body_opens = Arc::clone(&body_opens); + let producer_attempts = Arc::clone(&producer_attempts); + let changed_plan = changed_plan.clone(); + let initial_plan = initial_plan.clone(); + async move { + producer_attempts.fetch_add(1, AtomicOrdering::Relaxed); + let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, &changed_plan, &initial_plan) + else { + return; + }; + body_opens.fetch_add(1, AtomicOrdering::Relaxed); + producer.bypass(); + } + } + }) + .await; + + assert!(matches!(outcome, ColdFillCoordinateOutcome::Bypass)); + assert_eq!(producer_attempts.load(AtomicOrdering::Relaxed), 1); + assert_eq!(body_opens.load(AtomicOrdering::Relaxed), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn execute_get_object_rejects_conditions_before_joining_cold_fill() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (store, context) = real_cold_fill_test_context().await; + let bucket = format!("cold-condition-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("real cold-fill condition bucket must be created"); + let body = vec![b'a'; 1_300_000]; + let info = put_real_cold_fill_object(&store, &bucket, object, &body).await; + let adapter = context.object_data_cache(); + let plan = real_cold_fill_plan(&adapter, &bucket, object, &info); + let coordinator = adapter.cold_fill_coordinator(); + let ColdFillRole::Produce(producer) = + coordinator.join(plan.key().cloned().expect("real cold-fill plan must expose its key")) + else { + panic!("test must reserve the initial cold-fill producer"); + }; + + let input = GetObjectInput::builder() + .bucket(bucket) + .key(object.to_string()) + .build() + .expect("real cold-fill GET input must build"); + let mut req = build_request(input, Method::GET); + let etag = info.etag.expect("real cold-fill test object must have an ETag"); + req.headers.insert( + http::header::IF_NONE_MATCH, + HeaderValue::from_str(&format!("\"{etag}\"")).expect("ETag header must be valid"), + ); + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(req)) + .await + .expect("conditional GET must not wait for the reserved cold-fill session") + .expect_err("matching If-None-Match must reject the GET"); + + assert_eq!(result.code(), &S3ErrorCode::NotModified); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + drop(producer); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn execute_get_object_maps_cold_fill_session_rejection_to_slow_down_without_opening_reader() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (store, context) = real_cold_fill_test_context().await; + let bucket = format!("cold-rejected-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("real cold-fill rejection bucket must be created"); + let body = vec![b'a'; 1_300_000]; + let info = put_real_cold_fill_object(&store, &bucket, object, &body).await; + let adapter = context.object_data_cache(); + let plan = real_cold_fill_plan(&adapter, &bucket, object, &info); + let cache_key = plan.key().cloned().expect("real cold-fill plan must expose its key"); + let coordinator = adapter.cold_fill_coordinator(); + let mut held_producers = Vec::new(); + for index in 0..2048 { + let saturation_key = rustfs_object_data_cache::ObjectDataCacheKey::new( + "cold-fill-saturation", + format!("object-{index}"), + None, + "etag", + 4, + rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + ); + match coordinator.join(saturation_key) { + ColdFillRole::Produce(producer) => held_producers.push(producer), + ColdFillRole::Rejected => break, + ColdFillRole::Wait(_) | ColdFillRole::Bypass => panic!("unique saturation keys must produce or reject"), + } + } + assert_eq!(coordinator.active_session_count_for_test(), held_producers.len()); + assert!(!held_producers.is_empty(), "saturation must reserve cold-fill sessions"); + + let reader_opens = Arc::new(AtomicU64::new(0)); + *COLD_FILL_READER_OPEN_PROBE + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((cache_key, Arc::clone(&reader_opens))); + let input = GetObjectInput::builder() + .bucket(bucket) + .key(object.to_string()) + .build() + .expect("real cold-fill rejection GET input must build"); + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(build_request(input, Method::GET))) + .await + .expect("rejected real GET must not wait for a cold-fill session") + .expect_err("rejected real GET must return an S3 error"); + *COLD_FILL_READER_OPEN_PROBE + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + + assert_eq!(result.code(), &S3ErrorCode::SlowDown); + assert_eq!(reader_opens.load(Ordering::Relaxed), 0, "rejected GET must not open its body reader"); + assert_eq!(coordinator.active_session_count_for_test(), held_producers.len()); + drop(held_producers); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn execute_get_object_generation_change_bypasses_old_cold_fill_plan() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (store, context) = real_cold_fill_test_context().await; + let bucket = format!("cold-generation-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("real cold-fill generation bucket must be created"); + let initial_body = vec![b'a'; 1_300_000]; + let changed_body = vec![b'b'; initial_body.len()]; + let initial_info = put_real_cold_fill_object(&store, &bucket, object, &initial_body).await; + let adapter = context.object_data_cache(); + let initial_plan = real_cold_fill_plan(&adapter, &bucket, object, &initial_info); + let coordinator = adapter.cold_fill_coordinator(); + let ColdFillRole::Produce(producer) = + coordinator.join(initial_plan.key().cloned().expect("real cold-fill plan must expose its key")) + else { + panic!("test must reserve the initial cold-fill producer"); + }; + + let input = GetObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .build() + .expect("real cold-fill GET input must build"); + // The request is intentionally held behind the first producer while a + // 1.3 MiB replacement write changes its generation. Disable dynamic + // sizing for this test so runner I/O load cannot consume the five-second + // production minimum before the behavior under test is released. + let usecase = DefaultObjectUsecase::with_context_and_get_object_timeout_policy( + Some(context), + GetObjectTimeoutPolicy { + enable_dynamic_timeout: false, + ..GetObjectTimeoutPolicy::default() + }, + ); + let request = tokio::spawn(async move { usecase.execute_get_object(build_request(input, Method::GET)).await }); + tokio::time::timeout(Duration::from_secs(2), async { + while coordinator.global_waiter_count_for_test() != 1 { + tokio::task::yield_now().await; + } + }) + .await + .expect("real GET must join the reserved cold-fill session"); + + let changed_info = put_real_cold_fill_object(&store, &bucket, object, &changed_body).await; + assert_ne!(initial_info.etag, changed_info.etag); + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + + let mut response = tokio::time::timeout(Duration::from_secs(10), request) + .await + .expect("generation-changing GET must complete") + .expect("generation-changing GET task must join") + .expect("generation-changing GET must fall back successfully"); + let mut response_body = response.output.body.take().expect("GET response must include a body"); + let mut actual = Vec::with_capacity(changed_body.len()); + while let Some(chunk) = response_body.next().await { + actual.extend_from_slice(&chunk.expect("fallback body chunk must be readable")); + } + + assert_eq!(actual, changed_body); + assert!(matches!( + adapter.lookup_body(&initial_plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Miss + )); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_open_error_retries_once_then_single_successor_succeeds() { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("open retry cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "open-retry-bucket", + object: "open-retry-object", + version_id: None, + etag: "open-retry-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let cache_key = plan.key().cloned().expect("open retry plan must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let open_attempts = Arc::new(AtomicUsize::new(0)); + let open_attempts_for_start = Arc::clone(&open_attempts); + + let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { + let reservation = adapter.reserve_body(&plan); + let adapter = Arc::clone(&adapter); + let plan = plan.clone(); + let open_attempts = Arc::clone(&open_attempts_for_start); + async move { + start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + move || async move { + let attempt = open_attempts.fetch_add(1, AtomicOrdering::Relaxed); + if attempt == 0 { + return Err(StorageError::other("first open fails")); + } + Ok(GetObjectReader { + stream: Box::new(std::io::Cursor::new(Vec::::new())), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: Some(Bytes::from_static(b"body")), + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter, + engine_plan: plan, + }, + ) + .await + } + }) + .await; + + let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { + panic!("the unique successor must publish the body"); + }; + assert_eq!(body, Bytes::from_static(b"body")); + assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_open_timeout_retries_once_then_is_terminal() { + tokio::time::pause(); + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("open timeout cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "open-timeout-bucket", + object: "open-timeout-object", + version_id: None, + etag: "open-timeout-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let cache_key = plan.key().cloned().expect("open timeout plan must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let open_attempts = Arc::new(AtomicUsize::new(0)); + + let deadline = tokio::time::Instant::now() + Duration::from_millis(10); + let task = tokio::spawn({ + let adapter = Arc::clone(&adapter); + let coordinator = Arc::clone(&coordinator); + let plan = plan.clone(); + let open_attempts = Arc::clone(&open_attempts); + async move { + coordinate_cold_fill(&coordinator, cache_key, None, Some(deadline), move |producer| { + let adapter = Arc::clone(&adapter); + let plan = plan.clone(); + let open_attempts = Arc::clone(&open_attempts); + let reservation = adapter.reserve_body(&plan); + let producer_deadline = producer.deadline(); + async move { + start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + move || async move { + open_attempts.fetch_add(1, AtomicOrdering::Relaxed); + std::future::pending::>().await + }, + ColdFillProducerExecution { + expected: 4, + deadline: producer_deadline, + adapter, + engine_plan: plan, + }, + ) + .await + } + }) + .await + } + }); + while open_attempts.load(AtomicOrdering::Relaxed) == 0 { + tokio::task::yield_now().await; + } + tokio::time::advance(Duration::from_millis(11)).await; + let outcome = task.await.expect("open timeout task must join"); + assert!(matches!( + outcome, + ColdFillCoordinateOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + + assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_pre_reader_failure_promotes_one_of_two_thousand_waiters() { + const REQUESTS: usize = 2000; + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("successor cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "successor-bucket", + object: "successor-object", + version_id: None, + etag: "successor-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let cache_key = plan.key().cloned().expect("successor plan must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let admission_attempts = Arc::new(AtomicUsize::new(0)); + let open_attempts = Arc::new(AtomicUsize::new(0)); + let first_open_release = Arc::new(tokio::sync::Semaphore::new(0)); + let mut tasks = tokio::task::JoinSet::new(); + + for _ in 0..REQUESTS { + let adapter = Arc::clone(&adapter); + let coordinator = Arc::clone(&coordinator); + let cache_key = cache_key.clone(); + let plan = plan.clone(); + let admission_attempts = Arc::clone(&admission_attempts); + let open_attempts = Arc::clone(&open_attempts); + let first_open_release = Arc::clone(&first_open_release); + tasks.spawn(async move { + coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { + let reservation = adapter.reserve_body(&plan); + let adapter = Arc::clone(&adapter); + let plan = plan.clone(); + let admission_attempts = Arc::clone(&admission_attempts); + let open_attempts = Arc::clone(&open_attempts); + let first_open_release = Arc::clone(&first_open_release); + async move { + start_cold_fill_producer( + producer, + reservation, + move || async move { + admission_attempts.fetch_add(1, AtomicOrdering::Relaxed); + Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) + }, + move || async move { + if open_attempts.fetch_add(1, AtomicOrdering::Relaxed) == 0 { + first_open_release + .acquire() + .await + .expect("first open release gate must remain open") + .forget(); + return Err(StorageError::other("first open fails")); + } + Ok(GetObjectReader { + stream: Box::new(std::io::Cursor::new(Vec::::new())), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: Some(Bytes::from_static(b"body")), + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter, + engine_plan: plan, + }, + ) + .await + } + }) + .await + }); + } + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if coordinator.global_waiter_count_for_test() == REQUESTS - 1 && open_attempts.load(AtomicOrdering::Relaxed) == 1 + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("all followers must join before the first open fails"); + first_open_release.add_permits(1); + + while let Some(result) = tasks.join_next().await { + let ColdFillCoordinateOutcome::Ready(Ok(body)) = result.expect("successor request task must join") else { + panic!("all followers must receive the successor body"); + }; + assert_eq!(body, Bytes::from_static(b"body")); + } + assert_eq!(admission_attempts.load(AtomicOrdering::Relaxed), 2); + assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + fn install_cold_fill_publication_barrier( + plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan, + ) -> Arc { + let barrier = Arc::new(ColdFillPublicationBarrier { + reached: tokio::sync::Semaphore::new(0), + release: tokio::sync::Semaphore::new(0), + }); + let key = plan.key().cloned().expect("publication barrier plan must be cacheable"); + *COLD_FILL_PUBLICATION_BARRIER + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((key, Arc::clone(&barrier))); + barrier + } + + fn clear_cold_fill_publication_barrier() { + *COLD_FILL_PUBLICATION_BARRIER + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } + + fn publication_test_adapter() -> Arc { + Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("publication cache config must be valid"), + ) + } + + fn publication_test_plan(adapter: &ObjectDataCacheAdapter, object: &str) -> rustfs_object_data_cache::ObjectDataCacheGetPlan { + adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "publication-bucket", + object, + version_id: None, + etag: "publication-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }) + } + + #[tokio::test] + #[serial_test::serial(cold_fill_publication_barrier)] + async fn cold_fill_last_consumer_cancel_releases_session_before_publication_barrier() { + let adapter = publication_test_adapter(); + let plan = publication_test_plan(&adapter, "cancel"); + let barrier = install_cold_fill_publication_barrier(&plan); + let coordinator = adapter.cold_fill_coordinator(); + let key = plan.key().cloned().expect("publication plan must be cacheable"); + let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { + panic!("publication request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let disk_permits = Arc::new(tokio::sync::Semaphore::new(1)); + let disk_gate = Arc::clone(&disk_permits); + let producer_task = tokio::spawn(scope_cold_fill_disk_permit_owner_for_test( + ColdFillDiskPermitOwner::Producer, + start_cold_fill_producer( + producer, + reservation, + move || async move { + let permit = disk_gate + .acquire_owned() + .await + .map_err(|_| ColdFillError::DiskAdmissionClosed)?; + let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); + io.disk_permit = Some(permit.into()); + Ok(io) + }, + || async { + Ok(GetObjectReader { + stream: Box::new(std::io::Cursor::new(b"body".to_vec())), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + ), + )); + + let reached = barrier.reached.acquire().await.expect("publication barrier must remain open"); + reached.forget(); + assert_eq!( + disk_permits.available_permits(), + 1, + "the producer disk permit and its gauge guard must end before publication" + ); + let clear_adapter = Arc::clone(&adapter); + let clear = tokio::spawn(async move { + clear_adapter + .clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual) + .await + }); + tokio::task::yield_now().await; + assert!(!clear.is_finished(), "clear must wait while publication owns its reservation"); + drop(leader); + tokio::time::timeout(Duration::from_secs(1), async { + while coordinator.active_session_count_for_test() != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("last-consumer cancellation must release the session immediately"); + tokio::time::timeout(Duration::from_secs(1), clear) + .await + .expect("clear must finish after publication cancellation") + .expect("clear task must join"); + producer_task.await.expect("producer task must join"); + + barrier.release.add_permits(1); + clear_cold_fill_publication_barrier(); + drop(adapter.reserve_body(&plan).expect("publication reservation must be released")); + } + + #[tokio::test(start_paused = true)] + #[serial_test::serial(cold_fill_publication_barrier)] + async fn cold_fill_hard_deadline_releases_session_at_publication_barrier() { + let adapter = publication_test_adapter(); + let plan = publication_test_plan(&adapter, "deadline"); + let barrier = install_cold_fill_publication_barrier(&plan); + let coordinator = adapter.cold_fill_coordinator(); + let key = plan.key().cloned().expect("publication plan must be cacheable"); + let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { + panic!("publication request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let deadline = tokio::time::Instant::now() + Duration::from_millis(20); + let producer_task = tokio::spawn(start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + || async { + Ok(GetObjectReader { + stream: Box::new(std::io::Cursor::new(Vec::::new())), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: Some(Bytes::from_static(b"body")), + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: Some(deadline), + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + )); + + let reached = barrier.reached.acquire().await.expect("publication barrier must remain open"); + reached.forget(); + tokio::time::advance(Duration::from_millis(20)).await; + assert!(matches!( + leader.wait().await, + ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + assert_eq!(coordinator.active_session_count_for_test(), 0); + producer_task.await.expect("producer task must join"); + + barrier.release.add_permits(1); + clear_cold_fill_publication_barrier(); + drop( + adapter + .reserve_body(&plan) + .expect("deadline must release the publication reservation"), + ); + tokio::time::timeout( + Duration::from_secs(1), + adapter.clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual), + ) + .await + .expect("clear must complete after publication deadline"); + } + + #[tokio::test(start_paused = true)] + async fn cold_fill_without_request_timeout_stops_at_ten_minute_hard_cap() { + let adapter = publication_test_adapter(); + let plan = publication_test_plan(&adapter, "hard-cap"); + let coordinator = adapter.cold_fill_coordinator(); + let key = plan.key().cloned().expect("hard-cap plan must be cacheable"); + let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { + panic!("hard-cap request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let producer_task = tokio::spawn(start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + || async { + Ok(GetObjectReader { + stream: Box::new(PendingReader), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + )); + let wait = tokio::spawn(async move { leader.wait().await }); + + tokio::time::advance(Duration::from_secs(599)).await; + tokio::task::yield_now().await; + assert!(!wait.is_finished(), "hard cap must not fire before 600 seconds"); + assert!(adapter.reserve_body(&plan).is_none(), "reservation must remain owned before the hard cap"); + + tokio::time::advance(Duration::from_secs(1)).await; + assert!(matches!( + wait.await.expect("hard-cap waiter must join"), + ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + producer_task.await.expect("producer task must join"); + assert_eq!(coordinator.active_session_count_for_test(), 0); + drop( + adapter + .reserve_body(&plan) + .expect("hard cap must release the body reservation"), + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_same_key_cold_fill_consumes_one_reader() { + const REQUESTS: usize = 2000; + const BODY_BYTES: usize = 64 * 1024; + const BODY_BYTES_U64: u64 = 64 * 1024; + const BODY_BYTES_I64: i64 = 64 * 1024; + + for key_count in [1_usize, 4, 32] { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 128 * 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024 * 1024, + min_free_memory_percent: 0, + fill_concurrency_per_cpu: 64, + fill_concurrency_max: 64, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("matrix cache config must be valid"), + ); + let coordinator = adapter.cold_fill_coordinator(); + let disk_permits = Arc::new(tokio::sync::Semaphore::new(key_count)); + let writers = Arc::new(tokio::sync::Mutex::new(Vec::with_capacity(key_count))); + let permit_acquires = Arc::new(AtomicUsize::new(0)); + let reader_factories = Arc::new(AtomicUsize::new(0)); + let first_polls = Arc::new(AtomicUsize::new(0)); + let completed = Arc::new(AtomicUsize::new(0)); + let bytes_read = Arc::new(AtomicUsize::new(0)); + let mut tasks = tokio::task::JoinSet::new(); + + for request in 0..REQUESTS { + let key_index = request % key_count; + let object = format!("matrix-object-{key_index}"); + let engine_plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "matrix-bucket", + object: &object, + version_id: None, + etag: "matrix-etag", + size: BODY_BYTES_U64, + data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let cache_key = engine_plan.key().cloned().expect("matrix body must be cacheable"); + let adapter = Arc::clone(&adapter); + let coordinator = Arc::clone(&coordinator); + let disk_permits = Arc::clone(&disk_permits); + let writers = Arc::clone(&writers); + let permit_acquires = Arc::clone(&permit_acquires); + let reader_factories = Arc::clone(&reader_factories); + let first_polls = Arc::clone(&first_polls); + let completed = Arc::clone(&completed); + let bytes_read = Arc::clone(&bytes_read); + tasks.spawn(async move { + let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { + let reservation = adapter.reserve_body(&engine_plan); + let adapter = Arc::clone(&adapter); + let disk_permits = Arc::clone(&disk_permits); + let writers = Arc::clone(&writers); + let permit_acquires = Arc::clone(&permit_acquires); + let reader_factories = Arc::clone(&reader_factories); + let first_polls = Arc::clone(&first_polls); + let completed = Arc::clone(&completed); + let bytes_read = Arc::clone(&bytes_read); + let fill_plan = engine_plan.clone(); + async move { + start_cold_fill_producer( + producer, + reservation, + || async move { + permit_acquires.fetch_add(1, AtomicOrdering::Relaxed); + let permit = disk_permits + .acquire_owned() + .await + .map_err(|_| ColdFillError::DiskAdmissionClosed)?; + let mut io = + DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); + io.disk_permit = Some(permit.into()); + Ok(io) + }, + || async move { + reader_factories.fetch_add(1, AtomicOrdering::Relaxed); + let (writer, reader) = tokio::io::duplex(BODY_BYTES * 2); + writers.lock().await.push(writer); + Ok(GetObjectReader { + stream: Box::new(ColdFillMatrixReader { + inner: reader, + first_poll_recorded: false, + completion_recorded: false, + first_polls, + completed, + bytes_read, + }), + object_info: ObjectInfo { + size: BODY_BYTES_I64, + actual_size: BODY_BYTES_I64, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: BODY_BYTES, + deadline: None, + adapter, + engine_plan: fill_plan, + }, + ) + .await + } + }) + .await; + let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { + panic!("matrix request must receive the shared body, got {outcome:?}"); + }; + assert_eq!(body.len(), BODY_BYTES); + assert!(body.iter().all(|byte| *byte == 7)); + (key_index, body.as_ptr() as usize) + }); + } + + tokio::time::timeout(Duration::from_secs(30), async { + loop { + if writers.lock().await.len() == key_count + && coordinator.global_waiter_count_for_test() == REQUESTS - key_count + && first_polls.load(AtomicOrdering::Relaxed) == key_count + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("all matrix followers must join before releasing bodies"); + + let mut body_writers = std::mem::take(&mut *writers.lock().await); + let body = vec![7_u8; BODY_BYTES]; + for writer in &mut body_writers { + tokio::io::AsyncWriteExt::write_all(writer, &body) + .await + .expect("matrix body write must succeed"); + tokio::io::AsyncWriteExt::shutdown(writer) + .await + .expect("matrix body writer must close"); + } + let mut backing_pointers = std::collections::HashMap::>::new(); + tokio::time::timeout(Duration::from_secs(30), async { + while let Some(result) = tasks.join_next().await { + let (key_index, body_pointer) = result.expect("matrix GET task must complete"); + backing_pointers.entry(key_index).or_default().insert(body_pointer); + } + }) + .await + .expect("matrix GET tasks must complete before the watchdog"); + + assert_eq!(permit_acquires.load(AtomicOrdering::Relaxed), key_count); + assert_eq!(reader_factories.load(AtomicOrdering::Relaxed), key_count); + assert_eq!(first_polls.load(AtomicOrdering::Relaxed), key_count); + assert_eq!(completed.load(AtomicOrdering::Relaxed), key_count); + assert_eq!(bytes_read.load(AtomicOrdering::Relaxed), key_count * BODY_BYTES); + assert_eq!(backing_pointers.len(), key_count); + assert!( + backing_pointers.values().all(|pointers| pointers.len() == 1), + "all followers of one key must share one backing allocation" + ); + assert_eq!( + backing_pointers + .values() + .flatten() + .copied() + .collect::>() + .len(), + key_count + ); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + assert_eq!(disk_permits.available_permits(), key_count); + + for key_index in 0..key_count { + let object = format!("matrix-object-{key_index}"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "matrix-bucket", + object: &object, + version_id: None, + etag: "matrix-etag", + size: BODY_BYTES_U64, + data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + assert!(matches!( + adapter.lookup_body(&plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_) + )); + } + } + } + + // #1324: the in-memory (buffered/cache) source is guarded by + // MemoryTrackedBytesStream. A buffer whose length disagrees with the declared + // content length must yield a stream error on first poll instead of a clean + // short body or an over-long body. Reverting to the old warn-and-serve + // behavior would make these assertions observe Ok chunks. + #[tokio::test] + #[serial_test::serial] + async fn memory_tracked_bytes_stream_fails_short_body() { + let mut stream = MemoryTrackedBytesStream::new( + Bytes::from_static(b"test"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + None, + GetObjectBodyLifecycle::disabled(), + ); + let err = stream + .next() + .await + .expect("mismatched memory body must yield an item") + .expect_err("a short memory body must fail the stream instead of serving a truncated body"); + assert_eq!( + err.downcast_ref::().map(std::io::Error::kind), + Some(std::io::ErrorKind::InvalidData) + ); + assert!(stream.next().await.is_none(), "stream must terminate after the error"); + } + + #[tokio::test] + #[serial_test::serial] + async fn memory_tracked_bytes_stream_fails_over_long_body() { + let mut stream = MemoryTrackedBytesStream::new( + Bytes::from_static(b"hello!"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + None, + GetObjectBodyLifecycle::disabled(), + ); + let err = stream + .next() + .await + .expect("mismatched memory body must yield an item") + .expect_err("an over-long memory body must fail the stream instead of serving mismatched bytes"); + assert_eq!( + err.downcast_ref::().map(std::io::Error::kind), + Some(std::io::ErrorKind::InvalidData) + ); + } + + #[test] + fn memory_blob_preserves_exact_remaining_length() { + let blob = DefaultObjectUsecase::build_memory_bytes_blob( + Bytes::from_static(b"hello"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + GetObjectBodyLifecycle::disabled(), + ); + + assert_eq!(blob.remaining_length().exact(), Some(5)); + } + + #[test] + #[serial_test::serial] + fn memory_blob_once_fast_path_holds_guard_until_bytes_drop() { + temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || { + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let blob = DefaultObjectUsecase::build_memory_bytes_blob( + Bytes::from_static(b"hello"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + GetObjectBodyLifecycle::tracked(guard), + ); + let mut body = s3s::Body::from(blob); + let bytes = body.take_bytes().expect("opt-in exact memory body should stay on Body::Once"); + + assert_eq!(bytes, Bytes::from_static(b"hello")); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + drop(bytes); + assert_eq!(GetObjectGuard::concurrent_count(), initial); + }); + } + + #[test] + #[serial_test::serial] + fn memory_blob_once_fast_path_rejects_length_mismatch() { + temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || { + let blob = DefaultObjectUsecase::build_memory_bytes_blob( + Bytes::from_static(b"test"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + GetObjectBodyLifecycle::disabled(), + ); + let mut body = s3s::Body::from(blob); + + assert!(body.take_bytes().is_none(), "mismatched memory body must keep the guarded stream path"); + }); + } + + #[tokio::test] + async fn get_object_streaming_reader_times_out_when_body_stalls() { + let reader = GetObjectStreamingReader::new( + PendingReader, + "test-bucket", + "stalled-object", + "req-stalled-stream", + None, + 1, + Duration::from_millis(1), + GetObjectBodyLifecycle::disabled(), + None, + ); + let mut stream = ReaderStream::with_capacity(reader, 1024); + + let err = stream + .next() + .await + .expect("reader stream should yield timeout") + .expect_err("stalled reader should return an error"); + + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + } + + #[tokio::test] + async fn get_object_streaming_reader_fails_closed_without_active_reader() { + use tokio::io::AsyncReadExt; + + let mut reader = GetObjectStreamingReader::new( + cursor_reader(b"x"), + "test-bucket", + "missing-reader-object", + "req-missing-reader", + None, + 1, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + None, + ); + reader.inner.take(); + + let err = reader + .read_to_end(&mut Vec::new()) + .await + .expect_err("an impossible missing active reader must fail closed"); + + assert_eq!(err.kind(), std::io::ErrorKind::Other); + assert_eq!(err.to_string(), "get object streaming reader lost its active read outside resume"); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_streaming_reader_holds_request_guard_until_eof() { + use tokio::io::AsyncReadExt; + + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let mut reader = GetObjectStreamingReader::new( + std::io::Cursor::new(b"hello".to_vec()), + "test-bucket", + "complete-object", + "req-complete-stream", + None, + 5, + Duration::ZERO, + GetObjectBodyLifecycle::tracked(guard), + None, + ); + let mut out = Vec::new(); + + reader + .read_to_end(&mut out) + .await + .expect("complete streaming body should read successfully"); + + assert_eq!(out, b"hello"); + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_streaming_reader_errors_on_short_eof() { + use tokio::io::AsyncReadExt; + + // The inner reader delivers 5 bytes then a clean EOF, but the advertised + // Content-Length is 10. The reader must surface an error rather than a clean EOF, so + // the client sees a failed transfer instead of silently persisting a truncated body + // (the "incomplete data mirroring" of #2955). + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let mut reader = GetObjectStreamingReader::new( + std::io::Cursor::new(b"short".to_vec()), + "test-bucket", + "truncated-object", + "req-short-eof", + None, + 10, + Duration::ZERO, + GetObjectBodyLifecycle::tracked(guard), + None, + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("short body under a larger Content-Length must fail the stream"); + assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); + let incomplete_body = err + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + .expect("short eof should include remaining bytes as IncompleteBody"); + assert_eq!(incomplete_body.remaining, 5); + assert_eq!(out, b"short", "bytes read before the short EOF are still delivered"); + + drop(reader); + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + #[test] + #[serial_test::serial] + fn get_object_streaming_reader_releases_request_guard_when_dropped_incomplete() { + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let reader = GetObjectStreamingReader::new( + std::io::Cursor::new(b"short".to_vec()), + "test-bucket", + "dropped-object", + "req-dropped-stream", + None, + 10, + Duration::ZERO, + GetObjectBodyLifecycle::tracked(guard), + None, + ); + drop(reader); + + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + // Emits all of `data`, then either the injected error or a clean EOF. Drives + // the mid-stream resume state machine through its typed-error and + // premature-EOF triggers without a store. + struct FailAtEndReader { + data: std::io::Cursor>, + error: Option, + } + + impl FailAtEndReader { + fn new(data: &[u8], error: Option) -> Self { + Self { + data: std::io::Cursor::new(data.to_vec()), + error, + } + } + } + + impl AsyncRead for FailAtEndReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); + let source_len = self.data.get_ref().len(); + if position >= source_len { + return match self.error.take() { + Some(error) => Poll::Ready(Err(error)), + None => Poll::Ready(Ok(())), + }; + } + let want = buf.remaining().min(source_len - position); + if want == 0 { + return Poll::Ready(Ok(())); + } + buf.put_slice(&self.data.get_ref()[position..position + want]); + self.data.set_position(u64::try_from(position + want).unwrap_or(u64::MAX)); + Poll::Ready(Ok(())) + } + } + + fn relocation_read_error() -> std::io::Error { + std::io::Error::other(StorageError::FileNotFound) + } + + fn counting_resume_control( + reopen_count: Arc, + mut reopen: impl FnMut(usize) -> Result + Send + Sync + 'static, + ) -> GetObjectResumeControl { + let reopen: GetObjectReopen = Box::new(move |emitted| { + reopen_count.fetch_add(1, Ordering::Relaxed); + let outcome = reopen(emitted); + Box::pin(async move { outcome }) + }); + GetObjectResumeControl::new( + reopen, + RetryTimer::new( + GET_OBJECT_RESUME_MAX_ATTEMPTS, + Duration::from_millis(1), + Duration::from_millis(2), + rustfs_utils::retry::NO_JITTER, + 0, + ), + ) + } + + #[tokio::test] + async fn get_object_streaming_reader_resumes_after_relocation_error() { + use tokio::io::AsyncReadExt; + + // Every typed relocation variant the codec read path can surface + // mid-body must arm the resume flow. + for variant in [ + StorageError::FileNotFound, + StorageError::ObjectNotFound("test-bucket".to_string(), "relocated-object".to_string()), + StorageError::InsufficientReadQuorum("test-bucket".to_string(), "relocated-object".to_string()), + StorageError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "relocated shard disappeared")), + ] { + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| { + assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); + Ok(FailAtEndReader::new(b"world", None)) + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(std::io::Error::other(variant))), + "test-bucket", + "relocated-object", + "req-resume-typed-error", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect("a resumed body must deliver the full committed content"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 1); + } + } + + #[tokio::test(start_paused = true)] + async fn get_object_streaming_reader_releases_failed_disk_permit_before_reopen() { + use tokio::io::AsyncReadExt; + + let manager = Arc::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 0)); + let initial_permit = match manager + .admit_disk_read(Duration::ZERO) + .await + .expect("test disk admission must remain open") + { + DiskReadAdmission::Primary(permit) => permit, + other => panic!("initial read must hold the only primary permit, got {other:?}"), + }; + let reopen_count = Arc::new(AtomicUsize::new(0)); + let reopen: GetObjectReopen> = Box::new({ + let manager = Arc::clone(&manager); + let reopen_count = Arc::clone(&reopen_count); + move |emitted| { + assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); + reopen_count.fetch_add(1, Ordering::Relaxed); + let manager = Arc::clone(&manager); + Box::pin(async move { + match manager + .admit_disk_read(Duration::from_millis(1)) + .await + .map_err(|_| GetObjectResumeFailure::Fatal)? + { + DiskReadAdmission::Primary(permit) => { + Ok(DiskReadPermitReader::new(FailAtEndReader::new(b"world", None), permit.into())) + } + _ => Err(GetObjectResumeFailure::Retryable), + } + }) + } + }); + let control = GetObjectResumeControl::new( + reopen, + RetryTimer::new( + GET_OBJECT_RESUME_MAX_ATTEMPTS, + Duration::from_millis(1), + Duration::from_millis(2), + rustfs_utils::retry::NO_JITTER, + 0, + ), + ); + let initial_reader = + DiskReadPermitReader::new(FailAtEndReader::new(b"hello ", Some(relocation_read_error())), initial_permit.into()); + let mut reader = GetObjectStreamingReader::new( + initial_reader, + "test-bucket", + "relocated-object", + "req-resume-single-permit", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + + reader + .read_to_end(&mut out) + .await + .expect("resume must not wait on the failed reader's permit"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 1); + assert_eq!( + manager.io_queue_status().permits_in_use, + 0, + "the replacement reader must release its permit at EOF" + ); + } + + #[tokio::test] + async fn get_object_streaming_reader_resumes_after_premature_eof() { + use tokio::io::AsyncReadExt; + + // The legacy duplex read path surfaces vanished object data as a clean + // EOF before the committed length; the resume flow must treat it like + // the typed relocation error. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| { + assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); + Ok(FailAtEndReader::new(b"world", None)) + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", None), + "test-bucket", + "truncated-object", + "req-resume-short-eof", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect("a resumed body must deliver the full committed content"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn get_object_streaming_reader_clean_eof_does_not_resume() { + use tokio::io::AsyncReadExt; + + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| { + panic!("a cleanly completed body must never reopen"); + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello world", None), + "test-bucket", + "complete-object", + "req-resume-clean-eof", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + reader.read_to_end(&mut out).await.expect("complete body must read"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn get_object_streaming_reader_fatal_resume_failure_returns_original_error() { + use tokio::io::AsyncReadExt; + + // A fatal reopen failure (the reopened object is a different version) + // must surface the original trigger error after exactly one attempt, + // with only the originally emitted prefix delivered. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Fatal)); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(relocation_read_error())), + "test-bucket", + "replaced-object", + "req-resume-fatal", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("a fatal resume failure must fail the body with the original error"); + + assert!( + err.get_ref().is_some_and(|inner| inner.is::()), + "the surfaced error must be the original typed trigger, got: {err}" + ); + assert_eq!(out, b"hello "); + assert_eq!( + reopen_count.load(Ordering::Relaxed), + 1, + "a fatal failure must short-circuit the retry budget" + ); + } + + #[tokio::test] + async fn get_object_streaming_reader_exhausts_resume_budget() { + use tokio::io::AsyncReadExt; + + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Retryable)); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(relocation_read_error())), + "test-bucket", + "vanished-object", + "req-resume-budget", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("an exhausted resume budget must fail the body with the original error"); + + assert!( + err.get_ref().is_some_and(|inner| inner.is::()), + "the surfaced error must be the original typed trigger, got: {err}" + ); + assert_eq!(out, b"hello "); + assert_eq!( + reopen_count.load(Ordering::Relaxed), + usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), + "resume must stop after its reopen budget" + ); + } + + #[tokio::test] + async fn get_object_streaming_reader_rearms_resume_after_a_successful_resume() { + use tokio::io::AsyncReadExt; + + // A successful resume restores the armed state: a second mid-stream + // relocation error on the replacement stream must resume again. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted { + 6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))), + 8 => Ok(FailAtEndReader::new(b"rld", None)), + other => panic!("unexpected reopen offset {other}"), + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(relocation_read_error())), + "test-bucket", + "twice-relocated-object", + "req-resume-rearm", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect("a re-armed resume must deliver the full committed content"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 2); + } + + #[tokio::test] + async fn get_object_streaming_reader_resume_budget_is_per_body() { + use tokio::io::AsyncReadExt; + + // The retry budget is consumed across the whole body, not reset per + // error: one successful resume plus two failed reopens exhausts it. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted { + 6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))), + _ => Err(GetObjectResumeFailure::Retryable), + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(relocation_read_error())), + "test-bucket", + "budget-shared-object", + "req-resume-budget-per-body", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("the shared budget must exhaust and surface the latest trigger error"); + + assert!( + err.get_ref().is_some_and(|inner| inner.is::()), + "the surfaced error must be the typed trigger, got: {err}" + ); + assert_eq!(out, b"hello wo"); + assert_eq!( + reopen_count.load(Ordering::Relaxed), + usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), + "the budget spans every resume of the same body" + ); + } + + #[tokio::test] + async fn get_object_streaming_reader_non_relocation_error_passes_through() { + use tokio::io::AsyncReadExt; + + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| { + panic!("a non-relocation read error must not reopen"); + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(std::io::Error::new(std::io::ErrorKind::InvalidData, "corrupt"))), + "test-bucket", + "corrupt-object", + "req-resume-passthrough", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("a non-relocation error must fail the body unchanged"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(out, b"hello "); + assert_eq!(reopen_count.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn get_object_streaming_reader_error_after_full_delivery_does_not_resume() { + use tokio::io::AsyncReadExt; + + // The committed length is already delivered when the inner stream + // errors, so the error must keep the existing fail-loud behavior + // instead of arming a resume. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| { + panic!("an error after full delivery must not reopen"); + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello world", Some(relocation_read_error())), + "test-bucket", + "fully-delivered-object", + "req-resume-after-full", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("a post-completion inner error still surfaces instead of being swallowed"); + + assert!( + err.get_ref().is_some_and(|inner| inner.is::()), + "the surfaced error must be the inner typed error, got: {err}" + ); + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 0); + } + + #[test] + fn get_object_resume_identity_requires_same_version() { + let version_id = Uuid::from_u128(0x1234); + let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); + let later_mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_100).expect("valid timestamp"); + let identity = GetObjectResumeIdentity { + version_id: Some(version_id), + mod_time: Some(mod_time), + size: 11, + etag: Some("etag-a".to_string()), + range_dependent_size: false, + }; + let info = ObjectInfo { + version_id: Some(version_id), + mod_time: Some(mod_time), + size: 11, + etag: Some("etag-a".to_string()), + ..Default::default() + }; + assert!(identity.matches(&info, 0)); + assert!(identity.matches(&info, 6), "a plain read reports the range-invariant oi.size"); + // Rebalance regenerates data_dir for the same version: identity must + // still match so a relocated read can resume. + assert!(identity.matches( + &ObjectInfo { + data_dir: Some(Uuid::from_u128(0xbeef)), + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + version_id: Some(Uuid::from_u128(0x5678)), + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + version_id: None, + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + mod_time: Some(later_mod_time), + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + size: 12, + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + etag: Some("etag-b".to_string()), + ..info.clone() + }, + 0 + )); + assert!(!identity.matches(&ObjectInfo { etag: None, ..info }, 0)); + } + + #[test] + fn get_object_resume_identity_normalizes_range_dependent_size() { + // Encrypted and compressed reads report the per-read delivered length + // as object_info.size, so the reopened subrange reports size - emitted + // for the same version. + let version_id = Uuid::from_u128(0x1234); + let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); + let identity = GetObjectResumeIdentity { + version_id: Some(version_id), + mod_time: Some(mod_time), + size: 11, + etag: Some("etag-a".to_string()), + range_dependent_size: true, + }; + let reopened = ObjectInfo { + version_id: Some(version_id), + mod_time: Some(mod_time), + size: 5, + etag: Some("etag-a".to_string()), + ..Default::default() + }; + assert!(identity.matches(&reopened, 6), "the reopened subrange reports size - emitted"); + assert!(identity.matches( + &ObjectInfo { + size: 11, + ..reopened.clone() + }, + 0 + )); + assert!( + !identity.matches( + &ObjectInfo { + size: 11, + ..reopened.clone() + }, + 6 + ), + "an unshrunk range-dependent size after emitted bytes is a different object" + ); + assert!(!identity.matches(&ObjectInfo { size: 4, ..reopened }, 6)); + } + + #[test] + fn get_object_resume_range_offsets() { + // A full-object read that emitted nothing reopens range-free so the + // replacement stream keeps the codec fast path. + assert!(GetObjectResumeContext::resume_range(0, -1, 0).is_none()); + + // Mid-stream full-object resume: open-ended from the emitted offset. + let range = GetObjectResumeContext::resume_range(0, -1, 6).expect("a mid-stream resume must carry a range"); + assert!(!range.is_suffix_length); + assert_eq!((range.start, range.end), (6, -1)); + + // Ranged reads resume at absolute offsets with the committed end + // preserved (suffix ranges and partNumber GETs are resolved to absolute + // offsets before these values are captured). + let range = GetObjectResumeContext::resume_range(10, 19, 0).expect("a ranged resume must carry a range"); + assert!(!range.is_suffix_length); + assert_eq!((range.start, range.end), (10, 19)); + let range = GetObjectResumeContext::resume_range(10, 19, 5).expect("a ranged resume must carry a range"); + assert_eq!((range.start, range.end), (15, 19)); + } + + async fn real_get_resume_test_context() -> (Vec, Arc, Arc) { + let (disk_paths, store) = crate::app::gating_test_env::shared_gating_ecstore_and_disk_paths().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let ambient = current_app_context().expect("resume wiring tests require an ambient AppContext"); + let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); + (disk_paths, store, context) + } + + // Uploads a real multipart object through the store and returns the + // concatenated body, so resume wiring tests can verify byte-exact delivery + // against on-disk part files. + async fn put_real_multipart_object( + store: &Arc, + bucket: &str, + object: &str, + part_size: usize, + part_count: usize, + fill: u8, + ) -> Vec { + use crate::app::storage_api::multipart_usecase::contract::multipart::{CompletePart, MultipartOperations as _}; + + let upload = store + .new_multipart_upload(bucket, object, &ObjectOptions::default()) + .await + .expect("create multipart upload"); + let mut parts = Vec::new(); + let mut body = Vec::with_capacity(part_size * part_count); + for part_id in 1..=part_count { + let part_fill = fill.wrapping_add(u8::try_from(part_id - 1).expect("test part index must fit u8")); + let part_body = vec![part_fill; part_size]; + body.extend_from_slice(&part_body); + let mut reader = PutObjReader::from_vec(part_body); + let part = store + .put_object_part(bucket, object, &upload.upload_id, part_id, &mut reader, &ObjectOptions::default()) + .await + .expect("upload multipart part"); + parts.push(CompletePart { + part_num: part_id, + etag: part.etag, + ..Default::default() + }); + } + store + .clone() + .complete_multipart_upload(bucket, object, &upload.upload_id, parts, &ObjectOptions::default()) + .await + .expect("complete multipart upload"); + body + } + + // Deletes the given part files from every version data dir present on the + // disks, simulating rebalance removing the object data while xl.meta stays + // readable. Returns the number of version dirs visited and files removed. + fn delete_object_part_shards( + disk_paths: &[std::path::PathBuf], + bucket: &str, + object: &str, + part_numbers: &[usize], + ) -> (usize, usize) { + let mut version_dirs = 0; + let mut deleted = 0; + for disk_path in disk_paths { + let object_dir = disk_path.join(bucket).join(object); + let entries = match std::fs::read_dir(&object_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => panic!("object directory must be readable: {error}"), + }; + for entry in entries { + let entry = entry.expect("object directory entry must read"); + if !entry.file_type().expect("entry file type must read").is_dir() { + continue; + } + version_dirs += 1; + for part_number in part_numbers { + let part_file = entry.path().join(format!("part.{part_number}")); + if part_file.exists() { + std::fs::remove_file(&part_file).expect("part shard must be removable"); + deleted += 1; + } + } + } + } + (version_dirs, deleted) + } + + // The surfaced mid-stream failure must be the original trigger: a typed + // relocation StorageError from the codec read path, or an IncompleteBody + // (UnexpectedEof) from the duplex path. The resume flow must never + // fabricate a different error. + fn assert_original_trigger_error(error: &(dyn std::error::Error + Send + Sync + 'static)) { + let Some(io_error) = error.downcast_ref::() else { + panic!("body error must be an io::Error, got: {error}"); + }; + let is_trigger = io_error.kind() == std::io::ErrorKind::UnexpectedEof || is_object_relocation_error(io_error); + assert!(is_trigger, "body error must be the original relocation trigger, got: {error}"); + } + + #[tokio::test] + #[serial_test::serial] + // SAFETY: the test mutates one process env var before any use; nextest runs + // each test in its own process, so the mutation cannot race another test. + #[allow(unsafe_code)] + async fn execute_get_object_resume_exhausts_budget_when_object_data_vanishes() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + // The resume phase runs inside the body stall budget (default 10s), + // and three real reopen attempts against missing shards approach it on + // loaded CI disks; widen the budget so this test asserts the resume + // outcome instead of racing the stall timer. + unsafe { std::env::set_var(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, "120") }; + + let (disk_paths, store, context) = real_get_resume_test_context().await; + let bucket = format!("resume-vanish-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create resume failure-path bucket"); + let part_size = 6 * 1024 * 1024; + let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xAA).await; + + // Remove the part.2/part.3 shards on every disk before the GET starts, + // so no file descriptor for them can exist: the stream must fail at the + // part-2 boundary, and every reopen resolves intact metadata whose data + // is gone, so the whole resume budget burns down. + let (version_dirs, deleted) = delete_object_part_shards(&disk_paths, &bucket, object, &[2, 3]); + assert!(version_dirs > 0, "the multipart object must have at least one version data directory"); + assert_eq!(deleted, version_dirs * 2); + + let input = GetObjectInput::builder() + .bucket(bucket) + .key(object.to_string()) + .build() + .expect("resume failure-path GET input must build"); + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed); + let mut response = usecase + .execute_get_object(build_request(input, Method::GET)) + .await + .expect("the GET commits a response; the body fails mid-stream"); + let mut response_body = response.output.body.take().expect("GET response must include a body"); + let mut collected = Vec::new(); + let mut stream_error = None; + while let Some(chunk) = response_body.next().await { + match chunk { + Ok(bytes) => collected.extend_from_slice(&bytes), + Err(error) => { + stream_error = Some(error); + break; + } + } + } + + assert_eq!( + collected, + &body[..part_size], + "only the first part can be delivered before the object data vanishes" + ); + assert_original_trigger_error( + stream_error + .as_deref() + .expect("the body stream must fail at the missing part"), + ); + let attempts = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before; + assert_eq!( + attempts, + usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), + "resume must exhaust its reopen budget before failing" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn execute_get_object_resumes_from_relocated_pool_without_splicing_body() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (temp_dir, pool_disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let ambient = current_app_context().expect("multi-pool resume test requires an ambient AppContext"); + let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); + let bucket = format!("resume-relocate-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create multi-pool resume bucket"); + let part_size = 24 * 1024 * 1024; + let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xA5).await; + let upload_pool = pool_disk_paths + .iter() + .position(|paths| { + paths + .iter() + .any(|path| path.join(&bucket).join(object).join("xl.meta").is_file()) + }) + .expect("multipart object must be placed in one source pool"); + if upload_pool != 0 { + for (source_disk, target_disk) in pool_disk_paths[upload_pool].iter().zip(&pool_disk_paths[0]) { + let source_object = source_disk.join(&bucket).join(object); + let target_bucket = target_disk.join(&bucket); + std::fs::create_dir_all(&target_bucket).expect("create normalized target bucket directory"); + std::fs::rename(source_object, target_bucket.join(object)).expect("normalize the test object into the old pool"); + } + } + let source_pool = 0; + let target_pool = 1; + + let input = GetObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .build() + .expect("multi-pool resume GET input must build"); + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let mut response = usecase + .execute_get_object(build_request(input, Method::GET)) + .await + .expect("multi-pool GET must commit its response"); + let mut response_body = response + .output + .body + .take() + .expect("multi-pool GET response must include a body"); + + // Open the source reader before publishing the relocated object. Build + // each replica outside the bucket and rename it into place atomically so + // background maintenance never observes a metadata-less target object. + let mut staged_targets = Vec::with_capacity(pool_disk_paths[target_pool].len()); + for (source_disk, target_disk) in pool_disk_paths[source_pool].iter().zip(&pool_disk_paths[target_pool]) { + let source_dir = source_disk.join(&bucket).join(object); + let target_dir = target_disk.join(&bucket).join(object); + let staging_dir = temp_dir.path().join(format!("resume-relocate-{}", Uuid::new_v4())); + std::fs::create_dir_all(&staging_dir).expect("create relocated target staging directory"); + for entry in std::fs::read_dir(&source_dir).expect("read source object directory") { + let entry = entry.expect("read source object entry"); + if !entry.file_type().expect("read source object entry type").is_dir() { + continue; + } + let target_entry = staging_dir.join(entry.file_name()); + std::fs::create_dir_all(&target_entry).expect("create relocated target data directory"); + for child in std::fs::read_dir(entry.path()).expect("read source object data directory") { + let child = child.expect("read source object data entry"); + std::fs::copy(child.path(), target_entry.join(child.file_name())).expect("copy relocated object data entry"); + } + } + std::fs::copy(source_dir.join("xl.meta"), staging_dir.join("xl.meta")).expect("stage relocated object metadata"); + staged_targets.push((staging_dir, target_dir)); + } + let (version_dirs, deleted) = delete_object_part_shards(&pool_disk_paths[source_pool], &bucket, object, &[2, 3]); + assert!(version_dirs > 0, "the source pool must have at least one version data directory"); + assert_eq!(deleted, version_dirs * 2); + + for ((staging_dir, target_dir), source_disk) in staged_targets.into_iter().zip(&pool_disk_paths[source_pool]) { + std::fs::rename(staging_dir, target_dir).expect("publish relocated target object"); + let source_meta = source_disk.join(&bucket).join(object).join("xl.meta"); + std::fs::remove_file(source_meta).expect("remove relocated source object metadata"); + } + store + .get_object_info(&bucket, object, &ObjectOptions::default()) + .await + .expect("the relocated object must resolve from the target pool"); + + let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed); + let mut collected = Vec::new(); + while let Some(chunk) = response_body.next().await { + match chunk { + Ok(chunk) => collected.extend_from_slice(&chunk), + Err(err) => panic!( + "relocated GET from pool {source_pool} must resume from pool {target_pool} after {} attempts: {err:?}", + GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before + ), + } + } + + assert_eq!(collected, body, "resumed production GET must preserve the complete body byte-for-byte"); + assert_eq!( + GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before, + 1, + "the relocated body must reopen exactly once" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_resume_reopen_rejects_a_replaced_object_version() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + use tokio::io::AsyncReadExt as _; + + let (_disk_paths, store, _context) = real_get_resume_test_context().await; + let bucket = format!("resume-identity-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create resume identity bucket"); + let body = vec![0xAA; 1024 * 1024]; + put_real_cold_fill_object(&store, &bucket, object, &body).await; + let info = store + .get_object_info(&bucket, object, &ObjectOptions::default()) + .await + .expect("read the committed object metadata"); + + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + &bucket, + object, + ObjectOptions::default(), + &HeaderMap::new(), + &info, + 0, + -1, + ); + + // Positive control: the same version reopens and streams the body. + let manager = get_concurrency_manager(); + let permits_before = manager.io_queue_status().permits_in_use; + let mut reader = ctx.reopen(0).await.expect("reopening the same version must succeed"); + assert_eq!( + manager.io_queue_status().permits_in_use, + permits_before + 1, + "the resumed stream must hold disk-read admission like the initial read" + ); + let mut reopened_body = Vec::new(); + reader + .read_to_end(&mut reopened_body) + .await + .expect("the reopened reader must stream the body"); + assert_eq!(reopened_body, body); + // The reopened reader holds the object read lock; drop it before the + // delete below requests the write lock. + drop(reader); + assert_eq!( + manager.io_queue_status().permits_in_use, + permits_before, + "dropping the resumed stream must release its disk-read admission" + ); + + // A nonzero-offset reopen must splice the remaining bytes exactly. + let mut reader = ctx.reopen(1024).await.expect("reopening at a nonzero offset must succeed"); + let mut tail = Vec::new(); + reader + .read_to_end(&mut tail) + .await + .expect("the offset reader must stream the remaining body"); + assert_eq!(tail, body[1024..], "the resumed stream must continue from the emitted offset exactly"); + drop(reader); + + // Delete and re-PUT the key, then the stale context must refuse to + // splice the replacement version into the committed response. + store + .delete_object(&bucket, object, ObjectOptions::default()) + .await + .expect("delete the original object"); + let replacement_body = vec![0xBB; 2 * 1024 * 1024]; + put_real_cold_fill_object(&store, &bucket, object, &replacement_body).await; + let result = ctx.reopen(0).await; + assert!( + matches!(result, Err(GetObjectResumeFailure::Fatal)), + "reopening a replaced version must fail closed" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_resume_context_pins_latest_read_to_resolved_version() { + let (_disk_paths, store, _context) = real_get_resume_test_context().await; + let resolved_version = Uuid::new_v4(); + let info = ObjectInfo { + version_id: Some(resolved_version), + ..Default::default() + }; + + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &HeaderMap::new(), + &info, + 0, + -1, + ); + assert_eq!( + ctx.opts.version_id, + Some(resolved_version.to_string()), + "latest GET resume must reopen the initially resolved version, not the moving latest" + ); + + let explicit_version = Uuid::new_v4().to_string(); + let explicit_opts = ObjectOptions { + version_id: Some(explicit_version.clone()), + ..Default::default() + }; + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + explicit_opts, + &HeaderMap::new(), + &info, + 0, + -1, + ); + assert_eq!( + ctx.opts.version_id.as_deref(), + Some(explicit_version.as_str()), + "an explicit request version must stay authoritative" + ); + + let unversioned_info = ObjectInfo::default(); + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &HeaderMap::new(), + &unversioned_info, + 0, + -1, + ); + assert_eq!(ctx.opts.version_id, None, "unversioned reads have no version to pin"); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_resume_context_redacts_ssec_headers_and_flags_range_dependent_size() { + let (_disk_paths, store, _context) = real_get_resume_test_context().await; + + let mut request_headers = HeaderMap::new(); + request_headers.insert(SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256")); + request_headers.insert(SSEC_KEY_HEADER, HeaderValue::from_static("dGVzdC1rZXk=")); + request_headers.insert(SSEC_KEY_MD5_HEADER, HeaderValue::from_static("bWQ1")); + request_headers.insert(http::header::AUTHORIZATION, HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test")); + request_headers.insert("x-amz-security-token", HeaderValue::from_static("session-token")); + let store_headers = project_ssec_transport_headers(&request_headers); + assert_eq!(store_headers.len(), 3, "only store-consumed SSE-C headers are forwarded"); + assert!(store_headers.values().all(HeaderValue::is_sensitive)); + assert!(store_headers.get(http::header::AUTHORIZATION).is_none()); + assert!(store_headers.get("x-amz-security-token").is_none()); + assert!(!format!("{store_headers:?}").contains("dGVzdC1rZXk=")); + let plain_info = ObjectInfo { + size: 11, + ..Default::default() + }; + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &request_headers, + &plain_info, + 0, + -1, + ); + for name in [SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER] { + let value = ctx.ssec_headers.get(name).expect("the SSE-C trio is retained"); + assert!(value.is_sensitive(), "store spans record headers at debug; {name} must be redacted there"); + } + assert_eq!( + ctx.ssec_headers.len(), + 3, + "only the SSE-C trio may be retained; credential headers must never be replayed into store spans" + ); + assert!(!ctx.identity.range_dependent_size, "plain reads report the range-invariant oi.size"); + + let encrypted_info = ObjectInfo { + size: 11, + user_defined: Arc::new( + [("x-amz-server-side-encryption".to_string(), "aws:kms".to_string())] + .into_iter() + .collect(), + ), + ..Default::default() + }; + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &HeaderMap::new(), + &encrypted_info, + 0, + -1, + ); + assert!(ctx.identity.range_dependent_size, "encrypted reads report the per-read delivered length"); + + let compressed_info = ObjectInfo { + size: 11, + user_defined: Arc::new( + [("x-rustfs-internal-compression".to_string(), "snappy".to_string())] + .into_iter() + .collect(), + ), + ..Default::default() + }; + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &HeaderMap::new(), + &compressed_info, + 0, + -1, + ); + assert!(ctx.identity.range_dependent_size, "compressed reads report the per-read delivered length"); + } + + #[tokio::test] + #[serial_test::serial] + async fn memory_tracked_bytes_stream_releases_request_guard_after_emit() { + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let mut stream = MemoryTrackedBytesStream::new( + Bytes::from_static(b"hello"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + None, + GetObjectBodyLifecycle::tracked(guard), + ); + let chunk = stream + .next() + .await + .expect("memory body should emit one chunk") + .expect("memory body chunk should be readable"); + + assert_eq!(chunk.as_ref(), b"hello"); + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + #[test] + #[serial_test::serial] + fn memory_tracked_bytes_stream_releases_request_guard_for_zero_length_without_poll() { + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let stream = MemoryTrackedBytesStream::new( + Bytes::new(), + 0, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + None, + GetObjectBodyLifecycle::tracked(guard), + ); + drop(stream); + + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + #[tokio::test] + async fn disk_read_permit_reader_holds_permit_until_reader_is_dropped() { + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + let permit = semaphore + .clone() + .acquire_owned() + .await + .expect("test semaphore should grant owned permit"); + + let reader = DiskReadPermitReader::new(std::io::Cursor::new(Vec::::new()), permit.into()); + assert_eq!(semaphore.available_permits(), 0); + + drop(reader); + assert_eq!(semaphore.available_permits(), 1); + } + + #[tokio::test] + #[serial_test::serial(cold_fill_metrics_gate)] + async fn cold_fill_follower_disk_permit_metric_tracks_actual_permit_lifetime() { + COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.store(0, Ordering::Relaxed); + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { + let permit = semaphore + .clone() + .acquire_owned() + .await + .expect("follower test semaphore must grant an owned permit"); + let tracked = GetObjectDiskPermit::new(permit); + assert_eq!(semaphore.available_permits(), 0); + assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 1); + + drop(tracked); + assert_eq!(semaphore.available_permits(), 1); + assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 0); + }) + .await; + } + + #[test] + #[serial_test::serial(cold_fill_metrics_gate)] + fn cold_fill_disk_permit_metrics_obey_gate_and_return_to_zero() { + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + + let metrics_was_enabled = rustfs_io_metrics::metrics_enabled(); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("metric test runtime must build"); + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + rustfs_io_metrics::set_metrics_enabled(false); + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { + let permit = semaphore + .clone() + .acquire_owned() + .await + .expect("metric test permit must be available"); + let tracked = GetObjectDiskPermit::new(permit); + rustfs_io_metrics::set_metrics_enabled(true); + drop(tracked); + }) + .await; + assert!( + snapshotter.snapshot().into_vec().into_iter().all(|(composite, _, _, _)| { + !composite.key().name().starts_with("rustfs_object_data_cache_cold_fill_") + }), + "a permit acquired while metrics were disabled must not record an unmatched decrement" + ); + + scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Producer, async { + let permit = semaphore + .clone() + .acquire_owned() + .await + .expect("metric test permit must be available"); + let tracked = GetObjectDiskPermit::new(permit); + rustfs_io_metrics::set_metrics_enabled(false); + drop(tracked); + }) + .await; + rustfs_io_metrics::set_metrics_enabled(true); + scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { + let permit = semaphore.acquire_owned().await.expect("metric test permit must be available"); + let tracked = GetObjectDiskPermit::new(permit); + let _replacement = crate::app::object_data_cache::ColdFillCoordinator::default(); + drop(tracked); + }) + .await; + }); + }); + + let values = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(composite, _unit, _description, value)| { + composite + .key() + .name() + .starts_with("rustfs_object_data_cache_cold_fill_") + .then_some((composite.key().name().to_string(), value)) + }) + .collect::>(); + assert_eq!(values.len(), 2); + for name in [ + "rustfs_object_data_cache_cold_fill_producer_disk_permits", + "rustfs_object_data_cache_cold_fill_follower_disk_permits", + ] { + let DebugValue::Gauge(value) = values.get(name).unwrap_or_else(|| panic!("missing {name} gauge")) else { + panic!("{name} must be a gauge"); + }; + assert_eq!(value.into_inner(), 0.0, "{name} must return to zero after permit drop"); + } + rustfs_io_metrics::set_metrics_enabled(metrics_was_enabled); + } + + #[tokio::test] + async fn build_get_object_body_keeps_large_objects_on_streaming_path_without_preread() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 18_i64 * 1024 * 1024 * 1024, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 18_i64 * 1024 * 1024 * 1024, + "req-large-object", + None, + 128 * 1024, + true, + 1, + None, + false, + false, + None, + "test-bucket", + "large-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("build_get_object_body should succeed for streaming path"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "large-object response construction should not pre-read object data" + ); + } + + #[tokio::test] + async fn build_get_object_body_keeps_large_encrypted_objects_on_streaming_path_without_preread() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 18_i64 * 1024 * 1024 * 1024, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 18_i64 * 1024 * 1024 * 1024, + "req-large-encrypted-object", + None, + 128 * 1024, + true, + 1, + None, + false, + true, + None, + "test-bucket", + "large-encrypted-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("build_get_object_body should succeed for encrypted streaming path"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "large encrypted object response construction should not pre-read object data" + ); + } + + #[tokio::test] + async fn build_get_object_body_uses_buffered_body_without_reader_preread() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 4, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 4, + "req-direct-memory-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"test")), + "test-bucket", + "direct-memory-object", + GetObjectBodyLifecycle::disabled(), + |_| panic!("a buffered body must not initialize streaming resume state"), + ) + .await + .expect("build_get_object_body should consume buffered body"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "buffered GetObject body must not be read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_uses_cached_body_without_reader_preread() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "test-bucket", + object: "cached-object", + version_id: None, + etag: "etag", + size: 5, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"hello")).await; + + assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted); + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-cached-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| panic!("a cache hit must not initialize streaming resume state"), + ) + .await + .expect("cache hit body handoff should succeed"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "cache hit body handoff must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_rejects_size_mismatch_fill() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "test-bucket", + object: "cached-object", + version_id: None, + etag: "etag", + size: 5, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"oops")).await; + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-rejects-size-mismatch-fill", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("size-mismatched direct fill should not create a cache hit"); + let lookup_after_mismatch = adapter.lookup_body(&plan).await; + + assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::SkippedSizeMismatch); + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "size-mismatched rejected fill should construct the fallback stream without pre-reading" + ); + assert!( + matches!(lookup_after_mismatch, rustfs_object_data_cache::ObjectDataCacheLookup::Miss), + "size-mismatched fill must not leave a reusable cache entry" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_fills_from_buffered_body_without_reader_preread() { + let first_reads = Arc::new(AtomicUsize::new(0)); + let first_reader = ReadProbeReader { + reads: Arc::clone(&first_reads), + }; + let second_reads = Arc::new(AtomicUsize::new(0)); + let second_reader = ReadProbeReader { + reads: Arc::clone(&second_reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + + let _first_body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + first_reader, + &info, + 5, + "req-cache-fill-first", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"hello")), + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("buffered-body handoff should succeed"); + + // ODC-15: the fill is detached from the response path, so wait for it to + // populate the cache before the follow-up GET to keep the hit deterministic. + wait_for_cache_hit(&adapter, "test-bucket", "cached-object", "etag", 5).await; + + let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + second_reader, + &info, + 5, + "req-cache-fill-second", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("follow-up cache hit should succeed"); + + assert_eq!( + first_reads.load(AtomicOrdering::Relaxed), + 0, + "buffered-body fill path must not read from the fallback reader" + ); + assert_eq!( + second_reads.load(AtomicOrdering::Relaxed), + 0, + "cache hit after buffered-body fill must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_skips_buffered_fill_on_size_mismatch() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "test-bucket", + object: "cached-object", + version_id: None, + etag: "etag", + size: 5, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-rejects-buffered-size-mismatch", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"oops")), + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("size-mismatched buffered-body handoff should still return a response body"); + let lookup = adapter.lookup_body(&plan).await; + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "buffered-body handoff must not read from the fallback reader" + ); + assert!( + matches!(lookup, rustfs_object_data_cache::ObjectDataCacheLookup::Miss), + "size-mismatched buffered body must not be filled into cache" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_hook_served_records_no_second_lookup() { + // ODC-16 (backlog#1121): a hook-served GET must record exactly one + // lookup — the ecstore hook's. The app layer, handed the cache body as + // buffered_body with cache_hook_served=true, must serve it directly + // without a second lookup (which would double the hits and hit_bytes). + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "test-bucket", + object: "hook-served", + version_id: None, + etag: "etag", + size: 5, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let hit_body = Bytes::from_static(b"hello"); + assert_eq!( + adapter.cache().fill_body(&plan, hit_body.clone()).await, + rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted + ); + + // Simulate the ecstore hook: it performs exactly one lookup after fresh + // metadata resolution, hits, and hands the body forward as buffered_body. + assert!(matches!( + adapter.lookup_body(&plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_) + )); + let lookups_after_hook = adapter.cache().stats().lookups; + assert_eq!(lookups_after_hook, 1, "the hook performs exactly one lookup"); + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-hook-served", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(hit_body), + /* cache_hook_served */ true, + /* cache_hook_probed */ true, + /* cache_fill_allowed */ true, + "test-bucket", + "hook-served", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("hook-served body handoff should succeed"); + + assert_eq!( + adapter.cache().stats().lookups, + lookups_after_hook, + "a hook-served GET must not record a second lookup in the app layer" + ); + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "hook-served body handoff must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_hook_miss_skips_app_lookup() { + // ODC-16: when the hook probed and missed, its miss is authoritative + // (it ran after fresh metadata resolution), so the app layer must not + // run a second lookup — it only fills from the buffered body. + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + + let lookups_before = adapter.cache().stats().lookups; + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-hook-missed", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"hello")), + /* cache_hook_served */ false, + /* cache_hook_probed */ true, + /* cache_fill_allowed */ true, + "test-bucket", + "hook-missed", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("hook-miss buffered-body handoff should succeed"); + + assert_eq!( + adapter.cache().stats().lookups, + lookups_before, + "a hook-probed miss must not trigger an app-layer lookup" + ); + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "buffered-body handoff must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_materializes_once_and_hits_later() { + let first_reads = Arc::new(AtomicUsize::new(0)); + let first_reader = DataProbeReader { + reads: Arc::clone(&first_reads), + data: std::io::Cursor::new(b"hello".to_vec()), + }; + let second_reads = Arc::new(AtomicUsize::new(0)); + let second_reader = ReadProbeReader { + reads: Arc::clone(&second_reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let _first_body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + first_reader, + &info, + 5, + "req-materialize-first", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "materialized-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("materialize-fill handoff should succeed"); + + // ODC-15: the fill is detached from the response path, so wait for it to + // populate the cache before the follow-up GET to keep the hit deterministic. + wait_for_cache_hit(&adapter, "test-bucket", "materialized-object", "etag", 5).await; + + let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + second_reader, + &info, + 5, + "req-materialize-second", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "materialized-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("follow-up cache hit should succeed"); + + assert_eq!( + first_reads.load(AtomicOrdering::Relaxed), + 2, + "materialize-fill path should read the source stream once to data and once for EOF" + ); + assert_eq!( + second_reads.load(AtomicOrdering::Relaxed), + 0, + "cache hit after materialize-fill must not read from the fallback reader" + ); + } + + // ODC-07: a materialize read that yields more than the declared content + // length must be a hard error, not a warn-and-serve, matching the + // direct-memory GET path. The bounded `take` reads one byte past capacity so + // the over-long stream is detected without buffering it unbounded. + #[tokio::test] + async fn build_get_object_body_with_cache_materialize_rejects_length_mismatch() { + let reads = Arc::new(AtomicUsize::new(0)); + // Declared content length is 5, but the stream yields 6 bytes. + let reader = DataProbeReader { + reads: Arc::clone(&reads), + data: std::io::Cursor::new(b"hello!".to_vec()), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let result = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-materialize-mismatch", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "mismatch-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await; + + assert!( + result.is_err(), + "an over-long materialize read must be a hard error, not a truncated served body" + ); + } + + // #1324: a materialize-fill read that ends short of the declared content + // length (clean EOF at N-1 for a declared N) must hard-fail, matching the + // over-long case above. Reverting to warn-and-serve would return Ok with a + // truncated body. + #[tokio::test] + async fn build_get_object_body_with_cache_materialize_rejects_short_read() { + let reads = Arc::new(AtomicUsize::new(0)); + // Declared content length is 5, but the stream only yields 4 bytes. + let reader = DataProbeReader { + reads: Arc::clone(&reads), + data: std::io::Cursor::new(b"hell".to_vec()), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let result = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-materialize-short", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "short-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await; + + assert!( + result.is_err(), + "a short materialize read must be a hard error, not a truncated served body" + ); + } + + // #1324: a materialize-fill read that fails after draining K bytes must + // propagate the read error and must NOT fall back to streaming the same + // (partially consumed) reader, which would ship a prefix-misaligned body. + #[tokio::test] + async fn build_get_object_body_with_cache_materialize_rejects_partial_read_error() { + let reader = ErrAfterReader { + data: std::io::Cursor::new(b"hello".to_vec()), + fail_after: 3, + emitted: 0, + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let result = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-materialize-partial", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "partial-read-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await; + + assert!( + result.is_err(), + "a partial-read error during materialization must fail the request, not stream a prefix-misaligned body" + ); + } + + // #1324: the buffered-body (direct-memory / cache-served) source must also + // enforce the exact-length contract. A buffered body shorter than the + // declared content length is a hard error before headers. + #[tokio::test] + async fn build_get_object_body_rejects_short_buffered_body() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + ..Default::default() + }; + + let result = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 5, + "req-short-buffered-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + // Declared length 5 but only 4 buffered bytes. + Some(Bytes::from_static(b"hell")), + "test-bucket", + "short-buffered-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await; + + assert!(result.is_err(), "a buffered body shorter than the declared content length must hard-fail"); + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "the mismatch must be caught without touching the fallback reader" + ); + } + + // #1324 compatibility boundary: a legacy/backfilled object whose decoded + // bytes exactly equal its declared content length must still serve cleanly. + // The strict contract keys off actual-vs-declared equality only, so it never + // flips a legitimate exact-length object into a hard failure — it only + // rejects genuine short/over-long/errored reads. + #[tokio::test] + async fn build_get_object_body_serves_exact_length_buffered_body() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 5, + "req-exact-buffered-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"hello")), + "test-bucket", + "exact-buffered-object", + GetObjectBodyLifecycle::disabled(), + |_| panic!("an exact-length buffered body must not initialize streaming resume state"), + ) + .await + .expect("an exact-length buffered body must serve without error"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "an exact-length buffered body must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_skips_materialize_when_too_large_for_cache() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = DataProbeReader { + reads: Arc::clone(&reads), + data: std::io::Cursor::new(b"hello".to_vec()), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + max_entry_bytes: 4, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-materialize-too-large", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "too-large-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("too-large cache candidate should use streaming fallback"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "too-large materialize-fill candidate must not pre-read the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_keeps_small_plain_objects_on_streaming_path_by_default() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 4, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 4, + "req-small-plain-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + "test-bucket", + "small-plain-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("build_get_object_body should keep small plain object on streaming path"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "default GetObject response construction should not pre-read small plain object data" + ); + } + + #[test] + fn select_stream_buffer_strategy_expands_large_sequential_gets() { + let (buffer_size, strategy) = + DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, false); + + assert_eq!(strategy, GetObjectStreamStrategy::LargeSequentialReadahead); + assert_eq!(buffer_size, 4 * MI_B); + } + + #[test] + fn select_stream_buffer_strategy_keeps_ranges_and_small_gets_standard() { + let (range_buffer_size, range_strategy) = + DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, true); + assert_eq!(range_strategy, GetObjectStreamStrategy::Standard); + assert_eq!(range_buffer_size, 2 * MI_B); + + let (small_buffer_size, small_strategy) = + DefaultObjectUsecase::select_stream_buffer_strategy(64 * 1024 * 1024, 512 * 1024, true, false); + assert_eq!(small_strategy, GetObjectStreamStrategy::Standard); + assert_eq!(small_buffer_size, 512 * 1024); + } + + #[test] + fn tune_reader_stream_buffer_size_raises_large_standard_streams_only() { + assert_eq!( + tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), + LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES + ); + assert_eq!( + tune_reader_stream_buffer_size(512 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), + LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES + ); + assert_eq!( + tune_reader_stream_buffer_size(2 * MI_B, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), + 2 * MI_B + ); + assert_eq!( + tune_reader_stream_buffer_size(128 * 1024, MI_B as i64, GetObjectStreamStrategy::Standard), + MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES + ); + assert_eq!( + tune_reader_stream_buffer_size(256 * 1024, 2 * MI_B as i64, GetObjectStreamStrategy::Standard), + MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES + ); + assert_eq!( + tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::LargeSequentialReadahead), + 128 * 1024 + ); + } + + #[test] + fn resolve_reader_stream_buffer_size_keeps_selected_default() { + let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, None); + + assert_eq!(buffer_size, 128 * 1024); + assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED); + } + + #[test] + fn resolve_reader_stream_buffer_size_applies_positive_override() { + let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(MI_B)); + + assert_eq!(buffer_size, MI_B); + assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE); + } + + #[test] + fn resolve_reader_stream_buffer_size_ignores_zero_override() { + let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(0)); + + assert_eq!(buffer_size, 128 * 1024); + assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED); + } + + #[tokio::test] + async fn get_object_reader_stream_tracks_remaining_length() { + let mut stream = GetObjectReaderStream::new( + std::io::Cursor::new(b"hello".to_vec()), + 2, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + assert_eq!(stream.remaining_length().exact(), Some(5)); + + let first = stream + .next() + .await + .expect("reader stream should emit first chunk") + .expect("first chunk should read"); + + assert_eq!(first.as_ref(), b"he"); + assert_eq!(stream.remaining_length().exact(), Some(3)); + } + + #[tokio::test] + async fn get_object_reader_stream_truncates_to_expected_length() { + let stream = GetObjectReaderStream::new( + std::io::Cursor::new(b"hello!".to_vec()), + 64, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + let chunks = stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("reader stream should read"); + let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| { + acc.extend_from_slice(&chunk); + acc + }); + + assert_eq!(body, b"hello"); + } + + #[tokio::test] + async fn get_object_reader_stream_bounds_read_buffer_to_remaining() { + struct RecordingReader { + data: &'static [u8], + pos: usize, + observed_remaining: Arc>>, + } + + impl AsyncRead for RecordingReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let requested = buf.remaining(); + self.observed_remaining + .lock() + .expect("observed buffer sizes should not poison") + .push(requested); + let available = self.data.len().saturating_sub(self.pos); + let to_copy = requested.min(available); + if to_copy > 0 { + let end = self.pos + to_copy; + buf.put_slice(&self.data[self.pos..end]); + self.pos = end; + } + Poll::Ready(Ok(())) + } + } + + let observed_remaining = Arc::new(Mutex::new(Vec::new())); + let stream = GetObjectReaderStream::new( + RecordingReader { + data: b"hello", + pos: 0, + observed_remaining: Arc::clone(&observed_remaining), + }, + 64, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + let chunks = stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("reader stream should read exact payload"); + assert_eq!(chunks, vec![Bytes::from_static(b"hello")]); + assert_eq!( + *observed_remaining.lock().expect("observed buffer sizes should not poison"), + vec![5], + "stream should not ask the reader for more bytes than the response has left" + ); + } + + #[tokio::test] + async fn get_object_reader_stream_bounds_multi_chunk_final_read() { + let stream = GetObjectReaderStream::new( + std::io::Cursor::new(vec![b'a'; 66]), + 64, + 65, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + let chunks = stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("reader stream should ignore bytes past declared length"); + let chunk_lengths = chunks.iter().map(Bytes::len).collect::>(); + let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| { + acc.extend_from_slice(&chunk); + acc + }); + + assert_eq!(chunk_lengths, vec![64, 1]); + assert_eq!(body, vec![b'a'; 65]); + } + + // Serial with the capture test below: both drive the same short-EOF log + // callsite, and `tracing` caches callsite interest process-wide. Running + // this one concurrently on a thread with no subscriber re-caches that + // callsite as "never interested" and blinds the capture. + #[tokio::test] + #[serial_test::serial] + async fn get_object_reader_stream_errors_on_short_eof() { + let stream = GetObjectReaderStream::new( + std::io::Cursor::new(b"he".to_vec()), + 64, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + let err = stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect_err("short reader should fail the streaming body"); + + assert_eq!( + err.downcast_ref::().map(std::io::Error::kind), + Some(std::io::ErrorKind::UnexpectedEof) + ); + } + + /// Collects the structured fields of every event emitted while installed, + /// so a test can assert what an operator would actually read in the log + /// rather than only that an error value was returned. + type CapturedFieldMap = std::collections::HashMap; + + type CapturedEventLog = Arc>>; + + struct CapturedEvents(CapturedEventLog); + + struct CapturedFields(CapturedFieldMap); + + impl tracing::field::Visit for CapturedFields { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name().to_string(), format!("{value:?}")); + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.0.insert(field.name().to_string(), value.to_string()); + } + } + + impl tracing_subscriber::Layer for CapturedEvents { + fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { + let mut fields = CapturedFields(CapturedFieldMap::new()); + event.record(&mut fields); + self.0.lock().expect("captured events should not poison").push(fields.0); + } + } + + fn capture_events() -> (CapturedEventLog, tracing::subscriber::DefaultGuard) { + use tracing_subscriber::{Registry, prelude::*}; + + let captured = Arc::new(Mutex::new(Vec::new())); + let subscriber = Registry::default().with(CapturedEvents(Arc::clone(&captured))); + let guard = tracing::subscriber::set_default(subscriber); + // `tracing` caches per-callsite interest process-wide, so a subscriber + // installed by a test running in parallel can leave the log sites below + // cached as "never interested" and this capture would silently see + // nothing. Force the callsites to re-ask the subscriber we just + // installed. + tracing::callsite::rebuild_interest_cache(); + (captured, guard) + } + + fn find_stream_body_event(captured: &CapturedEventLog, state: &str) -> CapturedFieldMap { + let events = captured.lock().expect("captured events should not poison"); + events + .iter() + .find(|fields| fields.get("state").is_some_and(|value| value == state)) + .unwrap_or_else(|| { + panic!( + "a `{state}` streaming body failure must be logged, not only counted in a metric. \ + Captured {} event(s): {:?}", + events.len(), + events + ) + }) + .clone() + } + + /// rustfs#4784: a GET body that ends short of its committed Content-Length + /// is the fault that breaks every downstream copier (replication, site + /// replication, `rclone sync`), yet this layer only fed a metric counter — + /// its log line was compiled out unless the `tracing-chunk-debug` feature + /// was on, so operators saw nothing on the source side. + #[tokio::test] + #[serial_test::serial] + async fn get_object_reader_stream_short_eof_names_the_object() { + let (captured, _guard) = capture_events(); + + let stream = GetObjectReaderStream::new( + std::io::Cursor::new(b"he".to_vec()), + 64, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ) + .with_diagnostics("restic-paperless", "index/41b5a4c2344edb90", "req-reader-stream-short-eof"); + + stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect_err("short reader should fail the streaming body"); + + let event = find_stream_body_event(&captured, "reader_stream_short_eof"); + assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless")); + assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90")); + assert_eq!(event.get("request_id").map(String::as_str), Some("req-reader-stream-short-eof")); + assert_eq!(event.get("expected").map(String::as_str), Some("5")); + assert_eq!(event.get("emitted").map(String::as_str), Some("2")); + assert_eq!(event.get("remaining").map(String::as_str), Some("3")); + } + + /// The inner reader already logged mid-stream failures, but only under a + /// request_id — which cannot be resolved back to an object once the request + /// is gone. Without the identity the report in #4784 was unactionable. + #[tokio::test] + #[serial_test::serial] + async fn get_object_streaming_reader_short_eof_names_the_object() { + use tokio::io::AsyncReadExt; + + let (captured, _guard) = capture_events(); + + let mut reader = GetObjectStreamingReader::new( + std::io::Cursor::new(b"short".to_vec()), + "restic-paperless", + "index/41b5a4c2344edb90", + "req-streaming-short-eof", + None, + 10, + Duration::ZERO, + GetObjectBodyLifecycle::tracked(GetObjectGuard::new()), + None, + ); + + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect_err("short body under a larger Content-Length must fail the stream"); + + let event = find_stream_body_event(&captured, "short_eof"); + assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless")); + assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90")); + assert_eq!(event.get("request_id").map(String::as_str), Some("req-streaming-short-eof")); + } + + #[test] + fn get_object_stream_failure_labels_are_low_cardinality() { + assert_eq!(get_object_stream_failure_reason("short_eof"), GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF); + assert_eq!( + get_object_stream_failure_reason("timeout"), + GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR + ); + assert_eq!( + get_object_stream_size_bucket(4 * 1024 * 1024), + rustfs_io_metrics::GET_OBJECT_SIZE_BUCKET_GT_1_MIB + ); + } + + #[tokio::test] + async fn disk_read_permit_reader_releases_permit_at_eof() { + use tokio::io::AsyncReadExt; + + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + let permit = semaphore.clone().acquire_owned().await.expect("acquire permit"); + assert_eq!(semaphore.available_permits(), 0); + + let mut reader = DiskReadPermitReader::new(std::io::Cursor::new(b"hello".to_vec()), permit.into()); + let mut body = Vec::new(); + reader.read_to_end(&mut body).await.expect("read body"); + assert_eq!(body, b"hello"); + + // The reader is still alive (client hasn't dropped the body), but EOF + // was observed, so the permit must already be back in the semaphore. + assert_eq!(semaphore.available_permits(), 1); + drop(reader); + assert_eq!(semaphore.available_permits(), 1); + } + + #[tokio::test] + async fn build_get_object_output_context_returns_standard_headers() { + let mut metadata = HashMap::new(); + metadata.insert("cache-control".to_string(), "public, max-age=259200".to_string()); + metadata.insert("content-disposition".to_string(), "attachment; filename=\"demo.png\"".to_string()); + + let info = ObjectInfo { + bucket: "test-bucket".to_string(), + name: "path/raw".to_string(), + user_defined: Arc::new(metadata), + ..Default::default() + }; + + let input = GetObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("path/raw".to_string()) + .build() + .unwrap(); + let req = build_request(input, Method::GET); + let usecase = DefaultObjectUsecase::without_context(); + let queue_status = concurrency::IoQueueStatus::default(); + + let context = usecase + .build_get_object_output_context( + &req, + get_concurrency_manager(), + "test-bucket", + "path/raw", + info.clone(), + Some(info), + wrap_reader(tokio::io::empty()), + Some(Bytes::new()), + false, + false, + true, + None, + None, + None, + 0, + None, + "req-output-content-disposition", + None, + None, + None, + None, + false, + Duration::ZERO, + 0.0, + &queue_status, + 1, + None, + false, + GetObjectBodyLifecycle::disabled(), + |_| panic!("a buffered output must not initialize streaming resume state"), + ) + .await + .expect("get object output context"); + + assert_eq!(context.output.cache_control.as_deref(), Some("public, max-age=259200")); + assert_eq!(context.output.content_disposition.as_deref(), Some("attachment; filename=\"demo.png\"")); + assert!( + !context + .output + .metadata + .as_ref() + .is_some_and(|metadata| metadata.contains_key("cache-control")) + ); + assert!( + !context + .output + .metadata + .as_ref() + .is_some_and(|metadata| metadata.contains_key("content-disposition")) + ); + } + + #[tokio::test] + async fn execute_get_object_rejects_zero_part_number() { + let input = GetObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .part_number(Some(0)) + .build() + .unwrap(); + + let req = build_request(input, Method::GET); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn parse_get_object_part_number_rejects_above_s3_max() { + let err = parse_part_number_i32_to_usize(Some(10001), "GET").expect_err("partNumber above S3 max must fail"); + + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + assert_eq!(err.message(), Some("GET: partNumber must be between 1 and 10000")); + } + + #[test] + fn validate_get_object_part_number_rejects_missing_part() { + let info = ObjectInfo { + parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo { + number: 1, + ..Default::default() + }]), + ..Default::default() + }; + + let err = + DefaultObjectUsecase::validate_get_object_part_number(Some(2), &info).expect_err("missing requested part must fail"); + + assert_eq!(err.code(), &S3ErrorCode::InvalidPart); + assert!(DefaultObjectUsecase::validate_get_object_part_number(Some(1), &info).is_ok()); + } + + #[test] + fn cold_fill_conditions_fail_before_phase_probe_advances() { + fn run_phase_probe(headers: &HeaderMap, info: &ObjectInfo) -> (S3Result<()>, [usize; 3]) { + let coordination = AtomicUsize::new(0); + let permit = AtomicUsize::new(0); + let reader = AtomicUsize::new(0); + let result = DefaultObjectUsecase::validate_get_object_before_cold_fill(headers, None, info); + if result.is_ok() { + coordination.fetch_add(1, AtomicOrdering::Relaxed); + permit.fetch_add(1, AtomicOrdering::Relaxed); + reader.fetch_add(1, AtomicOrdering::Relaxed); + } + ( + result, + [ + coordination.load(AtomicOrdering::Relaxed), + permit.load(AtomicOrdering::Relaxed), + reader.load(AtomicOrdering::Relaxed), + ], + ) + } + + let info = ObjectInfo { + etag: Some("phase-etag".to_string()), + parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo { + number: 1, + ..Default::default() + }]), + ..Default::default() + }; + + let mut not_modified = HeaderMap::new(); + not_modified.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static("\"phase-etag\"")); + let (result, phases) = run_phase_probe(¬_modified, &info); + assert_eq!(result.expect_err("matching If-None-Match must reject").code(), &S3ErrorCode::NotModified); + assert_eq!(phases, [0, 0, 0]); + + let mut precondition_failed = HeaderMap::new(); + precondition_failed.insert(http::header::IF_MATCH, HeaderValue::from_static("\"other-etag\"")); + let (result, phases) = run_phase_probe(&precondition_failed, &info); + assert_eq!( + result.expect_err("mismatched If-Match must reject").code(), + &S3ErrorCode::PreconditionFailed + ); + assert_eq!(phases, [0, 0, 0]); + } + + #[tokio::test] + async fn execute_get_object_rejects_range_with_part_number() { + let input = GetObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .part_number(Some(1)) + .range(Some(Range::Int { first: 0, last: Some(1) })) + .build() + .unwrap(); + + let req = build_request(input, Method::GET); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[tokio::test] + async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() { + let input = GetObjectAttributesInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::GET); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_get_object_attributes(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InternalError); + } + + #[test] + fn object_attributes_requested_with_single_value() { + let object_attributes = vec![ObjectAttributes::from_static(ObjectAttributes::ETAG)]; + + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); + assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); + } + + #[test] + fn object_attributes_requested_with_comma_separated_values() { + let object_attributes = vec![ + ObjectAttributes::from_static("ObjectParts,etag"), + ObjectAttributes::from_static("StorageClass"), + ]; + + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS)); + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); + assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); + } + + #[test] + fn object_attributes_requested_with_quotes_and_spaces() { + let object_attributes = vec![ObjectAttributes::from_static("'ObjectSize', \"Checksum\" , \"Etag\"")]; + + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::CHECKSUM)); + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); + } + + #[test] + fn object_attributes_requested_returns_false_for_missing_name() { + let object_attributes = vec![ObjectAttributes::from_static("Checksum")]; + + assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); + } +} diff --git a/rustfs/src/app/object/head.rs b/rustfs/src/app/object/head.rs new file mode 100644 index 000000000..7c6055773 --- /dev/null +++ b/rustfs/src/app/object/head.rs @@ -0,0 +1,481 @@ +// 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. + +//! HeadObject path. + +use super::*; + +impl DefaultObjectUsecase { + /// Serve a HEAD whose local lookup failed with not-found by proxying to + /// the bucket's replication targets (MinIO `proxyHeadToRepTarget`). + async fn proxy_head_object_to_replication_targets( + req: &S3Request, + bucket: &str, + key: &str, + opts: &ObjectOptions, + ) -> Option { + let targets = get_read_proxy_targets(bucket, key, opts).await; + if targets.is_empty() { + return None; + } + let extra_headers = Self::proxy_read_passthrough_headers(&req.headers); + let range = req + .headers + .get(http::header::RANGE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let part_number = req.input.part_number; + + for target in targets { + match target + .head_object_for_proxy( + &target.bucket, + key, + opts.version_id.clone(), + range.clone(), + part_number, + extra_headers.clone(), + ) + .await + { + Ok(remote) => { + // MinIO-aligned accounting: one total per proxy attempt, + // one failed when no target served it. + record_replication_proxy(bucket, "HeadObject", false).await; + return Some(Self::proxy_sdk_head_output_to_s3s(remote)); + } + Err(err) if Self::proxy_sdk_error_is_not_found(&err) => { + debug!(bucket, key, arn = %target.arn, "read proxy: target does not have the object"); + } + Err(err) => { + warn!(bucket, key, arn = %target.arn, error = %err, "read proxy: HEAD against replication target failed"); + } + } + } + record_replication_proxy(bucket, "HeadObject", true).await; + None + } + + /// Translate a proxied SDK HEAD response into the s3s output. + /// + /// Known gaps: the SDK's HeadObjectOutput does not model 206/Content-Range + /// for a ranged HEAD (the SDK exposes no content_range member on HEAD), + /// and s3s' typed HeadObjectOutput has no tag_count field (the local path + /// injects x-amz-tagging-count as a raw header) — both are dropped for + /// proxied HEADs. + fn proxy_sdk_head_output_to_s3s(remote: aws_sdk_s3::operation::head_object::HeadObjectOutput) -> HeadObjectOutput { + HeadObjectOutput { + content_length: remote.content_length, + content_type: remote.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()), + content_encoding: remote.content_encoding, + content_disposition: remote.content_disposition, + content_language: remote.content_language, + cache_control: remote.cache_control, + accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()), + e_tag: remote.e_tag.as_deref().and_then(|v| ETag::from_str(v).ok()), + last_modified: remote + .last_modified + .and_then(|dt| OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanos()).ok()) + .map(Timestamp::from), + metadata: remote.metadata, + version_id: remote.version_id, + server_side_encryption: remote + .server_side_encryption + .map(|sse| ServerSideEncryption::from(sse.as_str().to_string())), + sse_customer_algorithm: remote.sse_customer_algorithm, + sse_customer_key_md5: remote.sse_customer_key_md5, + ssekms_key_id: remote.ssekms_key_id, + parts_count: remote.parts_count, + storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())), + expiration: remote.expiration, + restore: remote.restore, + checksum_crc32: remote.checksum_crc32, + checksum_crc32c: remote.checksum_crc32_c, + checksum_crc64nvme: remote.checksum_crc64_nvme, + checksum_sha1: remote.checksum_sha1, + checksum_sha256: remote.checksum_sha256, + checksum_type: remote.checksum_type.map(|ct| ChecksumType::from(ct.as_str().to_string())), + ..Default::default() + } + } + + #[instrument(level = "debug", skip(self, req))] + pub async fn execute_head_object(&self, req: S3Request) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = OperationHelper::new(&req, EventName::ObjectAccessedHead, S3Operation::HeadObject).suppress_event(); + // mc get 2 + let HeadObjectInput { + bucket, + key, + version_id, + part_number, + range, + if_none_match, + if_match, + if_modified_since, + if_unmodified_since, + .. + } = req.input.clone(); + + // Validate object key + validate_object_key(&key, "HEAD")?; + // Parse part number from Option to Option with validation + let part_number: Option = parse_part_number_i32_to_usize(part_number, "HEAD")?; + + let rs = range.map(range_to_http_range_spec).transpose()?; + + if rs.is_some() && part_number.is_some() { + return Err(s3_error!(InvalidArgument, "range and part_number invalid")); + } + + // Establish bucket existence before any bucket-metadata work (matches + // PUT/GET): nonexistent buckets fail here instead of paying the + // versioning lookup in get_opts first. Resolve the store through the + // request-bound server context (backlog#1052 S6), not the + // process-global handle. + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + validate_bucket_exists(&store, &bucket).await?; + + let opts: ObjectOptions = get_opts(&bucket, &key, version_id, part_number, &req.headers) + .await + .map_err(ApiError::from)?; + + // Modification Points: Explicitly handles get_object_info errors, distinguishing between object absence and other errors + let info = match store.get_object_info(&bucket, &key, &opts).await { + Ok(info) => info, + Err(err) => { + // If the error indicates the object or its version was not found, return 404 (NoSuchKey) + if is_err_object_not_found(&err) || is_err_version_not_found(&err) { + if is_dir_object(&key) { + let has_children = match probe_prefix_has_children(store, &bucket, &key, false).await { + Ok(has_children) => has_children, + Err(e) => { + error!(bucket, key, error = %e, "Failed to probe children for prefix"); + false + } + }; + let msg = head_prefix_not_found_message(&bucket, &key, has_children); + return Err(S3Error::with_message(S3ErrorCode::NoSuchKey, msg)); + } + // Active-active replication lag window: an object missing + // locally may still be served by proxying the HEAD to a + // replication target (backlog#1675 P1-5). + if let Some(output) = Self::proxy_head_object_to_replication_targets(&req, &bucket, &key, &opts).await { + let response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; + let result = Ok(response); + let _ = helper + .version_id(req.input.version_id.clone().unwrap_or_default()) + .complete(&result); + return result; + } + return Err(S3Error::new(S3ErrorCode::NoSuchKey)); + } + // Other errors, such as insufficient permissions, still return the original error + return Err(ApiError::from(err).into()); + } + }; + if info.delete_marker { + if opts.version_id.is_none() { + return Err(S3Error::new(S3ErrorCode::NoSuchKey)); + } + return Err(S3Error::new(S3ErrorCode::MethodNotAllowed)); + } + if let Some(match_etag) = if_none_match + && let Some(strong_etag) = match_etag.into_etag() + && info + .etag + .as_ref() + .is_some_and(|etag| ETag::Strong(etag.clone()) == strong_etag) + { + return Err(S3Error::new(S3ErrorCode::NotModified)); + } + if let Some(modified_since) = if_modified_since { + // obj_time < givenTime + 1s + if info.mod_time.is_some_and(|mod_time| { + let give_time: OffsetDateTime = modified_since.into(); + mod_time < give_time.add(time::Duration::seconds(1)) + }) { + return Err(S3Error::new(S3ErrorCode::NotModified)); + } + } + if let Some(match_etag) = if_match { + if let Some(strong_etag) = match_etag.into_etag() + && info + .etag + .as_ref() + .is_some_and(|etag| ETag::Strong(etag.clone()) != strong_etag) + { + return Err(S3Error::new(S3ErrorCode::PreconditionFailed)); + } + } else if let Some(unmodified_since) = if_unmodified_since + && info.mod_time.is_some_and(|mod_time| { + let give_time: OffsetDateTime = unmodified_since.into(); + mod_time > give_time.add(time::Duration::seconds(1)) + }) + { + return Err(S3Error::new(S3ErrorCode::PreconditionFailed)); + } + // An authorized replication convergence check only needs etag/size/mtime + // to compare source and replica; it holds no customer key, so the SSE-C + // read validation is skipped for it (and only it). + let replication_check = replication_request_authorized(&req) + && get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_CHECK).as_deref() == Some("true"); + if !replication_check { + validate_sse_headers_for_read(&info.user_defined, &req.headers)?; + + // Validate SSE-C: if the object was encrypted with a customer-provided key, + // the caller must supply the matching key even for HEAD requests (per S3 spec). + validate_ssec_for_read( + &info.user_defined, + req.input.sse_customer_key.as_ref(), + req.input.sse_customer_key_md5.as_ref(), + )?; + } + + // Compute x-amz-expiration header from lifecycle prediction (before info is partially moved) + let expiration_header = resolve_put_object_expiration(&bucket, &info).await; + // Clone ObjectInfo for event notification only when an event will + // actually be built — the clone is expensive for multipart objects. + let event_info = helper.wants_object_info().then(|| info.clone()); + let content_type = { + if let Some(content_type) = &info.content_type { + match ContentType::from_str(content_type) { + Ok(res) => Some(res), + Err(err) => { + error!(content_type = %content_type, error = ?err, "Archive content-type parse failed"); + // + None + } + } + } else { + None + } + }; + let last_modified = info.mod_time.map(Timestamp::from); + + let content_length = info.get_actual_size().map_err(|e| { + error!(error = %e, "Failed to resolve actual object size"); + ApiError::from(e) + })?; + + let metadata_map = info.user_defined.clone(); + let server_side_encryption = metadata_map + .get("x-amz-server-side-encryption") + .map(|v| ServerSideEncryption::from(v.clone())); + let sse_customer_algorithm = metadata_map + .get("x-amz-server-side-encryption-customer-algorithm") + .map(|v| SSECustomerAlgorithm::from(v.clone())); + let sse_customer_key_md5 = metadata_map.get("x-amz-server-side-encryption-customer-key-md5").cloned(); + let sse_kms_key_id = metadata_map.get("x-amz-server-side-encryption-aws-kms-key-id").cloned(); + let storage_class = response_storage_class(&info, &metadata_map); + // checksum: classify once; additional algorithms (XXHash3/64/128, SHA-512, MD5) + // land in `extra` and are emitted as raw headers below (s3s has no typed field). + let ResponseChecksums { + crc32: checksum_crc32, + crc32c: checksum_crc32c, + sha1: checksum_sha1, + sha256: checksum_sha256, + crc64nvme: checksum_crc64nvme, + checksum_type, + extra: extra_checksum_headers, + } = if let Some(checksum_mode) = req.headers.get(AMZ_CHECKSUM_MODE) + && checksum_mode.to_str().unwrap_or_default() == "ENABLED" + && rs.is_none() + { + let (checksums, is_multipart) = info + .decrypt_checksums(opts.part_number.unwrap_or(0), &req.headers) + .map_err(ApiError::from)?; + classify_response_checksums(checksums, is_multipart) + } else { + ResponseChecksums::default() + }; + // Extract standard HTTP headers from user_defined metadata + // Note: These headers are stored with lowercase keys by extract_metadata_from_mime + let cache_control = metadata_map.get("cache-control").cloned(); + let content_disposition = metadata_map.get("content-disposition").cloned(); + let content_language = metadata_map.get("content-language").cloned(); + let website_redirect_location = metadata_map.get(AMZ_WEBSITE_REDIRECT_LOCATION).cloned(); + let expires = info.expires.map(Timestamp::from); + + // Calculate tag count from user_tags already in ObjectInfo + // This avoids an additional API call since user_tags is already populated by get_object_info + let tag_count = if !info.user_tags.is_empty() { + let tag_set = decode_tags(&info.user_tags); + tag_set.len() + } else { + 0 + }; + let output = HeadObjectOutput { + content_length: Some(content_length), + content_type, + content_encoding: info.content_encoding.clone(), + cache_control, + content_disposition, + content_language, + accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()), + website_redirect_location, + expires, + last_modified, + e_tag: info.etag.map(|etag| to_s3s_etag(&etag)), + metadata: filter_object_metadata(&metadata_map), + version_id: info.version_id.map(|v| v.to_string()), + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id: sse_kms_key_id, + checksum_crc32, + checksum_crc32c, + checksum_sha1, + checksum_sha256, + checksum_crc64nvme, + checksum_type, + storage_class, + // x-amz-restore from object metadata + restore: metadata_map.get(X_AMZ_RESTORE.as_str()).and_then(|v| { + let rs = parse_restore_obj_status(v).ok()?; + Some(rs.to_string2()) + }), + // x-amz-expiration from lifecycle prediction + expiration: expiration_header, + // metadata: object_metadata, + ..Default::default() + }; + + let version_id = req.input.version_id.clone().unwrap_or_default(); + if let Some(event_info) = event_info { + helper = helper.object(event_info); + } + helper = helper.version_id(version_id); + + // NOTE ON CORS: + // Bucket-level CORS headers are intentionally applied only for object retrieval + // operations (GET/HEAD) via `wrap_response_with_cors`. Other S3 operations that + // interact with objects (PUT/POST/DELETE/LIST, etc.) rely on the system-level + // CORS layer instead. In case both are applicable, this bucket-level CORS logic + // takes precedence for these read operations. + let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; + + // Emit additional-checksum headers (XXHash3/64/128, SHA-512) that s3s cannot + // carry on the typed HeadObjectOutput (#1257). + inject_additional_checksum_headers(&mut response.headers, &extra_checksum_headers); + + // Add x-amz-tagging-count header if object has tags + // Per S3 API spec, this header should be present in HEAD object response when tags exist + if tag_count > 0 { + let header_name = http::HeaderName::from_static(AMZ_TAG_COUNT); + if let Ok(header_value) = tag_count.to_string().parse::() { + response.headers.insert(header_name, header_value); + } else { + warn!("Failed to parse x-amz-tagging-count header; skipping"); + } + } + if let Some(retain_date) = metadata_map + .get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) + .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE)) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(retain_date) + { + response.headers.insert(header_name, header_value); + } + if let Some(mode) = metadata_map + .get(AMZ_OBJECT_LOCK_MODE_LOWER) + .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_MODE)) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_MODE_LOWER.as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(mode) + { + response.headers.insert(header_name, header_value); + } + if let Some(legal_hold) = metadata_map + .get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) + .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_LEGAL_HOLD)) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(legal_hold) + { + response.headers.insert(header_name, header_value); + } + + if let Some(amz_restore) = metadata_map.get(X_AMZ_RESTORE.as_str()) { + let Ok(restore_status) = parse_restore_obj_status(amz_restore) else { + return Err(S3Error::with_message(S3ErrorCode::Custom("ErrMeta".into()), "parse amz_restore failed.")); + }; + if let Ok(header_value) = HeaderValue::from_str(restore_status.to_string2().as_str()) { + response.headers.insert(X_AMZ_RESTORE, header_value); + } + } + if let Some(amz_restore_request_date) = metadata_map.get(AMZ_RESTORE_REQUEST_DATE) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_RESTORE_REQUEST_DATE.as_bytes()) + { + let Ok(amz_restore_request_date) = OffsetDateTime::parse(amz_restore_request_date, &Rfc3339) else { + return Err(S3Error::with_message( + S3ErrorCode::Custom("ErrMeta".into()), + "parse amz_restore_request_date failed.", + )); + }; + let Ok(amz_restore_request_date) = amz_restore_request_date.format(&RFC1123) else { + return Err(S3Error::with_message( + S3ErrorCode::Custom("ErrMeta".into()), + "format amz_restore_request_date failed.", + )); + }; + if let Ok(header_value) = HeaderValue::from_str(&amz_restore_request_date) { + response.headers.insert(header_name, header_value); + } + } + if let Some(amz_restore_expiry_days) = metadata_map.get(AMZ_RESTORE_EXPIRY_DAYS) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_RESTORE_EXPIRY_DAYS.as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(amz_restore_expiry_days) + { + response.headers.insert(header_name, header_value); + } + if info.replication_status != ReplicationStatusType::Empty + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_BUCKET_REPLICATION_STATUS.to_ascii_lowercase().as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(info.replication_status.as_str()) + { + response.headers.insert(header_name, header_value); + } + + let result = Ok(response); + let _ = helper.complete(&result); + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::Method; + + #[tokio::test] + async fn execute_head_object_rejects_range_with_part_number() { + let input = HeadObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .part_number(Some(1)) + .range(Some(Range::Int { first: 0, last: Some(1) })) + .build() + .unwrap(); + + let req = build_request(input, Method::HEAD); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_head_object(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } +} diff --git a/rustfs/src/app/object/mod.rs b/rustfs/src/app/object/mod.rs new file mode 100644 index 000000000..cb30c01b9 --- /dev/null +++ b/rustfs/src/app/object/mod.rs @@ -0,0 +1,346 @@ +// 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. + +//! Object application use-case contracts. + +// Performance metrics recording (with zero-copy-metrics integration) +use rustfs_io_metrics::buffered_write; + +use crate::storage_api::table::get_bucket_metadata; + +use super::storage_api::object_usecase::access::{ + PostObjectRequestMarker, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_request, + has_bypass_governance_header, load_bucket_generation_from_store, recursive_force_delete_is_authorized, + replication_request_authorized, req_info_mut, req_info_ref, +}; +#[cfg(test)] +use super::storage_api::object_usecase::bucket::quota::BucketQuota; +use super::storage_api::object_usecase::bucket::quota::checker::QuotaChecker; +#[cfg(test)] +use super::storage_api::object_usecase::bucket::replication::{ReplicationState, replication_statuses_map}; +use super::storage_api::object_usecase::bucket::{ + VersioningConfigExt as _, + lifecycle::{ + bucket_lifecycle_audit::LcEventSrc, + bucket_lifecycle_ops::{enqueue_transition_immediate, post_restore_opts}, + lifecycle::{self, TransitionOptions}, + }, + metadata_sys, + object_lock::{ + objectlock::{get_object_legalhold_meta, get_object_retention_meta}, + objectlock_sys::{check_object_lock_for_deletion, is_retention_active, replication_write_may_pass_worm_gate}, + types::RetentionMode, + }, + predict_lifecycle_expiration, + quota::{QuotaCheckResult, QuotaError, QuotaOperation}, + replication::{ + DeleteReplicationConfigSnapshot, REPLICATE_INCOMING_DELETE, ReplicationStatusType, commit_force_delete_intent, + delete_replication_state_from_config, delete_replication_version_id, deleted_object_has_pending_replication_delete, + force_delete_target_set, get_read_proxy_targets, has_active_delete_rule, load_delete_config_snapshot, + must_replicate_object, persist_force_delete_intent, record_replication_proxy, schedule_object_replication, + schedule_replication_delete, schedule_replication_deletes, set_deleted_object_replication_state, + should_schedule_delete_replication, should_use_existing_delete_replication_info, + }, + tagging::decode_tags, + validate_restore_request, + versioning_sys::BucketVersioningSys, +}; +use super::storage_api::object_usecase::compression::{MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible}; +use super::storage_api::object_usecase::concurrency::{ + self, ConcurrencyManager, DiskReadAdmission, GetObjectGuard, PutObjectAdmission, PutObjectGuard, + get_concurrency_aware_buffer_size, get_concurrency_manager, get_put_concurrency_aware_buffer_size, +}; +#[cfg(test)] +use super::storage_api::object_usecase::contract::http::HTTPPreconditions; +use super::storage_api::object_usecase::contract::namespace::NamespaceLocking; +use super::storage_api::object_usecase::contract::object::{ObjectIO as _, ObjectOperations as _}; +use super::storage_api::object_usecase::contract::range::HTTPRangeSpec; +use super::storage_api::object_usecase::data_usage::{ + quota_object_size, record_bucket_delete_marker_memory, record_bucket_object_delete_memory, + record_bucket_object_version_write_memory, record_bucket_object_write_memory, + record_bucket_object_write_unknown_previous_memory, +}; +use super::storage_api::object_usecase::deadlock_detector; +use super::storage_api::object_usecase::ecfs::FS; +use super::storage_api::object_usecase::error::{ + Error as EcstoreError, StorageError, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found, +}; +use super::storage_api::object_usecase::head_prefix::{head_prefix_not_found_message, probe_prefix_has_children}; +use super::storage_api::object_usecase::helper::{OperationHelper, build_event_resp_elements, spawn_background_with_context}; +use super::storage_api::object_usecase::io::{DynReader, HashReader, WritePlan, compression_metadata_value, wrap_reader}; +#[cfg(test)] +use super::storage_api::object_usecase::object_cache::GetObjectBodySource; +#[cfg(test)] +use super::storage_api::object_usecase::object_cache::lookup_get_object_body_cache_hook; +use super::storage_api::object_usecase::object_cache::{GetObjectBodyCacheHookLookup, get_object_body_cache_plaintext_len}; +use super::storage_api::object_usecase::object_utils::to_s3s_etag; +use super::storage_api::object_usecase::options::{ + copy_dst_opts_with_replication_authorization, copy_src_opts, del_opts_with_versioning, extract_metadata, + extract_metadata_from_mime_with_object_name, filter_object_metadata, get_content_sha256_with_query, get_opts, + has_replication_retention_update, namespace_reserved_user_metadata, normalize_content_encoding_for_storage, + preserve_unclassified_user_metadata, put_opts_with_replication_authorization, validate_archive_content_encoding, +}; +use super::storage_api::object_usecase::request_context::{self, spawn_traced, spawn_traced_join}; +use super::storage_api::object_usecase::s3_api::multipart::parse_list_parts_params; +use super::storage_api::object_usecase::set_disk::{ + get_lock_acquire_timeout, get_object_disk_read_timeout, is_valid_storage_class, +}; +use super::storage_api::object_usecase::sse::{ + DecryptionRequest, EncryptionRequest, SseKmsPrincipal, apply_bucket_default_lock_retention, authorize_sse_kms_object_read, + bucket_default_write_sse, build_ssec_read_headers, classify_sse_read_response, encryption_material_to_metadata, + extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers, + get_buffer_size_opt_in, load_bucket_object_lock_config_state, map_get_object_reader_error, sse_encryption, + validate_bucket_object_lock_enabled_state, +}; +use super::storage_api::object_usecase::storage_class as storageclass; +use super::storage_api::object_usecase::timeout_wrapper::{GetObjectTimeoutPolicy, RequestTimeoutWrapper}; +use super::storage_api::object_usecase::{ECStore, OldCurrentSize}; +use super::storage_api::object_usecase::{ + RFC1123, check_preconditions, parse_object_lock_legal_hold, parse_object_lock_retention, parse_part_number_i32_to_usize, + remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata, validate_bucket_exists, validate_object_key, + validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read, wrap_response_with_cors, +}; +use crate::app::runtime_sources::{ + AppContext, current_app_context, current_notify_interface_for_context, current_object_data_cache_for_context, + current_object_store_handle_for_context, +}; +use crate::config::RustFSBufferConfig; +use crate::delete_tail_activity::{DeleteTailActivityGuard, DeleteTailStage}; +use crate::error::ApiError; +use crate::shared_types::convert_ecstore_object_info; +use crate::table_catalog; +use bytes::{BufMut as _, Bytes, BytesMut}; +use futures::{Stream, StreamExt, TryStreamExt}; +use http::{HeaderMap, HeaderValue, StatusCode}; +use md5::{Digest as Md5Digest, Md5}; +use metrics::{counter, histogram}; +use pin_project_lite::pin_project; +use rustfs_audit::ObjectVersion as AuditObjectVersion; +use rustfs_concurrency::GetObjectQueueSnapshot; +use rustfs_config::MI_B; +use rustfs_filemeta::{NULL_VERSION_ID, RestoreStatusOps, parse_restore_obj_status}; +use rustfs_io_core::{BytesPool, PooledBuffer}; +use rustfs_io_metrics; +use rustfs_lock::NamespaceLockGuard; +use rustfs_notify::EventArgsBuilder; +use rustfs_object_capacity::capacity_manager::get_capacity_manager; +use rustfs_policy::policy::action::{Action, S3Action}; +use rustfs_s3_ops::{S3Operation, delete_event_name_for_marker, put_event_name_for_post_object}; +use rustfs_targets::{EventName, get_request_host, get_request_port, get_request_user_agent}; +use rustfs_utils::CompressionAlgorithm; +#[cfg(test)] +use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER}; +#[cfg(test)] +use rustfs_utils::http::insert_header; +use rustfs_utils::http::{ + AMZ_BUCKET_REPLICATION_STATUS, AMZ_CHECKSUM_MODE, AMZ_CHECKSUM_TYPE, AMZ_WEBSITE_REDIRECT_LOCATION, CONTENT_TYPE, + SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICA_STATUS, SUFFIX_REPLICA_TIMESTAMP, + SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_RESTORE_OPERATION_ID, SUFFIX_SOURCE_REPLICATION_CHECK, + SUFFIX_SOURCE_REPLICATION_REQUEST, get_header, + headers::{ + AMZ_CONTENT_SHA256, AMZ_DECODED_CONTENT_LENGTH, AMZ_MINIO_SNOWBALL_IGNORE_DIRS, AMZ_MINIO_SNOWBALL_IGNORE_ERRORS, + AMZ_MINIO_SNOWBALL_PREFIX, AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE, + AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, + AMZ_OBJECT_TAGGING, AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE, AMZ_RUSTFS_SNOWBALL_IGNORE_DIRS, + AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, AMZ_RUSTFS_SNOWBALL_PREFIX, AMZ_SERVER_SIDE_ENCRYPTION, + AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, AMZ_SNOWBALL_EXTRACT, + AMZ_SNOWBALL_IGNORE_DIRS, AMZ_SNOWBALL_IGNORE_ERRORS, AMZ_SNOWBALL_PREFIX, AMZ_STORAGE_CLASS, AMZ_TAG_COUNT, + }, + insert_str, project_ssec_transport_headers, remove_str, +}; +use rustfs_utils::path::{encode_dir_object, is_dir_object, path_join_buf}; +use rustfs_utils::retry::{DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, RetryTimer}; +use rustfs_zip::{ArchiveLimits, CompressionFormat}; +use s3s::StdError; +use s3s::dto::{ + CacheControl, Checksum, ChecksumAlgorithm, ChecksumType, ContentDisposition, ContentEncoding, ContentLanguage, ContentType, + CopyObjectInput, CopyObjectOutput, CopyObjectResult, CopySource, DeleteObjectInput, DeleteObjectOutput, DeleteObjectsInput, + DeleteObjectsOutput, DeletedObject, ETag, GetObjectAttributesInput, GetObjectAttributesOutput, GetObjectAttributesParts, + GetObjectInput, GetObjectOutput, HeadObjectInput, HeadObjectOutput, MetadataDirective, ObjectAttributes, ObjectLockLegalHold, + ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, ObjectPart, PutObjectInput, + PutObjectOutput, Range, RequestCharged, RestoreObjectInput, RestoreObjectOutput, RestoreStatus, SSECustomerAlgorithm, + SSECustomerKeyMD5, SSEKMSKeyId, SelectObjectContentInput, SelectObjectContentOutput, ServerSideEncryption, + ServerSideEncryptionConfiguration, StorageClass, StreamingBlob, TaggingDirective, TaggingHeader, Timestamp, TimestampFormat, + WebsiteRedirectLocation, +}; +use s3s::header::{X_AMZ_RESTORE, X_AMZ_RESTORE_OUTPUT_PATH}; +use s3s::stream::{ByteStream, DynByteStream, RemainingLength}; +use s3s::{S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; + +mod copy; +mod delete; +mod extract; +mod get; +mod head; +mod put; +mod restore; +mod shared; +#[cfg(test)] +mod test_support; + +pub(crate) use self::copy::*; +#[cfg(test)] +pub(crate) use self::delete::*; +pub(crate) use self::extract::*; +pub(crate) use self::get::*; +use self::put::*; +pub(crate) use self::shared::*; +#[cfg(test)] +use self::test_support::*; + +use std::collections::HashMap; +use std::io; +use std::ops::Add; +use std::path::Path; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use std::str::FromStr; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::{OwnedSemaphorePermit, RwLock}; +use tokio_tar::Archive; +#[cfg(test)] +use tokio_util::io::ReaderStream; +use tokio_util::io::{StreamReader, poll_read_buf}; +use tracing::{debug, error, instrument, warn}; +use uuid::Uuid; + +use super::storage_api::object_usecase::{ + BUCKET_LIFECYCLE_LOCK_OBJECT, GetObjectReader, StorageDeletedObject, StorageObjectInfo as ObjectInfo, + StorageObjectLockDeleteOptions, StorageObjectOptions as ObjectOptions, StorageObjectToDelete as ObjectToDelete, + StoragePutObjReader as PutObjReader, +}; +use crate::app::object_data_cache::{ + ColdFillCoordinateOutcome, ColdFillDiskPermitOwner, ColdFillError, ColdFillProducer, GetObjectBodyCacheLookup, + GetObjectBodyCachePlan, GetObjectBodyCacheRequest, ObjectDataCacheAdapter, build_get_object_body_cache_plan, + build_get_object_body_cache_plan_for_revalidation, coordinate_cold_fill, current_cold_fill_disk_permit_owner, + fill_get_object_body_cache_from_buffered_body, fill_get_object_body_cache_from_materialized_body, + invalidate_object_data_cache_after_copy_success, invalidate_object_data_cache_after_delete_success, + invalidate_object_data_cache_after_put_success, invalidate_object_data_cache_before_mutation, + invalidate_object_data_cache_objects_after_delete_success, invalidate_object_data_cache_objects_before_mutation, + invalidate_object_data_cache_prefix_after_delete, invalidate_object_data_cache_prefix_before_mutation, + lookup_get_object_body_cache_hit, lookup_preplanned_get_object_body_cache_hook, +}; +#[cfg(test)] +use crate::app::object_data_cache::{ColdFillRole, ColdFillWaitOutcome, scope_cold_fill_disk_permit_owner_for_test}; +use crate::app::object_traffic_health::ObjectTrafficHealth; + +#[derive(Clone, Default)] +pub struct DefaultObjectUsecase { + context: Option>, + #[cfg(test)] + get_object_timeout_policy: Option, +} + +impl DefaultObjectUsecase { + #[cfg(test)] + pub fn without_context() -> Self { + Self { + context: None, + get_object_timeout_policy: None, + } + } + + pub fn from_global() -> Self { + Self { + context: current_app_context(), + #[cfg(test)] + get_object_timeout_policy: None, + } + } + + /// Build the use-case bound to an explicit application context + /// (backlog#1052 S6): the per-server request path passes its own context + /// so the use-case resolves that server's store; `None` falls back to the + /// ambient default. + pub fn with_context(context: Option>) -> Self { + Self { + context, + #[cfg(test)] + get_object_timeout_policy: None, + } + } + + #[cfg(test)] + fn with_context_and_get_object_timeout_policy( + context: Option>, + get_object_timeout_policy: GetObjectTimeoutPolicy, + ) -> Self { + Self { + context, + get_object_timeout_policy: Some(get_object_timeout_policy), + } + } + + fn bucket_metadata_sys(&self) -> Option>> { + self.context.as_ref().and_then(|context| context.bucket_metadata().handle()) + } + + fn object_store(&self) -> Option> { + current_object_store_handle_for_context(self.context.as_deref()) + } + + fn object_data_cache(&self) -> Arc { + current_object_data_cache_for_context(self.context.as_deref()) + } + + fn object_traffic_health(&self) -> Option> { + self.context + .as_ref() + .map(|context| context.object_traffic_health()) + .or_else(|| current_app_context().map(|context| context.object_traffic_health())) + } + + fn base_buffer_size(&self) -> usize { + self.context + .clone() + .or_else(current_app_context) + .map(|context| context.buffer_config().get().base_config.default_unknown) + .unwrap_or_else(|| RustFSBufferConfig::default().base_config.default_unknown) + } + + async fn check_bucket_quota(&self, bucket: &str, op: QuotaOperation, size: u64) -> S3Result> { + let Some(metadata_sys) = self.bucket_metadata_sys() else { + return Ok(None); + }; + let quota_checker = QuotaChecker::new(metadata_sys); + map_quota_check_outcome(bucket, quota_checker.check_quota(bucket, op, size).await).map(Some) + } + + #[hotpath::measure( + label = "rustfs::app::object_usecase::DefaultObjectUsecase::execute_put_object", + impl_type = "DefaultObjectUsecase" + )] + #[hotpath::measure( + label = "rustfs::app::object_usecase::DefaultObjectUsecase::execute_get_object", + impl_type = "DefaultObjectUsecase" + )] + #[instrument(level = "debug", skip(self, req))] + pub async fn execute_select_object_content( + &self, + req: S3Request, + ) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + crate::app::select_object::execute_select_object_content(req).await + } +} diff --git a/rustfs/src/app/object/put.rs b/rustfs/src/app/object/put.rs new file mode 100644 index 000000000..9b690d94e --- /dev/null +++ b/rustfs/src/app/object/put.rs @@ -0,0 +1,3199 @@ +// 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. + +//! PutObject write path: body admission, eager commit, zero-copy tuning. + +use super::*; + +const DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES: i64 = 32 * 1024 * 1024; + +const ENV_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: &str = "RUSTFS_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES"; + +const DEFAULT_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: usize = 16 * 1024 * 1024; + +const PUT_EAGER_STATUS_ELIGIBLE: &str = "eligible"; + +const PUT_EAGER_STATUS_EXTRACT: &str = "extract"; + +const PUT_EAGER_STATUS_COMPRESSED: &str = "compressed"; + +const PUT_EAGER_STATUS_ENCRYPTED: &str = "encrypted"; + +const PUT_EAGER_STATUS_INVALID_SIZE: &str = "invalid_size"; + +const PUT_EAGER_STATUS_ABOVE_EAGER_MAX: &str = "above_eager_max"; + +const PUT_EAGER_STATUS_ZERO_COPY_INELIGIBLE: &str = "zero_copy_ineligible"; + +const PUT_EAGER_STATUS_AWS_CHUNKED_MISSING_DECODED_LENGTH: &str = "aws_chunked_missing_decoded_length"; + +static CACHED_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: std::sync::OnceLock = std::sync::OnceLock::new(); + +const EVENT_PUT_OBJECT_STORE_INFLIGHT_SLOW: &str = "put_object_store_inflight_slow"; + +const EVENT_PUT_OBJECT_STORE_RETURNED: &str = "put_object_store_returned"; + +const EVENT_PUT_OBJECT_COMMIT_OWNER_DEADLINE: &str = "put_object_commit_owner_deadline"; + +const EVENT_PUT_OBJECT_BODY_READ_STALLED: &str = "put_object_body_read_stalled"; + +const PUT_OBJECT_STORE_WARN_THRESHOLD: Duration = Duration::from_secs(5); + +// Eager PUT bodies are fully materialized before the storage owner starts. On +// request cancellation, keep the commit/publication tail alive briefly, then +// request pre-commit rollback and await cleanup so its write-health guard is +// reaped without abandoning staged shards. +const EAGER_PUT_COMMIT_CANCELLATION_GRACE: Duration = + Duration::from_secs(rustfs_config::DEFAULT_DRIVE_MAX_TIMEOUT_DURATION_SECS * 4); + +/// Resolve the authoritative object length that bucket-quota admission (and downstream sizing) must use. +/// +/// `Content-Encoding: aws-chunked` alone only *declares* the encoding; whether the body actually arrived chunk-framed is signalled by a `STREAMING-*` `x-amz-content-sha256`, and the S3 auth layer both requires `x-amz-decoded-content-length` for those requests and hands the body down already de-framed. So when a decoded length is present it is authoritative (the wire `Content-Length` counts chunk framing and would overcount); a framed body without a decoded length is rejected rather than falling back to the framed wire length. A declared-only aws-chunked request (issue #1857 clients) carries an unframed body, so its wire `Content-Length` is the authoritative size, exactly as for a plain PUT. A negative or otherwise unknown length is rejected so it can never be reinterpreted as an enormous unsigned size downstream. +fn resolve_put_object_authoritative_size(headers: &HeaderMap, content_length: Option) -> S3Result { + let decoded_content_length = decoded_content_length_from_headers(headers)?; + let aws_chunked = request_uses_aws_chunked(headers) || request_body_is_aws_chunked_framed(headers); + let size = match (aws_chunked, decoded_content_length, content_length) { + (true, Some(decoded), _) => decoded, + // Declared aws-chunked without a streaming payload: the body is not framed (the auth + // layer only de-frames STREAMING-* payloads, which always carry a decoded length), so + // the wire Content-Length is the real object size. + (true, None, Some(raw)) if !request_body_is_aws_chunked_framed(headers) => raw, + (true, None, _) => return Err(s3_error!(UnexpectedContent)), + (false, _, Some(raw)) => raw, + (false, Some(decoded), None) => decoded, + (false, None, None) => return Err(s3_error!(UnexpectedContent)), + }; + + if size < 0 { + return Err(s3_error!(UnexpectedContent)); + } + + Ok(size) +} + +/// Resolve the S3 request-body inter-chunk read timeout from the environment. +/// +/// Returns `Duration::ZERO` when disabled (`RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT=0`), +/// in which case [`guard_put_object_body_read_timeout`] passes the body through +/// untouched. +fn put_object_body_read_timeout() -> Duration { + Duration::from_secs(rustfs_utils::get_env_u64( + rustfs_config::ENV_HTTP_REQUEST_BODY_READ_TIMEOUT, + rustfs_config::DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT, + )) +} + +/// A [`ByteStream`] decorator that aborts a request body whose peer stops +/// sending bytes without closing the connection. +/// +/// A well-behaved short body ends with EOF and is rejected promptly by the +/// eager/streaming readers. The failure this guards against is different: a +/// reverse proxy or CDN forwards a *partial* body and then goes silent while +/// holding the connection open, so the inner stream neither yields more bytes +/// nor reports EOF. Without a bound, RustFS would wait forever for bytes that +/// never arrive and the client eventually sees a hang/abort with no server-side +/// explanation (issue #3076). +/// +/// The timer resets on every chunk, so slow-but-progressing uploads are not +/// penalized; it only fires after `timeout` of complete silence. On timeout the +/// stall is logged with the received/expected byte counts and the read fails +/// with an `ErrorKind::TimedOut` error instead of hanging. +/// +/// `remaining_length` and `size_hint` are forwarded from the inner stream so +/// wrapping is transparent to length/content handling downstream. +struct RequestBodyReadTimeout { + inner: DynByteStream, + timeout: Duration, + timer: Option>>, + received: u64, + expected: Option, + bucket: String, + key: String, + request_id: String, + timed_out: bool, +} + +impl Stream for RequestBodyReadTimeout { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + + // Once we have surfaced a stall error, treat the stream as terminated so + // we never poll the abandoned inner stream again. + if this.timed_out { + return Poll::Ready(None); + } + + match Pin::new(&mut this.inner).poll_next(cx) { + Poll::Ready(Some(Ok(chunk))) => { + this.timer = None; + this.received = this.received.saturating_add(chunk.len() as u64); + Poll::Ready(Some(Ok(chunk))) + } + Poll::Ready(other) => { + this.timer = None; + Poll::Ready(other) + } + Poll::Pending => { + if this.timeout.is_zero() { + return Poll::Pending; + } + + if this.timer.is_none() { + this.timer = Some(Box::pin(tokio::time::sleep(this.timeout))); + } + + if let Some(timer) = this.timer.as_mut() + && std::future::Future::poll(timer.as_mut(), cx).is_ready() + { + this.timer = None; + this.timed_out = true; + let expected_display = this.expected.map(|v| v.to_string()).unwrap_or_else(|| "unknown".to_string()); + warn!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_BODY_READ_STALLED, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + request_id = %this.request_id, + bucket = %this.bucket, + key = %this.key, + received_bytes = this.received, + expected_bytes = %expected_display, + timeout_secs = this.timeout.as_secs(), + state = "stall_timeout", + "PutObject request body read stalled; aborting. A proxy/CDN likely forwarded a partial body without closing the connection." + ); + return Poll::Ready(Some(Err(Box::new(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "request body read stalled: received {} of {} bytes, no data for {}s", + this.received, + expected_display, + this.timeout.as_secs() + ), + )) as StdError))); + } + + Poll::Pending + } + } + } + + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl ByteStream for RequestBodyReadTimeout { + fn remaining_length(&self) -> RemainingLength { + self.inner.remaining_length() + } +} + +/// Wrap an incoming request body with [`RequestBodyReadTimeout`] unless the +/// feature is disabled (`timeout == 0`), in which case the body is returned +/// untouched. `remaining_length` is preserved via [`StreamingBlob::new`]. +fn guard_put_object_body_read_timeout( + body: StreamingBlob, + bucket: &str, + key: &str, + request_id: &str, + expected: Option, + timeout: Duration, +) -> StreamingBlob { + if timeout.is_zero() { + return body; + } + + StreamingBlob::new(RequestBodyReadTimeout { + inner: body.into(), + timeout, + timer: None, + received: 0, + expected: expected.and_then(|v| u64::try_from(v).ok()), + bucket: bucket.to_string(), + key: key.to_string(), + request_id: request_id.to_string(), + timed_out: false, + }) +} + +struct PooledBufferReader { + buffer: PooledBuffer, + len: usize, + pos: usize, +} + +impl PooledBufferReader { + fn new(buffer: PooledBuffer, len: usize) -> Self { + Self { buffer, len, pos: 0 } + } +} + +impl AsyncRead for PooledBufferReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if self.pos >= self.len { + return Poll::Ready(Ok(())); + } + + let remaining = self.len - self.pos; + let to_read = remaining.min(buf.remaining()); + buf.put_slice(&self.buffer[self.pos..self.pos + to_read]); + self.pos += to_read; + + Poll::Ready(Ok(())) + } +} + +struct ChunkedBytesReader { + chunks: Vec, + chunk_index: usize, + chunk_offset: usize, +} + +impl ChunkedBytesReader { + fn new(chunks: Vec) -> Self { + Self { + chunks, + chunk_index: 0, + chunk_offset: 0, + } + } +} + +impl AsyncRead for ChunkedBytesReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + while self.chunk_index < self.chunks.len() { + let chunk = &self.chunks[self.chunk_index]; + if self.chunk_offset >= chunk.len() { + self.chunk_index += 1; + self.chunk_offset = 0; + continue; + } + + let remaining = &chunk[self.chunk_offset..]; + let to_read = remaining.len().min(buf.remaining()); + buf.put_slice(&remaining[..to_read]); + self.chunk_offset += to_read; + return Poll::Ready(Ok(())); + } + + Poll::Ready(Ok(())) + } +} + +/// Determine if zero-copy write should be used for this PutObject operation. +/// +/// Zero-copy is beneficial for large objects without encryption or compression. +/// +/// # Arguments +/// +/// * `size` - Object size in bytes +/// * `headers` - HTTP headers (to check for encryption/compression) +/// +/// # Returns +/// +/// `true` if zero-copy should be used, `false` otherwise +fn should_use_zero_copy(size: i64, headers: &HeaderMap) -> bool { + // Only use zero-copy for objects larger than 1MB + const ZERO_COPY_MIN_SIZE: i64 = 1024 * 1024; + + if size <= ZERO_COPY_MIN_SIZE { + return false; + } + + // Don't use zero-copy if encryption is requested + if headers.get(AMZ_SERVER_SIDE_ENCRYPTION).is_some() + || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM).is_some() + || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some() + { + return false; + } + + // Don't use zero-copy if compression is likely (compressible content types) + // The compression check happens later in the flow + if let Some(content_type) = headers.get(CONTENT_TYPE) + && let Ok(ct) = content_type.to_str() + { + // Skip zero-copy for easily compressible content types + // since compression will be applied + let compressible_types = [ + "text/plain", + "text/html", + "text/css", + "text/javascript", + "application/javascript", + "application/json", + "application/xml", + "text/xml", + ]; + for ct_type in compressible_types { + if ct.contains(ct_type) { + return false; + } + } + } + + true +} + +#[cfg(test)] +fn should_use_zero_copy_eager_put_path( + size: i64, + headers: &HeaderMap, + server_side_encryption_requested: bool, + should_compress: bool, + is_extract: bool, +) -> bool { + zero_copy_eager_put_path_status(size, headers, server_side_encryption_requested, should_compress, is_extract) + == PUT_EAGER_STATUS_ELIGIBLE +} + +fn zero_copy_eager_put_path_status( + size: i64, + headers: &HeaderMap, + server_side_encryption_requested: bool, + should_compress: bool, + is_extract: bool, +) -> &'static str { + zero_copy_eager_put_path_status_with_max_size( + size, + headers, + server_side_encryption_requested, + should_compress, + is_extract, + zero_copy_eager_put_max_size_bytes(), + ) +} + +fn zero_copy_eager_put_path_status_with_max_size( + size: i64, + headers: &HeaderMap, + server_side_encryption_requested: bool, + should_compress: bool, + is_extract: bool, + max_size: i64, +) -> &'static str { + if is_extract { + return PUT_EAGER_STATUS_EXTRACT; + } + if should_compress { + return PUT_EAGER_STATUS_COMPRESSED; + } + if server_side_encryption_requested { + return PUT_EAGER_STATUS_ENCRYPTED; + } + + if size <= 0 { + return PUT_EAGER_STATUS_INVALID_SIZE; + } + if size > max_size { + return PUT_EAGER_STATUS_ABOVE_EAGER_MAX; + } + + if !should_use_zero_copy(size, headers) { + return PUT_EAGER_STATUS_ZERO_COPY_INELIGIBLE; + } + + if request_uses_aws_chunked(headers) && decoded_content_length_from_headers(headers).ok().flatten().is_none() { + return PUT_EAGER_STATUS_AWS_CHUNKED_MISSING_DECODED_LENGTH; + } + + PUT_EAGER_STATUS_ELIGIBLE +} + +fn zero_copy_eager_put_max_size_bytes() -> i64 { + let configured = *CACHED_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES.get_or_init(|| { + rustfs_utils::get_env_usize(ENV_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES, DEFAULT_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES) + }); + i64::try_from(configured).unwrap_or(i64::MAX) +} + +fn should_use_small_eager_put_path( + size: i64, + headers: &HeaderMap, + server_side_encryption_requested: bool, + should_compress: bool, + is_extract: bool, +) -> bool { + const SMALL_EAGER_PUT_MAX_SIZE: i64 = 1024 * 1024; + + if is_extract || should_compress || server_side_encryption_requested { + return false; + } + + if size <= 0 || size > SMALL_EAGER_PUT_MAX_SIZE { + return false; + } + + if has_put_sse_request_headers(headers) { + return false; + } + + if request_uses_aws_chunked(headers) && decoded_content_length_from_headers(headers).ok().flatten().is_none() { + return false; + } + + true +} + +/// Objects at or below this size bypass BytesPool and use direct allocation. +/// This avoids Small-tier Mutex contention under high concurrency for tiny objects +/// where the allocation cost is negligible (≤4KiB memcpy). +const POOL_BYPASS_MAX_SIZE: usize = 4 * 1024; + +async fn read_small_put_body_into(body: &mut R, buf: &mut B, size: usize) -> S3Result<()> +where + R: AsyncRead + Unpin, + B: bytes::BufMut, +{ + let mut filled = 0; + + while filled < size { + let mut remaining = (&mut *buf).limit(size - filled); + let read = tokio::io::AsyncReadExt::read_buf(&mut *body, &mut remaining) + .await + .map_err(ApiError::from)?; + if read == 0 { + return Err(s3_error!(IncompleteBody)); + } + filled += read; + } + + let mut extra = [0u8; 1]; + let extra_read = tokio::io::AsyncReadExt::read(&mut *body, &mut extra) + .await + .map_err(ApiError::from)?; + if extra_read != 0 { + return Err(s3_error!(UnexpectedContent)); + } + + Ok(()) +} + +async fn read_small_put_body_exact_pooled(mut body: R, size: usize, pool: &BytesPool) -> S3Result +where + R: AsyncRead + Unpin, +{ + let mut buf = pool.acquire_buffer(size).await; + read_small_put_body_into(&mut body, &mut *buf, size).await?; + Ok(buf) +} + +/// Read small PUT body into a directly-allocated buffer, bypassing BytesPool. +/// Used for objects ≤4KiB where pool contention under high concurrency +/// outweighs the allocation cost. +async fn read_small_put_body_exact_direct(mut body: R, size: usize) -> S3Result>> +where + R: AsyncRead + Unpin, +{ + let mut buf = Vec::with_capacity(size); + read_small_put_body_into(&mut body, &mut buf, size).await?; + Ok(std::io::Cursor::new(buf)) +} + +async fn read_zero_copy_put_body_exact(mut body: S, size: usize) -> S3Result +where + S: futures::Stream> + Unpin, + E: Into, +{ + let mut chunks = Vec::new(); + let mut filled = 0usize; + + while filled < size { + let Some(chunk) = body.next().await else { + return Err(s3_error!(IncompleteBody)); + }; + let chunk = chunk.map_err(|err| ApiError::from(s3s_body_error_to_io(err.into())))?; + if chunk.is_empty() { + continue; + } + if filled.saturating_add(chunk.len()) > size { + return Err(s3_error!(UnexpectedContent)); + } + + rustfs_io_metrics::record_zero_copy_buffer_operation("put_chunk", chunk.len()); + filled += chunk.len(); + chunks.push(chunk); + } + + while let Some(chunk) = body.next().await { + let chunk = chunk.map_err(|err| ApiError::from(s3s_body_error_to_io(err.into())))?; + if !chunk.is_empty() { + return Err(s3_error!(UnexpectedContent)); + } + } + + Ok(ChunkedBytesReader::new(chunks)) +} + +#[derive(Default)] +pub(super) struct PutObjectChecksums { + pub(super) crc32: Option, + pub(super) crc32c: Option, + pub(super) sha1: Option, + pub(super) sha256: Option, + pub(super) crc64nvme: Option, +} + +struct PutObjectCommitResult { + obj_info: ObjectInfo, + put_versioned: bool, +} + +struct EagerPutCommitOwner { + task: Option>, + cancellation: tokio_util::sync::CancellationToken, + cancellation_grace: Duration, +} + +impl EagerPutCommitOwner { + fn new( + task: tokio::task::JoinHandle, + cancellation: tokio_util::sync::CancellationToken, + cancellation_grace: Duration, + ) -> Self { + Self { + task: Some(task), + cancellation, + cancellation_grace, + } + } + + async fn join(mut self) -> Result { + let result = self.task.as_mut().expect("eager PUT commit owner task must be present").await; + self.task = None; + result + } +} + +impl Drop for EagerPutCommitOwner { + fn drop(&mut self) { + let Some(mut task) = self.task.take() else { + return; + }; + if tokio::runtime::Handle::try_current().is_err() { + task.abort(); + return; + } + let cancellation = self.cancellation.clone(); + let cancellation_grace = self.cancellation_grace; + spawn_traced(async move { + if tokio::time::timeout(cancellation_grace, &mut task).await.is_err() { + cancellation.cancel(); + metrics::counter!("rustfs_put_commit_owner_deadline_total", "put_path" => "eager").increment(1); + warn!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_COMMIT_OWNER_DEADLINE, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + state = "cancellation_requested", + cancellation_grace_ms = cancellation_grace.as_millis() as u64, + "cancelled eager PutObject commit owner exceeded its grace period and requested storage cleanup" + ); + let _ = task.await; + } + }); + } +} + +#[cfg(test)] +type PutPostStoreTestHook = (String, Arc, Arc); + +#[cfg(test)] +static PUT_POST_STORE_TEST_HOOK: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +fn install_put_post_store_test_hook(bucket: String, entered: Arc, resume: Arc) { + *PUT_POST_STORE_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("PUT post-store test hook lock should not be poisoned") = Some((bucket, entered, resume)); +} + +#[cfg(test)] +async fn wait_for_put_post_store_test_hook(bucket: &str) { + let hook = { + let mut slot = PUT_POST_STORE_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("PUT post-store test hook lock should not be poisoned"); + if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { + slot.take() + } else { + None + } + }; + if let Some((_bucket, entered, resume)) = hook { + entered.wait().await; + resume.wait().await; + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn apply_put_request_metadata( + metadata: &mut HashMap, + headers: &HeaderMap, + object_name: &str, + cache_control: Option, + content_disposition: Option, + content_encoding: Option, + content_language: Option, + content_type: Option, + expires: Option, + website_redirect_location: Option, + tagging: Option, + storage_class: Option, +) -> S3Result<()> { + namespace_reserved_user_metadata(metadata); + apply_standard_object_metadata( + metadata, + cache_control.as_deref(), + content_disposition.as_deref(), + content_encoding.as_deref(), + content_language.as_deref(), + content_type.as_deref(), + expires.as_ref(), + website_redirect_location.as_deref(), + )?; + if let Some(tags) = tagging { + metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), tags); + } + if let Some(storage_class) = storage_class { + metadata.insert(AMZ_STORAGE_CLASS.to_string(), storage_class.as_str().to_string()); + } + + extract_metadata_from_mime_with_object_name(headers, metadata, true, Some(object_name)); + Ok(()) +} + +pub(super) fn apply_put_request_object_lock_opts( + bucket: &str, + object_lock_config_state: &metadata_sys::ObjectLockConfigState, + object_lock_legal_hold_status: Option, + object_lock_mode: Option, + object_lock_retain_until_date: Option, + opts: &mut ObjectOptions, +) -> S3Result<()> { + if let Some(eval_metadata) = build_put_like_object_lock_metadata( + bucket, + object_lock_config_state, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + )? { + opts.eval_metadata = Some(eval_metadata); + } + + Ok(()) +} + +pub(super) fn is_sse_kms_requested(input: &PutObjectInput, headers: &HeaderMap) -> bool { + input + .server_side_encryption + .as_ref() + .is_some_and(|sse| sse.as_str().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) + || input.ssekms_key_id.is_some() + || headers + .get(AMZ_SERVER_SIDE_ENCRYPTION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) + || headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID) +} + +fn is_post_object_sse_kms_requested(input: &PutObjectInput, headers: &HeaderMap) -> bool { + is_sse_kms_requested(input, headers) +} + +impl DefaultObjectUsecase { + fn should_use_large_put_concurrency_tuning(size: i64) -> bool { + size >= DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES + } + + fn put_object_execution_context(req: &S3Request) -> (EventName, QuotaOperation, &'static str) { + if req.extensions.get::().is_some() { + (put_event_name_for_post_object(true), QuotaOperation::PostObject, "POST") + } else { + (put_event_name_for_post_object(false), QuotaOperation::PutObject, "PUT") + } + } + + #[instrument(name = "execute_put_object", level = "info", skip(self, _fs, req))] + pub async fn execute_put_object(&self, _fs: &FS, req: S3Request) -> S3Result> { + self.execute_put_object_boxed(_fs, req).await + } + + fn execute_put_object_boxed<'a>( + &'a self, + _fs: &'a FS, + req: S3Request, + ) -> impl std::future::Future>> + Send + 'a { + Box::pin(self.execute_put_object_inner(_fs, req)) + } + + async fn execute_put_object_inner(&self, _fs: &FS, req: S3Request) -> S3Result> { + let start_time = std::time::Instant::now(); + let mut req = req; + + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let (event_name, quota_operation, request_method_name) = Self::put_object_execution_context(&req); + if req.extensions.get::().is_some() && is_post_object_sse_kms_requested(&req.input, &req.headers) + { + return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for POST object uploads")); + } + if let Some(ref storage_class) = req.input.storage_class + && !is_valid_storage_class(storage_class.as_str()) + { + return Err(s3_error!(InvalidStorageClass)); + } + // An authorized inbound replication PUT must store the replica verbatim. + // A snowball-extracted member object keeps `x-amz-meta-snowball-auto-extract` + // in its user metadata, and the replication client replays stored metadata + // as headers — re-dispatching that PUT into the extract path would try to + // untar the member's own bytes (failing replication for any non-archive + // member) instead of writing the replica. + let inbound_replication_put = replication_request_authorized(&req) + && get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true"); + if is_put_object_extract_requested(&req.headers) && !inbound_replication_put { + return Box::pin(self.execute_put_object_extract(req)).await; + } + // SSE-C ciphertext passthrough (authorized replication only): the body + // is already ciphertext and must be stored verbatim — no compression, + // no bucket-default encryption. + let ciphertext_passthrough = + inbound_replication_put && rustfs_utils::http::ssec_transport_to_stored_metadata(&req.headers).is_some(); + + let input = std::mem::take(&mut req.input); + + let PutObjectInput { + body, + bucket, + cache_control, + key, + content_length, + content_disposition, + content_encoding, + content_language, + content_type, + expires, + tagging, + metadata, + version_id, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key, + sse_customer_key_md5, + ssekms_key_id, + content_md5, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + storage_class, + website_redirect_location, + .. + } = input; + + // Merge SSE-C params from headers (fallback when S3 layer does not populate input) + let (h_algo, h_key, h_md5) = extract_ssec_params_from_headers(&req.headers)?; + let sse_customer_algorithm = sse_customer_algorithm.or(h_algo); + let sse_customer_key = sse_customer_key.or(h_key); + let sse_customer_key_md5 = sse_customer_key_md5.or(h_md5); + + // Merge server_side_encryption from headers (fallback when S3 layer does not populate input) + let server_side_encryption = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?); + + // Validate object key + validate_object_key(&key, request_method_name)?; + validate_table_catalog_object_mutation(&bucket, &key).await?; + + // Validate archive content encoding (reject when strict mode is enabled) + validate_archive_content_encoding( + &key, + req.headers.get("content-type").and_then(|value| value.to_str().ok()), + req.headers.get("content-encoding").and_then(|value| value.to_str().ok()), + )?; + + let Some(body) = body else { return Err(s3_error!(IncompleteBody)) }; + + // Guard against a proxy/CDN that forwards a partial body then goes silent + // without closing the connection: bound the inter-chunk wait so the read + // fails (with a diagnostic log) instead of hanging forever (issue #3076). + let body = { + let request_id = req + .extensions + .get::() + .map(|ctx| ctx.request_id.clone()) + .unwrap_or_default(); + guard_put_object_body_read_timeout(body, &bucket, &key, &request_id, content_length, put_object_body_read_timeout()) + }; + + // Resolve the authoritative decoded/plain object length (rejecting negative/unknown) before anything else consumes it. + let mut size = resolve_put_object_authoritative_size(&req.headers, content_length)?; + + // The app check preserves the existing S3 error contract; the storage + // commit path reserves the exact net logical growth under its locks. + let quota_check = self + .check_bucket_quota( + &bucket, + quota_operation, + u64::try_from(size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + ) + .await?; + let quota_enabled = quota_check.as_ref().is_some_and(|result| result.quota_limit.is_some()); + if quota_enabled && ciphertext_passthrough { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + "SSE-C ciphertext replication is unavailable for quota-enabled buckets".to_string(), + )); + } + + let put_stage_metrics_enabled = rustfs_io_metrics::put_stage_metrics_enabled(); + let ingress_stage_start = put_stage_metrics_enabled.then(Instant::now); + let should_compress = + is_disk_compressible(&req.headers, &key) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64 && !ciphertext_passthrough; + let server_side_encryption_requested = + server_side_encryption.is_some() || sse_customer_algorithm.is_some() || ssekms_key_id.is_some(); + + // Resolve the store through the request-bound server context + // (backlog#1052 S6), not the process-global handle, so an embedded + // second server never writes into the first server's store. + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + let bucket_validate_stage_start = put_stage_metrics_enabled.then(Instant::now); + validate_bucket_exists(&store, &bucket).await?; + rustfs_io_metrics::record_put_object_stage_duration_from("app_bucket_validate", bucket_validate_stage_start); + + let put_admission = match get_concurrency_manager() + .admit_put_object() + .await + .map_err(|_| s3_error!(InternalError, "foreground write admission closed"))? + { + PutObjectAdmission::Disabled => None, + PutObjectAdmission::Admitted(permit) => { + counter!("rustfs.put_object.foreground_admission.total", "result" => "admitted").increment(1); + Some(permit) + } + PutObjectAdmission::Rejected => { + counter!("rustfs.put_object.foreground_admission.total", "result" => "rejected").increment(1); + return Err(s3_error!( + SlowDown, + "foreground write concurrency limit reached, please reduce your request rate" + )); + } + }; + + let mut put_request_guard = PutObjectGuard::new(); + let concurrent_put_requests = PutObjectGuard::concurrent_requests(); + + // Apply adaptive buffer sizing based on file size for optimal streaming performance. + // Uses workload profile configuration (enabled by default) to select appropriate buffer size. + // Buffer sizes range from 32KB to 4MB depending on file size and configured workload profile. + // Concurrency-aware adjustment reduces buffer size under high PUT concurrency to lower memory pressure. + let base_buffer_size = get_buffer_size_opt_in(size); + let use_large_put_concurrency_tuning = Self::should_use_large_put_concurrency_tuning(size); + let buffer_size = if use_large_put_concurrency_tuning { + get_put_concurrency_aware_buffer_size(size, base_buffer_size) + } else { + base_buffer_size + }; + + // Detect zero-copy opportunity before encryption/compression decisions + // Zero-copy is beneficial for large unencrypted, uncompressed objects + let enable_zero_copy = should_use_zero_copy(size, &req.headers); + + if enable_zero_copy { + // Record zero-copy write attempt + counter!("rustfs_zero_copy_write_attempts_total").increment(1); + histogram!("rustfs_zero_copy_write_size_bytes").record(size as f64); + debug!("Zero-copy write enabled for {} byte object (bucket={}, key={})", size, bucket, key); + } + + let use_empty_or_small_eager_put_path = size == 0 + || should_use_small_eager_put_path(size, &req.headers, server_side_encryption_requested, should_compress, false); + let zero_copy_eager_put_path_status = + zero_copy_eager_put_path_status(size, &req.headers, server_side_encryption_requested, should_compress, false); + let use_zero_copy_eager_put_path = zero_copy_eager_put_path_status == PUT_EAGER_STATUS_ELIGIBLE; + if use_zero_copy_eager_put_path { + counter!(buffered_write::ATTEMPTS_TOTAL).increment(1); + histogram!(buffered_write::ATTEMPT_SIZE_BYTES).record(size as f64); + } + let put_path = if should_compress { + "stream_compressed" + } else if use_zero_copy_eager_put_path { + "zero_copy_eager" + } else if use_empty_or_small_eager_put_path { + "small_eager" + } else { + "streaming" + }; + rustfs_io_metrics::record_put_object_diagnostics( + put_path, + zero_copy_eager_put_path_status, + size, + buffer_size, + use_large_put_concurrency_tuning, + ); + + let sse_config_stage_start = put_stage_metrics_enabled.then(Instant::now); + let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); + rustfs_io_metrics::record_put_object_stage_duration_from("app_sse_config_lookup", sse_config_stage_start); + debug!( + target: "rustfs::app::object_usecase", + component = "app", + subsystem = "object", + event = "bucket_sse_config_lookup", + bucket = %bucket, + found = bucket_sse_config.is_some(), + "Bucket SSE configuration lookup completed" + ); + + let original_sse = server_side_encryption.clone(); + let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( + bucket_sse_config.as_ref().map(|(config, _timestamp)| config), + server_side_encryption, + ssekms_key_id, + false, + ); + debug!( + target: "rustfs::app::object_usecase", + component = "app", + subsystem = "object", + event = "effective_sse_resolved", + bucket = %bucket, + requested = ?original_sse, + effective = ?effective_sse, + "Resolved effective SSE configuration" + ); + + if ciphertext_passthrough { + // The replica keeps the source's SSE-C metadata; the bucket + // default must not claim managed encryption on it. + effective_sse = None; + effective_kms_key_id = None; + } + + // Validate SSE-C headers early: reject partial/invalid combinations per S3 spec + validate_sse_headers_for_write( + effective_sse.as_ref(), + effective_kms_key_id.as_ref(), + extract_ssekms_context_from_headers(&req.headers)?.as_ref(), + sse_customer_algorithm.as_ref(), + sse_customer_key.as_ref(), + sse_customer_key_md5.as_ref(), + true, // PutObject requires all three: algorithm, key, key_md5 + )?; + + let mut metadata = metadata.unwrap_or_default(); + let has_explicit_object_lock_retention = object_lock_mode.is_some() + || object_lock_retain_until_date.is_some() + || has_replication_retention_update(&req.headers, inbound_replication_put); + let object_lock_config_stage_start = put_stage_metrics_enabled.then(Instant::now); + let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?; + rustfs_io_metrics::record_put_object_stage_duration_from("app_object_lock_config_lookup", object_lock_config_stage_start); + apply_put_request_metadata( + &mut metadata, + &req.headers, + &key, + cache_control, + content_disposition, + content_encoding, + content_language, + content_type, + expires, + website_redirect_location, + tagging, + storage_class.clone(), + )?; + apply_bucket_default_lock_retention( + &bucket, + &object_lock_config_state, + &mut metadata, + has_explicit_object_lock_retention, + )?; + + let put_opts_stage_start = put_stage_metrics_enabled.then(Instant::now); + let mut opts: ObjectOptions = put_opts_with_replication_authorization( + &bucket, + &key, + version_id.clone(), + &req.headers, + metadata.clone(), + replication_request_authorized(&req), + ) + .await + .map_err(ApiError::from)?; + if let Some(quota_check) = quota_check.as_ref() { + apply_quota_admission(&mut opts, quota_check)?; + } + rustfs_io_metrics::record_put_object_stage_duration_from("app_put_opts_build", put_opts_stage_start); + apply_bucket_generation_guard(&req, &bucket, &mut opts)?; + apply_put_request_object_lock_opts( + &bucket, + &object_lock_config_state, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + &mut opts, + )?; + let eager_put_commit_cancellation = + (use_zero_copy_eager_put_path || use_empty_or_small_eager_put_path).then(tokio_util::sync::CancellationToken::new); + opts.put_object_cancellation = eager_put_commit_cancellation.clone(); + + // rustfs/backlog#1009: the pre-PUT lookup has exactly two consumers — + // the existing-object WORM validation and usage accounting's + // previous_current_size. When the bucket has no object locking (WORM is + // a provable no-op; the gate fails closed on metadata errors) and the + // PUT targets the latest version (no explicit version_id from internal + // replication), the lookup is skipped and accounting is backfilled from + // the dst xl.meta that rename_data already reads, saving a full-disk + // metadata fanout per PUT. + let prelookup_required = version_id.is_some() || object_lock_checks_required_for_state(&object_lock_config_state); + // Outer None = prelookup skipped (accounting comes from the commit + // backfill); Some(inner) = the previous current size as observed by the + // lookup, with the pre-#1009 semantics kept bit-for-bit. + let prelookup_stage_start = (prelookup_required && put_stage_metrics_enabled).then(Instant::now); + let prelookup_previous_current_size: Option> = if prelookup_required { + let current_opts: ObjectOptions = internal_object_info_lookup_opts( + get_opts(&bucket, &key, version_id.clone(), None, &req.headers) + .await + .map_err(ApiError::from)?, + ); + let previous_current_info = { + crate::hp_guard!("S3::put_object_prelookup"); + store.get_object_info(&bucket, &key, ¤t_opts).await + }; + Some(match previous_current_info { + Ok(existing_obj_info) => { + validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &opts)?; + Some(if quota_enabled { + quota_object_size(&existing_obj_info).map_err(ApiError::from)? + } else { + existing_obj_info.size.max(0) as u64 + }) + } + Err(err) => { + if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { + return Err(ApiError::from(err).into()); + } + None + } + }) + } else { + None + }; + rustfs_io_metrics::record_put_object_stage_duration_from("app_prelookup", prelookup_stage_start); + + let actual_size = size; + if !ciphertext_passthrough && let Some(quota_check) = quota_check.as_ref() { + ensure_object_size_within_quota( + quota_check, + u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + )?; + } + + let mut md5hex = if let Some(base64_md5) = content_md5 { + let md5 = base64_simd::STANDARD + .decode_to_vec(base64_md5.as_bytes()) + .map_err(|e| ApiError::from(StorageError::other(format!("Invalid content MD5: {e}"))))?; + Some(hex_simd::encode_to_string(&md5, hex_simd::AsciiCase::Lower)) + } else { + None + }; + + let mut sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); + + let mut write_plan = WritePlan::new(); + // Additional-checksum (XXHash3/64/128, SHA-512) values to echo on the PutObject + // response (#1256); captured at want_checksum set points before opts is moved. + let mut put_extra_checksum_headers: Vec<(&'static str, String)> = Vec::new(); + let mut reader = if should_compress { + let body = tokio::io::BufReader::with_capacity( + buffer_size, + StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), + ); + let algorithm = CompressionAlgorithm::default(); + insert_str(&mut metadata, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); + insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, size.to_string()); + + let mut hrd = + HashReader::from_stream(body, size, size, md5hex.take(), sha256hex.take(), false).map_err(ApiError::from)?; + + if let Err(err) = hrd.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { + return Err(ApiError::from(err).into()); + } + + opts.want_checksum = hrd.checksum(); + put_extra_checksum_headers = additional_checksum_echo_pairs(&opts.want_checksum); + insert_str(&mut opts.user_defined, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); + insert_str(&mut opts.user_defined, SUFFIX_ACTUAL_SIZE, size.to_string()); + + size = HashReader::SIZE_PRESERVE_LAYER; + write_plan = write_plan.with_compression(algorithm); + hrd + } else { + if use_zero_copy_eager_put_path { + let zero_copy_start = std::time::Instant::now(); + let eager_body = read_zero_copy_put_body_exact(body, actual_size as usize).await?; + rustfs_io_metrics::record_zero_copy_write(actual_size as usize, zero_copy_start.elapsed().as_secs_f64() * 1000.0); + HashReader::from_stream(eager_body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? + } else if use_empty_or_small_eager_put_path { + if (actual_size as usize) <= POOL_BYPASS_MAX_SIZE { + // Bypass BytesPool for very small objects to avoid Small-tier + // Mutex contention under high concurrency. Direct allocation + // for ≤4KiB is negligible cost. + let eager_body = read_small_put_body_exact_direct( + StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), + actual_size as usize, + ) + .await?; + HashReader::from_stream(eager_body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? + } else { + let pool = get_concurrency_manager().bytes_pool(); + let eager_body = read_small_put_body_exact_pooled( + StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), + actual_size as usize, + pool.as_ref(), + ) + .await?; + let eager_reader = PooledBufferReader::new(eager_body, actual_size as usize); + HashReader::from_stream(eager_reader, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? + } + } else { + let body = tokio::io::BufReader::with_capacity( + buffer_size, + StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), + ); + HashReader::from_stream(body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? + } + }; + + if size >= 0 { + if let Err(err) = reader.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { + return Err(ApiError::from(err).into()); + } + + opts.want_checksum = reader.checksum(); + put_extra_checksum_headers = additional_checksum_echo_pairs(&opts.want_checksum); + } + rustfs_io_metrics::record_put_object_path(put_path); + rustfs_io_metrics::record_put_object_stage_duration_from("ingress_prepare", ingress_stage_start); + + let mut helper = OperationHelper::new(&req, event_name, S3Operation::PutObject); + let ssekms_context = extract_ssekms_context_from_headers(&req.headers)?; + + // Apply encryption using unified SSE API. + let encryption_stage_start = put_stage_metrics_enabled.then(Instant::now); + let write_principal = SseKmsPrincipal::from_request(&req); + let encryption_request = EncryptionRequest { + bucket: &bucket, + key: &key, + server_side_encryption: effective_sse.clone(), + ssekms_key_id: effective_kms_key_id.clone(), + ssekms_context, + sse_customer_algorithm: sse_customer_algorithm.clone(), + sse_customer_key, + sse_customer_key_md5: sse_customer_key_md5.clone(), + content_size: actual_size, + principal: write_principal.as_ref(), + }; + + // SSE-C ciphertext passthrough must skip sse_encryption entirely: an + // explicit guard is required because prepare_sse_configuration inside + // it falls back to the bucket default encryption config and would + // double-encrypt the already-encrypted body. + let encryption_material = if opts.preserve_ciphertext { + None + } else { + match sse_encryption(encryption_request).await { + Ok(material) => material, + Err(err) => { + let result = Err(err.into()); + let _ = helper.complete(&result); + return result; + } + } + }; + + if let Some(material) = encryption_material { + effective_sse = Some(material.server_side_encryption.clone()); + effective_kms_key_id = material.kms_key_id.clone(); + + write_plan = write_plan.with_encryption(material.write_encryption(None)); + + let encryption_metadata = encryption_material_to_metadata(&material)?; + metadata.extend(encryption_metadata.clone()); + opts.user_defined.extend(encryption_metadata); + } + + reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?; + rustfs_io_metrics::record_put_object_stage_duration_from("app_encryption_prepare", encryption_stage_start); + + let reader = PutObjReader::new(reader); + + let mt2 = metadata.clone(); + opts.user_defined.extend(metadata); + let request_context = req.extensions.get::().cloned(); + let request_id = request_context + .as_ref() + .map(|ctx| ctx.request_id.clone()) + .unwrap_or_else(|| request_context::RequestContext::fallback().request_id); + + // Compute the replication decision exactly once per PUT. The same + // immutable `dsc` drives both the pending metadata written below and the + // post-commit schedule (see the reuse site further down), so a + // replication-config hot update can no longer split the two phases + // (https://github.com/rustfs/backlog/issues/1320). + let replication_decision_stage_start = put_stage_metrics_enabled.then(Instant::now); + let dsc = + must_replicate_object(&bucket, &key, &mt2, "".to_string(), opts.delete_marker_replication_status(), opts.clone()) + .await; + rustfs_io_metrics::record_put_object_stage_duration_from("app_replication_decision", replication_decision_stage_start); + + if dsc.replicate_any() { + insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); + insert_str( + &mut opts.user_defined, + SUFFIX_REPLICATION_STATUS, + dsc.pending_status().unwrap_or_default(), + ); + } + + let cache_adapter = self.object_data_cache(); + let cache_invalidate_before_stage_start = put_stage_metrics_enabled.then(Instant::now); + let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; + rustfs_io_metrics::record_put_object_stage_duration_from( + "app_cache_invalidate_before", + cache_invalidate_before_stage_start, + ); + + let store_put_watchdog = tokio_util::sync::CancellationToken::new(); + spawn_traced({ + let store_put_watchdog = store_put_watchdog.clone(); + let request_id = request_id.clone(); + let bucket = bucket.clone(); + let key = key.clone(); + let put_path = put_path.to_string(); + async move { + tokio::select! { + _ = store_put_watchdog.cancelled() => {} + _ = tokio::time::sleep(PUT_OBJECT_STORE_WARN_THRESHOLD) => { + warn!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_STORE_INFLIGHT_SLOW, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + request_id = %request_id, + bucket = %bucket, + key = %key, + put_path = %put_path, + object_size = actual_size, + threshold_ms = PUT_OBJECT_STORE_WARN_THRESHOLD.as_millis() as u64, + state = "store_put_pending", + "PutObject store write remains in flight" + ); + } + } + } + }); + + let object_traffic_health = if use_zero_copy_eager_put_path || use_empty_or_small_eager_put_path { + self.object_traffic_health() + } else { + None + }; + let put_commit = spawn_traced_join({ + let store = Arc::clone(&store); + let bucket = bucket.clone(); + let key = key.clone(); + let opts = opts.clone(); + let cache_adapter = cache_adapter.clone(); + let request_id = request_id.clone(); + let put_path = put_path.to_string(); + let put_admission = put_admission; + async move { + let _put_admission = put_admission; + let object_traffic_progress = object_traffic_health + .as_deref() + .and_then(ObjectTrafficHealth::track_write_storage); + let mut reader = reader; + let store_put_stage_start = put_stage_metrics_enabled.then(Instant::now); + let (obj_info, backfilled_old_current_size) = match store + .put_object_with_old_current_size(&bucket, &key, &mut reader, &opts) + .await + .map_err(ApiError::from) + { + Ok(obj_info) => { + store_put_watchdog.cancel(); + debug!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_STORE_RETURNED, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + request_id = %request_id, + bucket = %bucket, + key = %key, + put_path = %put_path, + object_size = actual_size, + duration_ms = start_time.elapsed().as_millis() as u64, + result = "success", + "PutObject store write returned" + ); + obj_info + } + Err(err) => { + store_put_watchdog.cancel(); + rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start); + warn!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_STORE_RETURNED, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + request_id = %request_id, + bucket = %bucket, + key = %key, + put_path = %put_path, + object_size = actual_size, + duration_ms = start_time.elapsed().as_millis() as u64, + result = "error", + error = %err, + "PutObject store write returned" + ); + return Err(err.into()); + } + }; + rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start); + drop(_put_admission); + drop(object_traffic_progress); + #[cfg(test)] + wait_for_put_post_store_test_hook(&bucket).await; + + let post_store_stage_start = put_stage_metrics_enabled.then(Instant::now); + maybe_enqueue_transition_immediate(&obj_info, LcEventSrc::S3PutObject).await; + let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &key).await; + + let put_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await; + // Fast in-memory update for immediate quota and admin usage consistency. + // The previous current size comes from the prelookup when it ran, + // otherwise from the rename_data backfill (rustfs/backlog#1009); the + // backfill reproduces the lookup's observation bit for bit (latest + // version's ObjectInfo.size — 0 for a delete-marker latest — or + // not-found → None). + let committed_size = quota_accounting_object_size(&obj_info, quota_enabled)?; + match prelookup_previous_current_size.or_else(|| previous_current_size_from_backfill(backfilled_old_current_size)) + { + Some(previous_current_size) => { + if put_versioned { + record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; + } else { + record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; + } + } + None => { + // Neither source could determine the previous state (peers + // predating the backfill field during a rolling upgrade, or + // sub-quorum metadata divergence). Record the components that + // are correct regardless; the next authoritative scanner + // refresh replaces the in-memory numbers. + debug!( + target: "rustfs::app::object_usecase", + bucket = %bucket, + key = %key, + put_versioned, + "put_object old-size backfill unknown; recording degraded usage delta" + ); + record_bucket_object_write_unknown_previous_memory(&bucket, committed_size, put_versioned).await; + } + } + + if dsc.replicate_any() { + schedule_object_replication(obj_info.clone(), store, dsc).await; + } + + rustfs_scanner::record_dirty_usage_bucket(&bucket); + rustfs_io_metrics::record_put_object_stage_duration_from("app_post_store_bookkeeping", post_store_stage_start); + + let capacity_update_stage_start = put_stage_metrics_enabled.then(Instant::now); + let manager = get_capacity_manager(); + manager.record_write_operation().await; + rustfs_io_metrics::record_put_object_stage_duration_from("app_capacity_update", capacity_update_stage_start); + + Ok::<_, S3Error>(PutObjectCommitResult { obj_info, put_versioned }) + } + }); + let put_commit_result = if let Some(cancellation) = eager_put_commit_cancellation { + EagerPutCommitOwner::new(put_commit, cancellation, EAGER_PUT_COMMIT_CANCELLATION_GRACE) + .join() + .await + } else { + put_commit.await + }; + let PutObjectCommitResult { obj_info, put_versioned } = match put_commit_result { + Ok(Ok(result)) => result, + Ok(Err(err)) => { + let result: S3Result> = Err(err); + put_request_guard.finish_err(); + let _ = helper.complete(&result); + return result; + } + Err(err) => { + let result: S3Result> = Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!("put object commit owner task failed: {err}"), + )); + put_request_guard.finish_err(); + let _ = helper.complete(&result); + return result; + } + }; + + let raw_version = obj_info.version_id.map(|v| v.to_string()); + + helper = helper.object(obj_info.clone()); + if let Some(version_id) = &raw_version { + helper = helper.version_id(version_id.clone()); + } + + let put_version = if put_versioned { raw_version } else { None }; + + let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag)); + + let expiration = resolve_put_object_expiration(&bucket, &obj_info).await; + + let mut checksums = PutObjectChecksums { + crc32: input.checksum_crc32, + crc32c: input.checksum_crc32c, + sha1: input.checksum_sha1, + sha256: input.checksum_sha256, + crc64nvme: input.checksum_crc64nvme, + }; + apply_trailing_checksums( + input.checksum_algorithm.as_ref().map(|a| a.as_str()), + &req.trailing_headers, + &mut checksums, + ); + + let output = PutObjectOutput { + e_tag, + server_side_encryption: effective_sse, + sse_customer_algorithm: sse_customer_algorithm.clone(), + sse_customer_key_md5: sse_customer_key_md5.clone(), + ssekms_key_id: effective_kms_key_id, + expiration, + checksum_crc32: checksums.crc32, + checksum_crc32c: checksums.crc32c, + checksum_sha1: checksums.sha1, + checksum_sha256: checksums.sha256, + checksum_crc64nvme: checksums.crc64nvme, + version_id: put_version, + ..Default::default() + }; + + // For browser-based POST uploads (multipart/form-data), response status/body handling + // is decided by s3s PostObject serializer (success_action_status / redirect semantics). + + let mut response = S3Response::new(output); + // Echo XXHash3/64/128 / SHA-512 checksums that s3s PutObjectOutput has no typed + // field for (#1256). + inject_additional_checksum_headers(&mut response.headers, &put_extra_checksum_headers); + let result = Ok(response); + let _ = helper.complete(&result); + + // Record PutObject metrics via zero-copy-metrics + { + let duration_ms = start_time.elapsed().as_millis() as f64; + rustfs_io_metrics::record_put_object( + duration_ms, + size, + enable_zero_copy, // Track if zero-copy was enabled + ); + } + + debug!( + target: "rustfs::app::object_usecase", + component = "app", + subsystem = "object", + bucket = %bucket, + key = %key, + concurrent_put_requests, + buffer_size, + "PutObject request completed" + ); + + put_request_guard.finish_ok(); + + result + } +} + +/// rustfs/backlog#1009: map the rename_data old-size backfill onto the +/// `previous_current_size` value the usage-accounting helpers expect. Outer +/// `None` = unknown (no quorum agreement, or a peer predates the field) — the +/// caller must fall back to the degraded accounting path. +pub(super) fn previous_current_size_from_backfill(backfill: Option) -> Option> { + backfill.map(|observation| match observation { + OldCurrentSize::Present(size) => Some(size.max(0) as u64), + OldCurrentSize::Absent => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderName, HeaderValue, Method}; + use s3s::dto::{DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRule}; + use std::pin::Pin; + use std::sync::Arc; + use std::task::{Context, Poll}; + use tokio::io::{AsyncRead, ReadBuf}; + + #[tokio::test] + async fn cancelled_eager_put_commit_owner_reaps_stalled_storage_task() { + let health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); + let task_health = Arc::clone(&health); + let cancellation = tokio_util::sync::CancellationToken::new(); + let task_cancellation = cancellation.clone(); + let task = spawn_traced_join(async move { + let _progress = task_health.track_write_storage().expect("write tracking must be enabled"); + task_cancellation.cancelled().await; + }); + let owner = EagerPutCommitOwner::new(task, cancellation, Duration::from_millis(10)); + let request = spawn_traced_join(owner.join()); + + tokio::time::timeout(Duration::from_secs(2), async { + while !health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("stalled owner must publish write-storage progress"); + + request.abort(); + let _ = request.await; + tokio::time::timeout(Duration::from_secs(2), async { + while health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("cancelled owner must abort and reap the stalled storage task"); + } + + #[test] + fn put_request_user_metadata_cannot_suppress_bucket_default_retention() { + let mut metadata = + HashMap::from([(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::GOVERNANCE.to_string())]); + apply_put_request_metadata( + &mut metadata, + &HeaderMap::new(), + "object", + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(ObjectLockRule { + default_retention: Some(DefaultRetention { + mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), + days: Some(1), + years: None, + }), + }), + }, + updated_at: OffsetDateTime::now_utc(), + }; + apply_bucket_default_lock_retention("bucket", &state, &mut metadata, false).unwrap(); + + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("COMPLIANCE")); + assert!(metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); + assert_eq!(metadata.get("x-amz-meta-x-amz-object-lock-mode").map(String::as_str), Some("GOVERNANCE")); + + let mut replication_headers = HeaderMap::new(); + insert_header(&mut replication_headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true"); + insert_header( + &mut replication_headers, + rustfs_utils::http::SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP, + "2026-01-01T00:00:00Z", + ); + let mut replica_metadata = HashMap::new(); + let explicit_clear = has_replication_retention_update(&replication_headers, true); + apply_bucket_default_lock_retention("bucket", &state, &mut replica_metadata, explicit_clear).unwrap(); + assert!(!replica_metadata.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); + assert!(!replica_metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); + } + + /// rustfs/backlog#1009: the backfill→accounting mapping must mirror the + /// prelookup exactly — a live latest version maps to `Some(size)` (clamped + /// at 0 like the prelookup's `.max(0)`), absent/delete-marker maps to + /// `None`, and an unknown backfill maps to outer `None` so the caller + /// takes the degraded path instead of fabricating "new object". + #[test] + fn previous_current_size_from_backfill_mirrors_prelookup_semantics() { + assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(42))), Some(Some(42))); + assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(-7))), Some(Some(0))); + assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Absent)), Some(None)); + assert_eq!(previous_current_size_from_backfill(None), None); + } + + #[test] + fn should_use_zero_copy_rejects_boundary_at_1mb() { + let headers = HeaderMap::new(); + + assert!(!should_use_zero_copy(1024 * 1024, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_small_objects() { + let headers = HeaderMap::new(); + + assert!(!should_use_zero_copy(1024 * 1024 - 1, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_one_megabyte() { + let headers = HeaderMap::new(); + + assert!(!should_use_zero_copy(1024 * 1024, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_encrypted_requests() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SERVER_SIDE_ENCRYPTION, HeaderValue::from_static("AES256")); + + assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn object_progress_tracks_real_get_and_small_put_lock_waits() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); + let context = temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE, Some("false"))], async { + crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await + }) + .await; + let store = context.object_store(); + let bucket = format!("object-progress-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("object progress bucket must be created"); + put_real_cold_fill_object(&store, &bucket, object, b"initial").await; + + let metadata_entered = Arc::new(tokio::sync::Barrier::new(2)); + let metadata_resume = Arc::new(tokio::sync::Barrier::new(2)); + crate::storage::options::install_versioning_config_test_hook( + bucket.clone(), + Arc::clone(&metadata_entered), + Arc::clone(&metadata_resume), + ); + let metadata_input = GetObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .build() + .expect("metadata GET input must build"); + let metadata_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let metadata_get = tokio::spawn(async move { + metadata_usecase + .execute_get_object(build_request(metadata_input, Method::GET)) + .await + }); + tokio::time::timeout(Duration::from_secs(2), metadata_entered.wait()) + .await + .expect("GET must enter the bucket metadata stage"); + assert!(object_traffic_health.snapshot().read_stalled); + assert!(!metadata_get.is_finished(), "GET must still be waiting in bucket metadata"); + metadata_resume.wait().await; + let metadata_response = tokio::time::timeout(Duration::from_secs(10), metadata_get) + .await + .expect("metadata GET must finish after release") + .expect("metadata GET task must join") + .expect("metadata GET must succeed after release"); + assert!(!object_traffic_health.snapshot().read_stalled); + drop(metadata_response); + + let read_lock = store + .new_ns_lock(&bucket, object) + .await + .expect("read test namespace lock must be created") + .get_write_lock(Duration::from_secs(5)) + .await + .expect("read test namespace lock must be held"); + let get_input = GetObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .build() + .expect("GET input must build"); + let get_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let get = tokio::spawn(async move { get_usecase.execute_get_object(build_request(get_input, Method::GET)).await }); + tokio::time::timeout(Duration::from_secs(2), async { + while !object_traffic_health.read_storage_stalled_for_test() { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocked GET must publish a storage stall"); + assert!(!get.is_finished(), "GET must still be waiting for the held namespace lock"); + drop(read_lock); + let get_response = tokio::time::timeout(Duration::from_secs(10), get) + .await + .expect("GET must finish after releasing the lock") + .expect("GET task must join") + .expect("GET must succeed after releasing the lock"); + assert!(!object_traffic_health.snapshot().read_stalled); + drop(get_response); + + let write_lock = store + .new_ns_lock(&bucket, object) + .await + .expect("write test namespace lock must be created") + .get_write_lock(Duration::from_secs(5)) + .await + .expect("write test namespace lock must be held"); + let post_store_entered = Arc::new(tokio::sync::Barrier::new(2)); + let post_store_resume = Arc::new(tokio::sync::Barrier::new(2)); + install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume)); + let payload = Bytes::from_static(b"replacement"); + let put_input = PutObjectInput::builder() + .bucket(bucket) + .key(object.to_string()) + .body(Some(StreamingBlob::from(s3s::Body::from(payload.clone())))) + .content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64"))) + .build() + .expect("PUT input must build"); + let put_usecase = DefaultObjectUsecase::with_context(Some(context)); + let put = tokio::spawn(async move { + put_usecase + .execute_put_object(&FS::new(), build_request(put_input, Method::PUT)) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + while !object_traffic_health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocked small PUT must publish a storage stall"); + assert!(!put.is_finished(), "PUT must still be waiting for the held namespace lock"); + drop(write_lock); + tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait()) + .await + .expect("PUT must reach the first post-store hook"); + assert!(!object_traffic_health.snapshot().write_stalled); + assert!(!put.is_finished(), "PUT must remain blocked after the store guard has ended"); + post_store_resume.wait().await; + tokio::time::timeout(Duration::from_secs(10), put) + .await + .expect("PUT must finish after releasing the lock") + .expect("PUT task must join") + .expect("PUT must succeed after releasing the lock"); + let recovered = object_traffic_health.snapshot(); + assert!(!recovered.read_stalled); + assert!(!recovered.write_stalled); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn cancelled_put_request_completes_post_commit_publication() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (store, context) = real_cold_fill_test_context().await; + let bucket = format!("put-owner-tail-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("PUT owner-tail bucket must be created"); + + let old_body = Bytes::from_static(b"old body that must be invalidated"); + let old_info = put_real_cold_fill_object(&store, &bucket, object, &old_body).await; + let adapter = context.object_data_cache(); + let old_plan = real_cold_fill_plan(&adapter, &bucket, object, &old_info); + + let post_store_entered = Arc::new(tokio::sync::Barrier::new(2)); + let post_store_resume = Arc::new(tokio::sync::Barrier::new(2)); + install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume)); + + let payload = Bytes::from_static(b"published despite caller cancellation"); + let put_input = PutObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .body(Some(StreamingBlob::from(s3s::Body::from(payload.clone())))) + .content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64"))) + .build() + .expect("PUT input must build"); + let put_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let put = tokio::spawn(async move { + put_usecase + .execute_put_object(&FS::new(), build_request(put_input, Method::PUT)) + .await + }); + + tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait()) + .await + .expect("PUT must reach the post-store owner-tail hook"); + assert_eq!( + adapter.fill_body(&old_plan, old_body.clone()).await, + rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted, + "test must republish the old body while the owner tail is paused" + ); + put.abort(); + post_store_resume.wait().await; + let _ = put.await.expect_err("outer request task must be cancelled"); + + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if matches!( + adapter.lookup_body(&old_plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Miss + ) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("post-commit owner tail must invalidate stale body cache after caller cancellation"); + + let recovered = store + .get_object_info(&bucket, object, &ObjectOptions::default()) + .await + .expect("cancelled request's owned commit must still publish the object"); + assert_eq!(recovered.size, i64::try_from(payload.len()).expect("test payload length must fit i64")); + } + + #[tokio::test] + async fn object_progress_tracks_zero_byte_and_zero_copy_put_lock_waits() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); + let context = + crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await; + let store = context.object_store(); + let bucket = format!("progress-buffered-{}", Uuid::new_v4()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("buffered PUT progress bucket must be created"); + + let extra_body_object = "zero-byte-extra.bin"; + let extra_body_input = PutObjectInput::builder() + .bucket(bucket.clone()) + .key(extra_body_object.to_string()) + .body(Some(StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"x"))))) + .content_length(Some(88)) + .build() + .expect("zero-byte extra-body PUT input must build"); + let extra_body_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let mut extra_body_request = build_request(extra_body_input, Method::PUT); + extra_body_request.headers = streaming_headers(Some("0")); + let extra_body_err = extra_body_usecase + .execute_put_object(&FS::new(), extra_body_request) + .await + .expect_err("decoded zero-byte PUT with body data must fail"); + assert_eq!(extra_body_err.code(), &S3ErrorCode::UnexpectedContent); + assert!(!object_traffic_health.snapshot().write_stalled); + let lookup_err = store + .get_object_info(&bucket, extra_body_object, &ObjectOptions::default()) + .await + .expect_err("rejected zero-byte PUT must not create an object"); + assert!(is_err_object_not_found(&lookup_err)); + + let zero_object = "zero-byte.bin"; + let zero_write_lock = store + .new_ns_lock(&bucket, zero_object) + .await + .expect("zero-byte PUT namespace lock must be created") + .get_write_lock(Duration::from_secs(30)) + .await + .expect("zero-byte PUT namespace lock must be held"); + let (body_polled_tx, body_polled_rx) = tokio::sync::oneshot::channel(); + let (body_release_tx, body_release_rx) = tokio::sync::oneshot::channel(); + let pending_zero_body = StreamingBlob::wrap(futures::stream::once(async move { + body_polled_tx.send(()).expect("zero-byte body poll signal must be received"); + body_release_rx.await.expect("zero-byte body EOF must be released"); + Ok::(Bytes::new()) + })); + let zero_input = PutObjectInput::builder() + .bucket(bucket.clone()) + .key(zero_object.to_string()) + .body(Some(pending_zero_body)) + .content_length(Some(87)) + .build() + .expect("zero-byte PUT input must build"); + let zero_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let mut zero_request = build_request(zero_input, Method::PUT); + zero_request.headers = streaming_headers(Some("0")); + let zero_put = tokio::spawn(async move { zero_usecase.execute_put_object(&FS::new(), zero_request).await }); + + tokio::time::timeout(Duration::from_secs(30), body_polled_rx) + .await + .expect("zero-byte PUT body must be polled for EOF") + .expect("zero-byte PUT body poll signal must be sent"); + assert!(!object_traffic_health.snapshot().write_stalled); + assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for request EOF"); + + body_release_tx.send(()).expect("zero-byte PUT body EOF must be released"); + tokio::time::timeout(Duration::from_secs(30), async { + while !object_traffic_health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("fully received zero-byte PUT must publish a storage stall"); + assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for the held namespace lock"); + + drop(zero_write_lock); + tokio::time::timeout(Duration::from_secs(30), zero_put) + .await + .expect("zero-byte PUT must finish after releasing the lock") + .expect("zero-byte PUT task must join") + .expect("zero-byte PUT must succeed after releasing the lock"); + assert!(!object_traffic_health.snapshot().write_stalled); + + let zero_copy_object = "zero-copy-eager.jpg"; + let zero_copy_payload = Bytes::from(vec![b'z'; 1024 * 1024 + 1]); + let zero_copy_size = i64::try_from(zero_copy_payload.len()).expect("zero-copy payload length must fit i64"); + let zero_copy_headers = HeaderMap::new(); + assert!(!is_disk_compressible(&zero_copy_headers, zero_copy_object)); + assert_eq!( + zero_copy_eager_put_path_status(zero_copy_size, &zero_copy_headers, false, false, false), + PUT_EAGER_STATUS_ELIGIBLE, + "test payload must exercise the production zero-copy eager path", + ); + let zero_copy_write_lock = store + .new_ns_lock(&bucket, zero_copy_object) + .await + .expect("zero-copy PUT namespace lock must be created") + .get_write_lock(Duration::from_secs(30)) + .await + .expect("zero-copy PUT namespace lock must be held"); + let zero_copy_input = PutObjectInput::builder() + .bucket(bucket) + .key(zero_copy_object.to_string()) + .body(Some(StreamingBlob::from(s3s::Body::from(zero_copy_payload)))) + .content_length(Some(zero_copy_size)) + .build() + .expect("zero-copy PUT input must build"); + let zero_copy_usecase = DefaultObjectUsecase::with_context(Some(context)); + let zero_copy_put = tokio::spawn(async move { + zero_copy_usecase + .execute_put_object(&FS::new(), build_request(zero_copy_input, Method::PUT)) + .await + }); + + tokio::time::timeout(Duration::from_secs(30), async { + while !object_traffic_health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocked zero-copy eager PUT must publish a storage stall"); + assert!( + !zero_copy_put.is_finished(), + "zero-copy PUT must still be waiting for the held namespace lock" + ); + + drop(zero_copy_write_lock); + tokio::time::timeout(Duration::from_secs(30), zero_copy_put) + .await + .expect("zero-copy PUT must finish after releasing the lock") + .expect("zero-copy PUT task must join") + .expect("zero-copy PUT must succeed after releasing the lock"); + assert!(!object_traffic_health.snapshot().write_stalled); + } + + #[tokio::test] + async fn put_object_body_read_timeout_guard_aborts_on_stall() { + // Inner stream never yields and never reports EOF (a proxy that forwarded + // a partial body then went silent while holding the connection open). + let inner = StreamingBlob::wrap(futures::stream::pending::>()); + let mut guarded = guard_put_object_body_read_timeout( + inner, + "test-bucket", + "stalled-object", + "req-1", + Some(1024), + Duration::from_millis(1), + ); + + let err = guarded + .next() + .await + .expect("guard should yield a stall error") + .expect_err("stalled body should return an error"); + let io_err = err + .downcast_ref::() + .expect("stall error should wrap an io::Error"); + assert_eq!(io_err.kind(), std::io::ErrorKind::TimedOut); + + // After a stall the guard terminates the stream instead of re-polling the + // abandoned inner stream. + assert!(guarded.next().await.is_none()); + } + + #[tokio::test] + async fn put_object_body_read_timeout_guard_preserves_length_and_passes_through() { + let body = StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"hello world"))); + assert_eq!(body.remaining_length().exact(), Some(11)); + + let mut guarded = + guard_put_object_body_read_timeout(body, "test-bucket", "ok-object", "req-2", Some(11), Duration::from_secs(60)); + // remaining_length must be forwarded, not reset to unknown. + assert_eq!(guarded.remaining_length().exact(), Some(11)); + + let mut collected = Vec::new(); + while let Some(chunk) = guarded.next().await { + collected.extend_from_slice(&chunk.expect("chunk should read")); + } + assert_eq!(collected, b"hello world"); + } + + #[tokio::test] + async fn put_object_body_read_timeout_guard_disabled_passthrough() { + let body = StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"data"))); + let mut guarded = guard_put_object_body_read_timeout(body, "test-bucket", "ok-object", "req-3", Some(4), Duration::ZERO); + + let mut collected = Vec::new(); + while let Some(chunk) = guarded.next().await { + collected.extend_from_slice(&chunk.expect("chunk should read")); + } + assert_eq!(collected, b"data"); + } + + #[test] + fn should_use_zero_copy_rejects_encrypted_requests_with_sse_customer_algorithm() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, HeaderValue::from_static("AES256")); + + assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_encrypted_requests_with_kms_key_id() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, HeaderValue::from_static("test-kms-key-id")); + + assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_compressible_content_types() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json; charset=utf-8")); + + assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[test] + fn should_use_small_eager_put_path_allows_up_to_1mb() { + let headers = HeaderMap::new(); + + assert!(should_use_small_eager_put_path(1024, &headers, false, false, false)); + assert!(should_use_small_eager_put_path(1024 * 1024, &headers, false, false, false)); + assert!(!should_use_small_eager_put_path(1024 * 1024 + 1, &headers, false, false, false)); + } + + #[test] + fn should_use_small_eager_put_path_rejects_sse_requests() { + let headers = HeaderMap::new(); + + assert!(!should_use_small_eager_put_path(1024, &headers, true, false, false)); + } + + #[test] + fn should_use_small_eager_put_path_rejects_compressible_objects() { + let headers = HeaderMap::new(); + + assert!(!should_use_small_eager_put_path(1024, &headers, false, true, false)); + } + + #[test] + fn should_use_small_eager_put_path_rejects_extract_requests() { + let headers = HeaderMap::new(); + + assert!(!should_use_small_eager_put_path(1024, &headers, false, false, true)); + } + + #[test] + fn should_use_small_eager_put_path_rejects_large_or_empty_objects() { + let headers = HeaderMap::new(); + + assert!(!should_use_small_eager_put_path(0, &headers, false, false, false)); + assert!(!should_use_small_eager_put_path(1024 * 1024 + 1, &headers, false, false, false)); + } + + #[test] + fn should_use_zero_copy_eager_put_path_allows_large_plain_objects_within_cap() { + let headers = HeaderMap::new(); + + assert!(should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, false, false)); + assert!(should_use_zero_copy_eager_put_path(16 * 1024 * 1024, &headers, false, false, false)); + assert!(!should_use_zero_copy_eager_put_path(16 * 1024 * 1024 + 1, &headers, false, false, false)); + assert_eq!( + zero_copy_eager_put_path_status(16 * 1024 * 1024, &headers, false, false, false), + PUT_EAGER_STATUS_ELIGIBLE + ); + assert_eq!( + zero_copy_eager_put_path_status(16 * 1024 * 1024 + 1, &headers, false, false, false), + PUT_EAGER_STATUS_ABOVE_EAGER_MAX + ); + } + + #[test] + fn zero_copy_eager_put_path_status_honors_configured_cap() { + let headers = HeaderMap::new(); + let max_size = 64 * 1024 * 1024; + + assert_eq!( + zero_copy_eager_put_path_status_with_max_size(33 * 1024 * 1024, &headers, false, false, false, max_size), + PUT_EAGER_STATUS_ELIGIBLE + ); + assert_eq!( + zero_copy_eager_put_path_status_with_max_size(65 * 1024 * 1024, &headers, false, false, false, max_size), + PUT_EAGER_STATUS_ABOVE_EAGER_MAX + ); + } + + #[test] + fn should_use_zero_copy_eager_put_path_rejects_compression_sse_and_extract() { + let headers = HeaderMap::new(); + + assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, true, false, false)); + assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, true, false)); + assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, false, true)); + assert_eq!( + zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, true, false, false), + PUT_EAGER_STATUS_ENCRYPTED + ); + assert_eq!( + zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, false, true, false), + PUT_EAGER_STATUS_COMPRESSED + ); + assert_eq!( + zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, false, false, true), + PUT_EAGER_STATUS_EXTRACT + ); + } + + #[tokio::test] + async fn read_small_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { + let body = StreamReader::new(futures::stream::iter(vec![Err::(s3s_body_error_to_io(Box::new( + MockUploadStreamSha256Mismatch, + )))])); + + let error = read_small_put_body_exact_direct(body, 1) + .await + .expect_err("SHA256 mismatch should reject the small PUT body"); + + assert_eq!(error.code(), &S3ErrorCode::BadDigest); + } + + #[tokio::test] + async fn read_zero_copy_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { + let body = futures::stream::iter(vec![Err::(MockUploadStreamSha256Mismatch)]); + + let error = match read_zero_copy_put_body_exact(body, 1).await { + Ok(_) => panic!("SHA256 mismatch should reject the zero-copy PUT body"), + Err(error) => error, + }; + + assert_eq!(error.code(), &S3ErrorCode::BadDigest); + } + + struct FragmentedBody { + data: std::io::Cursor>, + } + + impl AsyncRead for FragmentedBody { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let position = usize::try_from(self.data.position()).expect("test cursor position should fit usize"); + let remaining = &self.data.get_ref()[position..]; + let copied = remaining.len().min(buf.remaining()).min(2); + buf.put_slice(&remaining[..copied]); + self.data + .set_position(u64::try_from(position + copied).expect("test cursor position should fit u64")); + Poll::Ready(Ok(())) + } + } + + struct InitializedLengthProbe { + data: std::io::Cursor>, + initialized_lengths: Arc>>, + } + + impl AsyncRead for InitializedLengthProbe { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + self.initialized_lengths + .lock() + .expect("initialized-length probe lock should not poison") + .push(buf.initialized().len()); + let position = usize::try_from(self.data.position()).expect("test cursor position should fit usize"); + let remaining = &self.data.get_ref()[position..]; + let copied = remaining.len().min(buf.remaining()); + buf.put_slice(&remaining[..copied]); + self.data + .set_position(u64::try_from(position + copied).expect("test cursor position should fit u64")); + Poll::Ready(Ok(())) + } + } + + #[tokio::test] + async fn read_small_put_body_exact_pooled_reads_exact_bytes_without_prefill() { + let pool = get_concurrency_manager().bytes_pool(); + let initialized_lengths = Arc::new(Mutex::new(Vec::new())); + let body = InitializedLengthProbe { + data: std::io::Cursor::new(b"hello".to_vec()), + initialized_lengths: Arc::clone(&initialized_lengths), + }; + + let buffer = read_small_put_body_exact_pooled(body, 5, pool.as_ref()) + .await + .expect("pooled exact read should succeed"); + + assert_eq!(&buffer[..5], b"hello"); + assert_eq!(buffer.len(), 5); + assert_eq!( + initialized_lengths + .lock() + .expect("initialized-length probe lock should not poison")[0], + 0, + "the first pooled body read must use uninitialized spare capacity rather than a zero-filled slice" + ); + } + + #[tokio::test] + async fn read_small_put_body_exact_pooled_rejects_short_body() { + let pool = get_concurrency_manager().bytes_pool(); + let body = std::io::Cursor::new(b"hell".to_vec()); + + let err = match read_small_put_body_exact_pooled(body, 5, pool.as_ref()).await { + Ok(_) => panic!("short pooled body should fail"), + Err(err) => err, + }; + + assert_eq!(err.code(), &S3ErrorCode::IncompleteBody); + } + + #[tokio::test] + async fn read_small_put_body_exact_pooled_rejects_extra_body() { + let pool = get_concurrency_manager().bytes_pool(); + let body = std::io::Cursor::new(b"hello!".to_vec()); + + let err = match read_small_put_body_exact_pooled(body, 5, pool.as_ref()).await { + Ok(_) => panic!("extra pooled body should fail"), + Err(err) => err, + }; + + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_reads_exact_bytes_without_prefill() { + let body = std::io::Cursor::new(b"hello".to_vec()); + let reader = read_small_put_body_exact_direct(body, 5) + .await + .expect("direct exact read should succeed"); + + assert_eq!(reader.get_ref().as_slice(), b"hello"); + assert_eq!(reader.get_ref().len(), 5); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_rejects_short_and_extra_bodies() { + let short = read_small_put_body_exact_direct(std::io::Cursor::new(b"hell".to_vec()), 5) + .await + .expect_err("short direct body should fail"); + assert_eq!(short.code(), &S3ErrorCode::IncompleteBody); + + let extra = read_small_put_body_exact_direct(std::io::Cursor::new(b"hello!".to_vec()), 5) + .await + .expect_err("extra direct body should fail"); + assert_eq!(extra.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_handles_empty_body_boundary() { + let empty = read_small_put_body_exact_direct(std::io::Cursor::new(Vec::::new()), 0) + .await + .expect("empty direct body should succeed"); + assert!(empty.get_ref().is_empty()); + + let extra = read_small_put_body_exact_direct(std::io::Cursor::new(vec![1u8]), 0) + .await + .expect_err("non-empty body declared as empty should fail"); + assert_eq!(extra.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_rejects_error_after_partial_read() { + struct PartialThenError { + delivered_prefix: bool, + } + + impl AsyncRead for PartialThenError { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if self.delivered_prefix { + return Poll::Ready(Err(std::io::Error::other("body read failed"))); + } + + self.delivered_prefix = true; + buf.put_slice(b"he"); + Poll::Ready(Ok(())) + } + } + + let err = read_small_put_body_exact_direct(PartialThenError { delivered_prefix: false }, 5) + .await + .expect_err("a partial body followed by an I/O error must fail"); + + assert_eq!(err.code(), &S3ErrorCode::InternalError); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_accepts_fragmented_body() { + let reader = read_small_put_body_exact_direct( + FragmentedBody { + data: std::io::Cursor::new(b"hello".to_vec()), + }, + 5, + ) + .await + .expect("a fragmented exact-length body should succeed"); + + assert_eq!(reader.get_ref().as_slice(), b"hello"); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_rejects_fragmented_extra_body() { + let err = read_small_put_body_exact_direct( + FragmentedBody { + data: std::io::Cursor::new(b"hello!".to_vec()), + }, + 5, + ) + .await + .expect_err("a fragmented body longer than declared must fail"); + + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_reads_into_uninitialized_spare_capacity() { + let initialized_lengths = Arc::new(Mutex::new(Vec::new())); + let body = InitializedLengthProbe { + data: std::io::Cursor::new(b"hello".to_vec()), + initialized_lengths: Arc::clone(&initialized_lengths), + }; + + let reader = read_small_put_body_exact_direct(body, 5) + .await + .expect("direct exact read should succeed"); + + assert_eq!(reader.get_ref().as_slice(), b"hello"); + assert_eq!( + initialized_lengths + .lock() + .expect("initialized-length probe lock should not poison")[0], + 0, + "the first body read must use uninitialized spare capacity rather than a zero-filled slice" + ); + } + + #[tokio::test] + async fn read_zero_copy_put_body_exact_reads_chunked_body() { + use tokio::io::AsyncReadExt; + + let body = futures::stream::iter(vec![ + Ok::(Bytes::from_static(b"hello ")), + Ok::(Bytes::from_static(b"world")), + ]); + + let mut reader = read_zero_copy_put_body_exact(body, 11) + .await + .expect("zero-copy eager body read should succeed"); + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect("chunked bytes reader should be readable"); + + assert_eq!(out, b"hello world"); + } + + #[tokio::test] + async fn read_zero_copy_put_body_exact_rejects_extra_bytes() { + let body = futures::stream::iter(vec![ + Ok::(Bytes::from_static(b"hello")), + Ok::(Bytes::from_static(b"!")), + ]); + + let err = match read_zero_copy_put_body_exact(body, 5).await { + Ok(_) => panic!("extra bytes should fail"), + Err(err) => err, + }; + + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn pooled_buffer_reader_keeps_buffer_alive_until_consumed() { + use tokio::io::AsyncReadExt; + + let pool = get_concurrency_manager().bytes_pool(); + let body = std::io::Cursor::new(b"hello".to_vec()); + let buffer = read_small_put_body_exact_pooled(body, 5, pool.as_ref()) + .await + .expect("pooled exact read should succeed"); + let mut reader = PooledBufferReader::new(buffer, 5); + let mut out = Vec::new(); + + reader.read_to_end(&mut out).await.expect("pooled reader should be readable"); + + assert_eq!(out, b"hello"); + } + + #[test] + fn should_use_zero_copy_allows_large_unencrypted_binary_objects() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")); + + assert!(should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[tokio::test] + async fn execute_put_object_rejects_post_object_sse_kms_from_input() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .server_side_encryption(Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS))) + .build() + .unwrap(); + + let mut req = build_request(input, Method::POST); + req.extensions.insert(PostObjectRequestMarker); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_put_object_rejects_extract_sse_kms() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("archive.tar".to_string()) + .server_side_encryption(Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS))) + .build() + .unwrap(); + + let mut req = build_request(input, Method::PUT); + req.headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_put_object_extract_rejects_invalid_storage_class() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("archive.tar".to_string()) + .storage_class(Some(StorageClass::from_static("INVALID"))) + .build() + .unwrap(); + + let mut req = build_request(input, Method::PUT); + req.headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); + } + + #[tokio::test] + async fn execute_put_object_rejects_post_object_sse_kms_from_headers() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let mut req = build_request(input, Method::POST); + req.extensions.insert(PostObjectRequestMarker); + req.headers + .insert(AMZ_SERVER_SIDE_ENCRYPTION, HeaderValue::from_static("aws:kms")); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_put_object_rejects_post_object_sse_kms_key_id_header() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let mut req = build_request(input, Method::POST); + req.extensions.insert(PostObjectRequestMarker); + req.headers + .insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, HeaderValue::from_static("test-kms-key-id")); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_put_object_rejects_invalid_storage_class() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .storage_class(Some(StorageClass::from_static("INVALID-STORAGE-CLASS"))) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); + } + + // https://github.com/rustfs/backlog/issues/1311 — bucket-quota admission must run against the authoritative + // decoded/plain object length, never the aws-chunked wire Content-Length, and must reject negative/unknown lengths. + // https://github.com/rustfs/backlog/issues/1336 — but Content-Encoding: aws-chunked alone is only a declared + // encoding: without a STREAMING-* payload the body is unframed and the wire Content-Length is authoritative. + fn aws_chunked_headers(decoded_len: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_ENCODING, HeaderValue::from_static("aws-chunked")); + if let Some(decoded) = decoded_len { + headers.insert( + HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), + HeaderValue::from_str(decoded).unwrap(), + ); + } + headers + } + + fn streaming_headers(decoded_len: Option<&str>) -> HeaderMap { + let mut headers = aws_chunked_headers(decoded_len); + headers.insert( + HeaderName::from_bytes(AMZ_CONTENT_SHA256.as_bytes()).unwrap(), + HeaderValue::from_static("STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), + ); + headers + } + + #[test] + fn authoritative_size_prefers_aws_chunked_decoded_over_wire_content_length() { + // Wire Content-Length (chunk framing) differs from the decoded object length; the decoded length wins. + let headers = streaming_headers(Some("1000")); + let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); + assert_eq!( + size, 1000, + "aws-chunked admission must use the decoded object length, not the framed wire length" + ); + + // A declared-only aws-chunked request that still carries a decoded length behaves the same. + let headers = aws_chunked_headers(Some("1000")); + let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); + assert_eq!(size, 1000); + } + + #[test] + fn authoritative_size_streaming_without_content_encoding_uses_decoded_length() { + // A streaming payload signals framing via x-amz-content-sha256 alone; Content-Encoding is optional. + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_bytes(AMZ_CONTENT_SHA256.as_bytes()).unwrap(), + HeaderValue::from_static("STREAMING-UNSIGNED-PAYLOAD-TRAILER"), + ); + headers.insert( + HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), + HeaderValue::from_static("1000"), + ); + let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); + assert_eq!( + size, 1000, + "a streaming payload without Content-Encoding must still use the decoded length" + ); + } + + #[test] + fn authoritative_size_rejects_framed_body_without_decoded_length() { + // A genuinely framed upload without x-amz-decoded-content-length has no authoritative size; + // the framed wire length must NOT be a fallback. + let headers = streaming_headers(None); + let err = resolve_put_object_authoritative_size(&headers, Some(1088)) + .expect_err("framed upload without decoded length must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + + // ... even when the wire Content-Length is also absent. + let err = + resolve_put_object_authoritative_size(&headers, None).expect_err("framed upload without any length must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[test] + fn authoritative_size_declared_aws_chunked_without_streaming_uses_wire_content_length() { + // backlog#1336: an SDK PUT that merely declares Content-Encoding: aws-chunked (issue #1857 + // clients) has an unframed body and no decoded length; the wire Content-Length is the real + // object size and the request must be admitted, not rejected with UnexpectedContent. + let headers = aws_chunked_headers(None); + let size = resolve_put_object_authoritative_size(&headers, Some(1088)) + .expect("declared-only aws-chunked must fall back to the wire Content-Length"); + assert_eq!(size, 1088); + + // Same for a combined declared encoding (aws-chunked,gzip). + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_ENCODING, HeaderValue::from_static("aws-chunked,gzip")); + let size = resolve_put_object_authoritative_size(&headers, Some(2048)) + .expect("declared-only aws-chunked,gzip must fall back to the wire Content-Length"); + assert_eq!(size, 2048); + + // Without any length information it is still rejected. + let headers = aws_chunked_headers(None); + let err = resolve_put_object_authoritative_size(&headers, None) + .expect_err("declared-only aws-chunked with no length at all must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[test] + fn authoritative_size_plain_put_uses_content_length() { + let headers = HeaderMap::new(); + let size = resolve_put_object_authoritative_size(&headers, Some(4096)).expect("plain PUT uses Content-Length"); + assert_eq!(size, 4096); + } + + #[test] + fn authoritative_size_plain_put_falls_back_to_decoded_length() { + // Non-chunked request that only surfaced an explicit decoded length. + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), + HeaderValue::from_static("2048"), + ); + let size = resolve_put_object_authoritative_size(&headers, None).expect("decoded length is the fallback"); + assert_eq!(size, 2048); + } + + #[test] + fn authoritative_size_rejects_unknown_length() { + let headers = HeaderMap::new(); + let err = resolve_put_object_authoritative_size(&headers, None).expect_err("no length information must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[test] + fn authoritative_size_rejects_negative_length() { + // A negative decoded length would wrap to an enormous unsigned size for quota/buffer sizing; reject it. + let headers = aws_chunked_headers(Some("-1")); + let err = + resolve_put_object_authoritative_size(&headers, Some(64)).expect_err("negative decoded length must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + + let plain = HeaderMap::new(); + let err = + resolve_put_object_authoritative_size(&plain, Some(-100)).expect_err("negative Content-Length must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[test] + fn authoritative_size_accepts_exact_and_rejects_negative_boundary() { + // Exact zero-length object is admissible (the over-by-1/exact-limit boundary is enforced by the quota checker on this value). + let headers = aws_chunked_headers(Some("0")); + assert_eq!( + resolve_put_object_authoritative_size(&headers, Some(87)).expect("zero-length decoded is valid"), + 0 + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn quota_rejects_ciphertext_replication_before_polling_the_body() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let (_store, bucket) = + crate::app::gating_test_env::durable_quota_test_bucket("ciphertext-replication-early-reject", 4096).await; + let body_polled = Arc::new(AtomicBool::new(false)); + let body_polled_in_stream = Arc::clone(&body_polled); + let body = StreamingBlob::wrap(futures::stream::once(async move { + body_polled_in_stream.store(true, Ordering::Release); + Ok::(Bytes::from_static(b"ciphertext")) + })); + let input = PutObjectInput::builder() + .bucket(bucket) + .key("object".to_string()) + .body(Some(body)) + .content_length(Some(10)) + .build() + .expect("ciphertext replication PUT input should build"); + let mut request = build_request(input, Method::PUT); + insert_header(&mut request.headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true"); + request + .headers + .insert(rustfs_utils::http::REPLICATION_SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256")); + request.extensions.insert(crate::storage::access::ReqInfo { + replication_request_authorized: true, + ..Default::default() + }); + + let err = DefaultObjectUsecase::from_global() + .execute_put_object(&FS::new(), request) + .await + .expect_err("quota-enabled ciphertext replication should fail at ingress"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert!(!body_polled.load(Ordering::Acquire), "rejected ciphertext body must not be consumed"); + } + + #[tokio::test] + #[serial_test::serial] + async fn legacy_quota_rejects_full_put_before_polling_the_body() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + use std::sync::atomic::{AtomicBool, Ordering}; + + const GI_B: u64 = 1024 * 1024 * 1024; + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + let bucket = format!("legacy-quota-{}", Uuid::new_v4().simple()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create legacy quota test bucket"); + crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 4 * GI_B).await; + let metadata_sys = DefaultObjectUsecase::from_global() + .bucket_metadata_sys() + .expect("test app context should expose bucket metadata"); + QuotaChecker::new(metadata_sys) + .set_quota_config( + &bucket, + BucketQuota { + quota: Some(5 * GI_B), + ..Default::default() + }, + ) + .await + .expect("configure legacy quota"); + + let body_polled = Arc::new(AtomicBool::new(false)); + let body_polled_in_stream = Arc::clone(&body_polled); + let body = StreamingBlob::wrap(futures::stream::once(async move { + body_polled_in_stream.store(true, Ordering::Release); + Ok::(Bytes::new()) + })); + let input = PutObjectInput::builder() + .bucket(bucket) + .key("object".to_string()) + .body(Some(body)) + .content_length(Some(i64::try_from(2 * GI_B).expect("test size should fit i64"))) + .build() + .expect("legacy quota PUT input should build"); + + let err = DefaultObjectUsecase::from_global() + .execute_put_object(&FS::new(), build_request(input, Method::PUT)) + .await + .expect_err("4 GiB used plus a 2 GiB PUT must exceed a 5 GiB legacy quota"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert!(!body_polled.load(Ordering::Acquire), "legacy quota rejection must not consume the body"); + } + + #[tokio::test] + #[serial_test::serial] + async fn concurrent_puts_share_durable_bucket_quota_reservations() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("concurrent-put-quota", 6000).await; + + let first_opts = ObjectOptions::default(); + let second_opts = ObjectOptions::default(); + let first_store = Arc::clone(&store); + let first_bucket = bucket.clone(); + let first = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x73; 4096]); + first_store.put_object(&first_bucket, "first", &mut reader, &first_opts).await + }); + let second = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x74; 4096]); + store.put_object(&bucket, "second", &mut reader, &second_opts).await + }); + let (first, second) = tokio::join!(first, second); + let first = first.expect("first PUT task should not panic"); + let second = second.expect("second PUT task should not panic"); + + assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); + let denied = first.err().or_else(|| second.err()).expect("one PUT must be denied"); + assert!(matches!( + denied, + StorageError::QuotaExceeded { + current: 4096, + limit: 6000 + } + )); + } + + #[tokio::test] + #[serial_test::serial] + async fn concurrent_within_limit_puts_keep_independent_mutation_fences() { + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("concurrent-fence-quota", 8192).await; + let first_barrier = PutObjectCommitBarrier::install(&bucket, "first", PutObjectCommitPause::BeforeQuotaRename); + let second_barrier = PutObjectCommitBarrier::install(&bucket, "second", PutObjectCommitPause::BeforeQuotaRename); + + let first_store = Arc::clone(&store); + let first_bucket = bucket.clone(); + let first = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x75; 4096]); + first_store + .put_object(&first_bucket, "first", &mut reader, &ObjectOptions::default()) + .await + }); + let second_store = Arc::clone(&store); + let second_bucket = bucket.clone(); + let second = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x76; 4096]); + second_store + .put_object(&second_bucket, "second", &mut reader, &ObjectOptions::default()) + .await + }); + + first_barrier.wait_until_paused().await; + second_barrier.wait_until_paused().await; + first_barrier.release(); + second_barrier.release(); + + first + .await + .expect("first PUT task should not panic") + .expect("first within-limit PUT should commit"); + second + .await + .expect("second PUT task should not panic") + .expect("second within-limit PUT should commit"); + } + + #[tokio::test] + #[serial_test::serial] + async fn put_rejects_rotated_quota_capability_before_rename() { + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("rotated-proof-put-quota", 4096).await; + let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); + let put_store = Arc::clone(&store); + let put_bucket = bucket.clone(); + let put = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x77; 4096]); + put_store + .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) + .await + }); + barrier.wait_until_paused().await; + assert!( + crate::storage::storage_api::ecstore_notification::rotate_cross_pool_fence_fleet_proof_for_test(), + "the gating environment must have a current fleet proof" + ); + barrier.release(); + + let err = put + .await + .expect("PUT task should not panic") + .expect_err("a replaced fleet proof must fence the authoritative rename"); + assert!(matches!( + err, + StorageError::NamespaceLockQuorumUnavailable { + mode: "quota_reservation", + .. + } + )); + store + .get_object_info(&bucket, "object", &ObjectOptions::default()) + .await + .expect_err("proof rotation before rename must leave no committed object"); + } + + #[tokio::test] + #[serial_test::serial] + async fn data_movement_put_has_zero_quota_growth() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("data-movement-put-quota", 0).await; + let mut reader = PutObjReader::from_vec(vec![0x79; 4096]); + let stored = store + .put_object( + &bucket, + "object", + &mut reader, + &ObjectOptions { + data_movement: true, + ..Default::default() + }, + ) + .await + .expect("moving an already-accounted object between pools must have zero quota growth"); + assert_eq!(stored.size, 4096); + } + + #[tokio::test] + #[serial_test::serial] + async fn cancelled_put_releases_durable_quota_reservation() { + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("cancelled-put-quota", 4096).await; + + let barrier = PutObjectCommitBarrier::install(&bucket, "cancelled", PutObjectCommitPause::AfterQuotaReservation); + let cancelled_store = Arc::clone(&store); + let cancelled_bucket = bucket.clone(); + let cancelled = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x51; 4096]); + cancelled_store + .put_object(&cancelled_bucket, "cancelled", &mut reader, &ObjectOptions::default()) + .await + }); + barrier.wait_until_paused().await; + cancelled.abort(); + let cancelled_result = cancelled.await; + assert!(cancelled_result.is_err(), "the paused request must be cancelled"); + drop(barrier); + + let mut replacement = PutObjReader::from_vec(vec![0x52; 4096]); + store + .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) + .await + .expect("cancelling before commit must release the complete reservation"); + } + + #[tokio::test] + #[serial_test::serial] + async fn cancelled_put_after_commit_marker_is_reconciled() { + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("cancelled-spawned-put-quota", 4096).await; + let commit_barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); + let first_store = Arc::clone(&store); + let first_bucket = bucket.clone(); + let first = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x53; 4096]); + first_store + .put_object(&first_bucket, "object", &mut reader, &ObjectOptions::default()) + .await + }); + commit_barrier.wait_until_paused().await; + first.abort(); + assert!(first.await.is_err(), "the outer request task must be cancelled"); + drop(commit_barrier); + + store + .get_object_info(&bucket, "object", &ObjectOptions::default()) + .await + .expect_err("cancelling before rename must not commit the object"); + let mut replacement = PutObjReader::from_vec(vec![0x54; 4096]); + store + .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) + .await + .expect("the next admission must reap the abandoned commit marker"); + } + + #[tokio::test] + #[serial_test::serial] + async fn committed_put_survives_quota_ledger_settlement_failure() { + use crate::app::storage_api::test::set_disk::{ + PutObjectCommitBarrier, PutObjectCommitPause, fail_next_quota_ledger_save_for_test, + }; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("settlement-failure-quota", 4096).await; + let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); + let put_store = Arc::clone(&store); + let put_bucket = bucket.clone(); + let put = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x59; 4096]); + put_store + .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) + .await + }); + barrier.wait_until_paused().await; + fail_next_quota_ledger_save_for_test(); + barrier.release(); + put.await + .expect("PUT task should not panic") + .expect("a post-commit ledger failure must not change the successful write result"); + let stored = store + .get_object_info(&bucket, "object", &ObjectOptions::default()) + .await + .expect("the committed object must remain visible"); + assert_eq!(stored.size, 4096); + } + + #[tokio::test] + #[serial_test::serial] + async fn suspended_null_version_overwrite_uses_exact_quota_delta() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("suspended-version-quota", 6200).await; + let mut versioned_reader = PutObjReader::from_vec(vec![0x61; 4096]); + store + .put_object( + &bucket, + "object", + &mut versioned_reader, + &ObjectOptions { + versioned: true, + ..Default::default() + }, + ) + .await + .expect("write UUID version"); + + for (size, byte) in [(1024, 0x62), (2048, 0x63)] { + let mut reader = PutObjReader::from_vec(vec![byte; size]); + store + .put_object( + &bucket, + "object", + &mut reader, + &ObjectOptions { + version_suspended: true, + ..Default::default() + }, + ) + .await + .expect("suspended write should replace only the exact null version"); + } + + let mut excess = PutObjReader::from_vec(vec![0x64; 57]); + let err = store + .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) + .await + .expect_err("UUID plus replacement null version must consume 6144 bytes"); + assert!(matches!( + err, + StorageError::QuotaExceeded { + current: 6144, + limit: 6200 + } + )); + } + + #[tokio::test] + #[serial_test::serial] + async fn durable_quota_reservation_observes_lowered_config_revision() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("lowered-quota-revision", 8192).await; + let mut initial = PutObjReader::from_vec(vec![0x71; 4096]); + store + .put_object(&bucket, "initial", &mut initial, &ObjectOptions::default()) + .await + .expect("write under original quota"); + + let metadata_sys = DefaultObjectUsecase::from_global() + .bucket_metadata_sys() + .expect("test app context should expose bucket metadata"); + QuotaChecker::new(metadata_sys) + .set_quota_config(&bucket, BucketQuota::new(Some(4096))) + .await + .expect("lower bucket quota"); + let mut excess = PutObjReader::from_vec(vec![0x72]); + let err = store + .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) + .await + .expect_err("reservation must not use the stale larger quota revision"); + assert!(matches!( + err, + StorageError::QuotaExceeded { + current: 4096, + limit: 4096 + } + )); + } + + #[tokio::test] + #[serial_test::serial] + async fn quota_enable_waits_for_unlimited_commit() { + use crate::app::storage_api::test::metadata_sys::ConfigWriteLockProbe; + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("quota-config-fence", 8192).await; + let metadata_sys = DefaultObjectUsecase::from_global() + .bucket_metadata_sys() + .expect("test app context should expose bucket metadata"); + QuotaChecker::new(Arc::clone(&metadata_sys)) + .set_quota_config(&bucket, BucketQuota::new(None)) + .await + .expect("clear quota before the fenced write"); + let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::AfterQuotaReservation); + let put_store = Arc::clone(&store); + let put_bucket = bucket.clone(); + let put = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x73; 4096]); + put_store + .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) + .await + }); + barrier.wait_until_paused().await; + + let update_probe = ConfigWriteLockProbe::install(&bucket); + let update_bucket = bucket.clone(); + let update = tokio::spawn(async move { + QuotaChecker::new(metadata_sys) + .set_quota_config(&update_bucket, BucketQuota::new(Some(0))) + .await + }); + update_probe.wait_until_attempted().await; + assert!( + !update.is_finished(), + "quota mutation must wait for the reservation's metadata transaction guard" + ); + + barrier.release(); + put.await + .expect("PUT task should not panic") + .expect("the write linearized before the quota update must commit"); + update + .await + .expect("quota update task should not panic") + .expect("quota update should proceed after commit"); + + let mut excess = PutObjReader::from_vec(vec![0x74]); + let err = store + .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) + .await + .expect_err("writes after the zero-byte quota update must be denied"); + assert!(matches!(err, StorageError::QuotaExceeded { current: 4096, limit: 0 })); + } +} diff --git a/rustfs/src/app/object/restore.rs b/rustfs/src/app/object/restore.rs new file mode 100644 index 000000000..8246cb90a --- /dev/null +++ b/rustfs/src/app/object/restore.rs @@ -0,0 +1,355 @@ +// 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. + +//! RestoreObject path. + +use super::*; + +impl DefaultObjectUsecase { + #[instrument(level = "debug", skip(self, req))] + pub async fn execute_restore_object(&self, req: S3Request) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = OperationHelper::new(&req, EventName::ObjectRestorePost, S3Operation::RestoreObject); + let RestoreObjectInput { + bucket, + key: object, + restore_request: rreq, + version_id, + .. + } = req.input.clone(); + + validate_table_catalog_object_mutation(&bucket, &object).await?; + + let rreq = rreq.ok_or_else(|| { + S3Error::with_message(S3ErrorCode::Custom("ErrValidRestoreObject".into()), "restore request is required") + })?; + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + + let version_id_str = version_id.clone().unwrap_or_default(); + let mut opts = post_restore_opts(&version_id_str, &bucket, &object) + .await + .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrPostRestoreOpts".into()), "restore object failed."))?; + apply_bucket_generation_guard(&req, &bucket, &mut opts)?; + // `apply_bucket_generation_guard` deliberately tolerates a missing guard + // (only the S3 access layer installs one), so this must not hard-require + // it. Resolve the current generation instead, exactly as the copy path + // does. The fence is unaffected: the value is re-read from disk and + // compared below, before the restore is admitted. + let restore_bucket_incarnation_id = match opts.expected_bucket_incarnation_id { + Some(incarnation_id) => incarnation_id, + None => { + let incarnation_id = store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)?; + opts.expected_bucket_incarnation_id = Some(incarnation_id); + incarnation_id + } + }; + + // SELECT-type restores skip both the ongoing check and the metadata + // write below, so the accept guard would protect nothing for them — + // they keep the plain (read-locked) accept path. + let is_select = rreq.type_.as_ref().is_some_and(|t| t.as_str() == "SELECT"); + + // Hold the restore-accept guard across the restore-status read, the + // ongoing/already-restored decision, and the metadata write below, so + // two concurrent (non-SELECT) POST ?restore cannot both observe + // ongoing=false and both start a copy-back (backlog#1304). Reads and + // writes inside this scope run with no_lock; the guard is dropped + // before the copy-back is spawned so it never blocks readers. + // Contention on the accept guard (e.g. a concurrent accept or an + // in-flight commit on the same object) is transient — answer 503 + // SlowDown so SDK clients back off and retry instead of treating it + // as a hard failure. + let restore_bucket_lifecycle_guard = Some(acquire_copy_bucket_lifecycle_lock(store.as_ref(), &bucket).await?); + if store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)? != restore_bucket_incarnation_id { + return Err(ApiError::from(StorageError::BucketNotFound(bucket.clone())).into()); + } + let accept_guard = if is_select { + None + } else { + let guard = store + .acquire_restore_accept_guard(&bucket, &object) + .await + .map_err(|_| S3Error::with_message(S3ErrorCode::SlowDown, "restore object failed."))?; + opts.no_lock = true; + Some(guard) + }; + + let mut obj_info = store + .get_object_info(&bucket, &object, &opts) + .await + .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrInvalidObjectState".into()), "restore object failed."))?; + + // Check if object is in a transitioned state + if obj_info.transitioned_object.status != lifecycle::TRANSITION_COMPLETE { + return Err(S3Error::with_message( + S3ErrorCode::Custom("ErrInvalidTransitionedState".into()), + "restore object failed.", + )); + } + + // Validate restore request + if let Err(e) = validate_restore_request(&rreq, store.clone()) { + return Err(S3Error::with_message( + S3ErrorCode::Custom("ErrValidRestoreObject".into()), + format!("Restore object validation failed: {}", e), + )); + } + + // Check if restore is already in progress. AWS answers this with + // 409 RestoreAlreadyInProgress; a Custom code would serialize as a + // retryable 500 and make SDK clients retry the conflict (backlog#1304). + if obj_info.restore_ongoing && !is_select { + return Err(S3Error::with_message( + S3ErrorCode::RestoreAlreadyInProgress, + "Object restore is already in progress.", + )); + } + + let mut already_restored = false; + if let Some(restore_expires) = obj_info.restore_expires + && !obj_info.restore_ongoing + && restore_expires.unix_timestamp() != 0 + { + already_restored = true; + } + + let restore_expiry = lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), *rreq.days.as_ref().unwrap_or(&1)); + let mut metadata = (*obj_info.user_defined).clone(); + let restore_operation_id = (!is_select && !already_restored).then(Uuid::new_v4); + + let mut header = HeaderMap::new(); + + let event_object_info = obj_info.clone(); + let obj_info_ = obj_info.clone(); + if !is_select { + obj_info.metadata_only = true; + metadata.insert(AMZ_RESTORE_EXPIRY_DAYS.to_string(), rreq.days.unwrap_or(1).to_string()); + let request_date = OffsetDateTime::now_utc().format(&Rfc3339).map_err(|e| { + S3Error::with_message(S3ErrorCode::InternalError, format!("format restore request date failed: {}", e)) + })?; + metadata.insert(AMZ_RESTORE_REQUEST_DATE.to_string(), request_date); + if already_restored { + metadata.insert( + X_AMZ_RESTORE.as_str().to_string(), + RestoreStatus { + is_restore_in_progress: Some(false), + restore_expiry_date: Some(Timestamp::from(restore_expiry)), + } + .to_string(), + ); + } else { + metadata.insert( + X_AMZ_RESTORE.as_str().to_string(), + RestoreStatus { + is_restore_in_progress: Some(true), + restore_expiry_date: Some(Timestamp::from(OffsetDateTime::now_utc())), + } + .to_string(), + ); + if let Some(id) = restore_operation_id { + insert_str(&mut metadata, SUFFIX_RESTORE_OPERATION_ID, id.to_string()); + } + } + obj_info.user_defined = Arc::new(metadata); + + // Fence the compare-and-set write: if the accept guard was lost + // (lock-service degradation), another node may have concurrently + // accepted this restore — back off instead of committing a second + // ongoing flag and double-starting the copy-back. + if accept_guard.as_ref().is_some_and(|g| g.is_lock_lost()) { + return Err(S3Error::with_message(S3ErrorCode::SlowDown, "restore object failed.")); + } + + let mut restore_dst_opts = ObjectOptions { + version_id: obj_info_.version_id.map(|v| v.to_string()), + mod_time: obj_info_.mod_time, + no_lock: true, + expected_bucket_incarnation_id: Some(restore_bucket_incarnation_id), + ..Default::default() + }; + if let Some(guard) = restore_bucket_lifecycle_guard.as_ref() { + restore_dst_opts.add_bucket_lifecycle_lock_guard(guard); + } + if let Some(guard) = accept_guard.as_ref() { + guard.add_namespace_lock_fence(&mut restore_dst_opts); + } + store + .clone() + .copy_object( + &bucket, + &object, + &bucket, + &object, + &mut obj_info, + &ObjectOptions { + version_id: obj_info_.version_id.map(|v| v.to_string()), + // Inside the accept-guard critical section (see above). + no_lock: true, + ..Default::default() + }, + &restore_dst_opts, + ) + .await + .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrCopyObject".into()), "restore object failed."))?; + rustfs_scanner::record_dirty_usage_bucket(&bucket); + + if already_restored { + let output = RestoreObjectOutput { + request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)), + restore_output_path: None, + }; + helper = helper + .object(event_object_info.clone()) + .version_id(version_id_str.clone()) + .suppress_event(); + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + return result; + } + } + + // The accept decision is committed; release the object write lock so + // the background copy-back and concurrent reads are never blocked on it. + drop(accept_guard); + drop(restore_bucket_lifecycle_guard); + + // Handle output location for SELECT requests + if let Some(output_location) = &rreq.output_location + && let Some(s3) = &output_location.s3 + && !s3.bucket_name.is_empty() + { + let restore_object = Uuid::new_v4().to_string(); + if let Ok(header_value) = format!("{}{}{}", s3.bucket_name, s3.prefix, restore_object).parse() { + header.insert(X_AMZ_RESTORE_OUTPUT_PATH, header_value); + } + } + + // Spawn restoration task in the background. Pin the copy-back to the + // version the accept resolved and flagged: with a versionless request + // on a versioned bucket, a PUT landing between the accept and the + // copy-back would otherwise re-resolve "latest" to the new version, + // fail (not transitioned), and strand the flagged version at + // ongoing=true forever (backlog#1304). + let store_clone = store.clone(); + let bucket_clone = bucket.clone(); + let object_clone = object.clone(); + let rreq_clone = rreq.clone(); + let version_id_clone = obj_info_ + .version_id + .map(|v| v.to_string()) + .or_else(|| (opts.versioned || opts.version_suspended).then(|| Uuid::nil().to_string())); + let versioned = opts.versioned; + let version_suspended = opts.version_suspended; + let mut restore_operation_metadata = HashMap::new(); + if let Some(id) = restore_operation_id { + insert_str(&mut restore_operation_metadata, SUFFIX_RESTORE_OPERATION_ID, id.to_string()); + } + + spawn_traced(async move { + let opts = ObjectOptions { + transition: TransitionOptions { + restore_request: rreq_clone, + restore_expiry, + ..Default::default() + }, + version_id: version_id_clone, + versioned, + version_suspended, + expected_bucket_incarnation_id: Some(restore_bucket_incarnation_id), + user_defined: restore_operation_metadata, + ..Default::default() + }; + + if let Err(err) = store_clone + .restore_transitioned_object(&bucket_clone, &object_clone, &opts) + .await + { + warn!( + "unable to restore transitioned bucket/object {}/{}: {}", + bucket_clone, + object_clone, + err.to_string() + ); + } else { + rustfs_scanner::record_dirty_usage_bucket(&bucket_clone); + debug!(bucket = %bucket_clone, object = %object_clone, "Transitioned object restored"); + } + }); + + let output = RestoreObjectOutput { + request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)), + restore_output_path: None, + }; + helper = helper.object(event_object_info).version_id(version_id_str); + let result = Ok(S3Response::with_headers(output, header)); + let _ = helper.complete(&result); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::Method; + use s3s::dto::RestoreRequest; + + #[tokio::test] + async fn execute_restore_object_rejects_missing_restore_request() { + let input = RestoreObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_restore_object(req).await.unwrap_err(); + match err.code() { + S3ErrorCode::Custom(code) => assert_eq!(code, "ErrValidRestoreObject"), + code => panic!("unexpected error code: {:?}", code), + } + } + + #[tokio::test] + async fn execute_restore_object_returns_internal_error_when_store_uninitialized() { + let restore_request = RestoreRequest { + days: Some(1), + description: None, + glacier_job_parameters: None, + output_location: None, + select_parameters: None, + tier: None, + type_: None, + }; + let input = RestoreObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .restore_request(Some(restore_request)) + .build() + .unwrap(); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_restore_object(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InternalError); + } +} diff --git a/rustfs/src/app/object/shared.rs b/rustfs/src/app/object/shared.rs new file mode 100644 index 000000000..17f7b89a2 --- /dev/null +++ b/rustfs/src/app/object/shared.rs @@ -0,0 +1,1653 @@ +// 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. + +//! Cross-cutting helpers shared by the object use-case modules. + +use super::*; + +pub(super) const RUSTFS_EXPECTED_CURRENT_VERSION_ID: &str = "x-rustfs-expected-current-version-id"; + +pub(super) type S3StdError = Box; + +pub(crate) fn s3s_body_error_to_io(err: StdError) -> io::Error { + io::Error::other(err) +} + +pub(super) const ACCEPT_RANGES_BYTES: &str = "bytes"; + +pub(super) const LOG_COMPONENT_APP: &str = "app"; + +pub(super) const LOG_SUBSYSTEM_OBJECT: &str = "object"; + +pub(super) fn decoded_content_length_from_headers(headers: &HeaderMap) -> S3Result> { + let Some(val) = headers.get(AMZ_DECODED_CONTENT_LENGTH) else { + return Ok(None); + }; + + match atoi::atoi::(val.as_bytes()) { + Some(x) => Ok(Some(x)), + None => Err(s3_error!(UnexpectedContent)), + } +} + +/// Losslessly convert an s3s [`Range`] into the internal [`HTTPRangeSpec`]. +/// +/// Shared by GET and HEAD so both apply identical range semantics. s3s parses +/// `first`/`last` as `u64`, but its own parser already rejects any value greater +/// than `i64::MAX`, so the int branch is a checked cast that never truncates. +/// +/// The suffix length, however, is an unchecked `u64`. A naive `length as i64` +/// truncates deterministically: `bytes=-18446744073709551615` wraps to `-1` and +/// is then read as "last 1 byte", and `bytes=-0` yields a 0-length 206 instead +/// of a 416. This function instead mirrors s3s [`Range::check`] semantics: +/// * a zero-length suffix is rejected with `InvalidRange` (416), matching AWS +/// S3 and MinIO; +/// * a suffix larger than `i64::MAX` is clamped to `i64::MAX`. Object sizes in +/// this system are bounded by `i64::MAX`, so such a suffix always covers the +/// whole object, and [`HTTPRangeSpec::get_length`] clamps it to the real +/// size once the object is known. +pub(super) fn range_to_http_range_spec(range: Range) -> S3Result { + match range { + Range::Int { first, last } => { + let start = i64::try_from(first).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))?; + let end = match last { + Some(last) => { + i64::try_from(last).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))? + } + None => -1, + }; + Ok(HTTPRangeSpec { + is_suffix_length: false, + start, + end, + }) + } + Range::Suffix { length } => { + if length == 0 { + return Err(s3_error!(InvalidRange, "The requested range is not satisfiable")); + } + // Clamp to i64::MAX: any suffix >= object size returns the whole + // object, and object sizes never exceed i64::MAX. + let start = i64::try_from(length).unwrap_or(i64::MAX); + Ok(HTTPRangeSpec { + is_suffix_length: true, + start, + end: -1, + }) + } + } +} + +/// True when the request body actually arrived chunk-framed on the wire, i.e. the payload was +/// signed as a SigV4 streaming upload (`x-amz-content-sha256: STREAMING-*`). This is the only +/// case in which the auth layer de-frames the body; `Content-Encoding: aws-chunked` without a +/// streaming payload is just a declared encoding over an unframed body. +pub(super) fn request_body_is_aws_chunked_framed(headers: &HeaderMap) -> bool { + headers + .get(AMZ_CONTENT_SHA256) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.len() >= 10 && value[..10].eq_ignore_ascii_case("STREAMING-")) +} + +/// Map a bucket-quota checker outcome onto the S3 admission result. +/// +/// Hard is the only supported quota type, so a checker fault (bucket-config read, config parse, or usage lookup) must fail closed rather than admit the write: allowing it would silently bypass a configured hard quota. The no-quota happy path never reaches the error arm — `QuotaChecker::check_quota` returns `Ok(allowed)` via the zero-extra-I/O fast path when no quota is configured, so failing closed here cannot penalise buckets without a quota. A fault surfaces as a retryable `ServiceUnavailable` and is counted; the client-facing message stays generic so internal config/usage details are not leaked. +pub(crate) fn map_quota_check_outcome(bucket: &str, outcome: Result) -> S3Result { + match outcome { + Ok(result) if !result.allowed => Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!( + "Bucket quota exceeded. Current usage: {} bytes, limit: {} bytes", + result.current_usage.unwrap_or(0), + result.quota_limit.unwrap_or(0) + ), + )), + Err(e) => { + counter!("rustfs_bucket_quota_check_failed_total").increment(1); + if matches!(&e, QuotaError::UsageUnavailable { .. }) { + debug!(bucket, error = %e, state = "usage_pending", "Bucket quota check waiting for authoritative usage"); + } else { + warn!(bucket, error = %e, state = "checker_failed", "Bucket quota check failed closed"); + } + Err(S3Error::with_message( + S3ErrorCode::ServiceUnavailable, + "Bucket quota check temporarily unavailable, please retry".to_string(), + )) + } + Ok(result) => Ok(result), + } +} + +pub(crate) fn apply_quota_admission(opts: &mut ObjectOptions, result: &QuotaCheckResult) -> S3Result<()> { + if result.uses_durable_reservations { + return Ok(()); + } + let Some(quota_limit) = result.quota_limit else { + return Ok(()); + }; + let Some(current_usage) = result.current_usage else { + return Err(S3Error::with_message( + S3ErrorCode::ServiceUnavailable, + "Bucket quota check temporarily unavailable, please retry".to_string(), + )); + }; + if current_usage > quota_limit { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), + )); + } + let _ = opts.set_quota_admission(current_usage, quota_limit); + Ok(()) +} + +pub(super) fn ensure_object_size_within_quota(result: &QuotaCheckResult, new_size: u64) -> S3Result<()> { + let (Some(current_usage), Some(quota_limit)) = (result.current_usage, result.quota_limit) else { + return Ok(()); + }; + if new_size > quota_limit { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), + )); + } + Ok(()) +} + +pub(super) fn quota_accounting_object_size(info: &ObjectInfo, fail_closed: bool) -> S3Result { + match quota_object_size(info) { + Ok(size) => Ok(size), + Err(err) if fail_closed => Err(ApiError::from(err).into()), + Err(_) => Ok(info.size.max(0) as u64), + } +} + +pub(super) fn request_uses_aws_chunked(headers: &HeaderMap) -> bool { + let has_aws_chunked = |header_name: &str| { + headers + .get(header_name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.split(',').any(|part| part.trim().eq_ignore_ascii_case("aws-chunked"))) + }; + + has_aws_chunked("content-encoding") || has_aws_chunked("transfer-encoding") +} + +pub(super) async fn validate_table_catalog_object_mutation(bucket: &str, key: &str) -> S3Result<()> { + table_catalog::validate_bucket_object_mutation(bucket, key) + .await + .map_err(|_| s3_error!(InvalidRequest, "{}", table_catalog::RESERVED_CATALOG_OBJECT_MESSAGE)) +} + +pub(super) struct DeadlockRequestGuard { + deadlock_detector: Arc, + request_id: String, +} + +impl DeadlockRequestGuard { + fn new(deadlock_detector: Arc, request_id: String) -> Self { + Self { + deadlock_detector, + request_id, + } + } + + pub(super) fn register_if_enabled( + deadlock_detector: Arc, + request_id: &str, + description: F, + ) -> Option + where + F: FnOnce() -> String, + { + if !deadlock_detector.is_enabled() { + return None; + } + + let request_id = request_id.to_string(); + deadlock_detector.register_request(&request_id, description()); + Some(Self::new(deadlock_detector, request_id)) + } +} + +impl Drop for DeadlockRequestGuard { + fn drop(&mut self) { + self.deadlock_detector.unregister_request(&self.request_id); + } +} + +pub(super) fn has_put_sse_request_headers(headers: &HeaderMap) -> bool { + headers.get(AMZ_SERVER_SIDE_ENCRYPTION).is_some() + || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM).is_some() + || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some() +} + +/// Resolve the effective server-side encryption for a write against the bucket's +/// default encryption configuration. +/// +/// A request-level value always wins; the bucket default only fills a gap, and +/// the unknown-algorithm fallback lives once in [`bucket_default_write_sse`]. +/// +/// `has_explicit_ssec` suppresses the default entirely. Only COPY passes `true` +/// today: its destination may carry SSE-C, which must not also be given managed +/// encryption. PUT and extract pass `false`, matching their current behaviour — +/// see backlog#1826 for the divergence that leaves. +/// +/// Callers layering further overrides (PUT's `ciphertext_passthrough`) apply +/// them to the returned pair. +pub(super) fn resolve_bucket_default_sse( + bucket_sse_config: Option<&ServerSideEncryptionConfiguration>, + requested_sse: Option, + requested_kms_key_id: Option, + has_explicit_ssec: bool, +) -> (Option, Option) { + let bucket_default = || { + if has_explicit_ssec { + return None; + } + bucket_sse_config + .and_then(|config| config.rules.first()) + .and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref()) + }; + + let effective_sse = requested_sse.or_else(|| bucket_default().map(bucket_default_write_sse)); + let effective_kms_key_id = requested_kms_key_id.or_else(|| bucket_default().and_then(|sse| sse.kms_master_key_id.clone())); + (effective_sse, effective_kms_key_id) +} + +#[cfg(test)] +mod deadlock_request_guard_tests { + use super::DeadlockRequestGuard; + use crate::app::storage_api::object_usecase::deadlock_detector::{DeadlockDetector, RequestHangDetectionPolicy}; + use std::cell::Cell; + use std::rc::Rc; + use std::sync::Arc; + + #[test] + fn deadlock_request_guard_unregisters_on_drop() { + let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy { + enabled: true, + ..RequestHangDetectionPolicy::default() + })); + let request_id = "test-request-id".to_string(); + + detector.register_request(&request_id, "test request"); + assert_eq!(detector.tracked_count(), 1); + + { + let _guard = DeadlockRequestGuard::new(Arc::clone(&detector), request_id); + // `_guard` is dropped at the end of this scope, which should unregister the request. + } + + assert_eq!(detector.tracked_count(), 0); + } + + #[test] + fn deadlock_request_guard_skips_disabled_detector() { + let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy { + enabled: false, + ..RequestHangDetectionPolicy::default() + })); + let description_built = Rc::new(Cell::new(false)); + let description_built_for_closure = Rc::clone(&description_built); + + let guard = DeadlockRequestGuard::register_if_enabled(detector, "test-request-id", || { + description_built_for_closure.set(true); + "test request".to_string() + }); + + assert!(guard.is_none()); + assert!(!description_built.get()); + } +} + +pub(super) async fn maybe_enqueue_transition_immediate(obj_info: &ObjectInfo, src: LcEventSrc) { + enqueue_transition_immediate(obj_info, src).await; +} + +/// Inject additional-checksum response headers (XXHash3/64/128, SHA-512) that s3s +/// cannot carry on its typed `*Output` structs. Centralized so that when s3s gains +/// typed fields for these algorithms, only this one function changes (fill the typed +/// field, drop the header insert) — and there is exactly one place that could ever +/// emit a duplicate header. Header names come from `ChecksumType::key()`, so they are +/// known-valid static strings. +pub(crate) fn inject_additional_checksum_headers(headers: &mut HeaderMap, pairs: &[(&'static str, String)]) { + for (name, value) in pairs { + match HeaderValue::from_str(value) { + Ok(header_value) => { + headers.insert(http::HeaderName::from_static(name), header_value); + } + Err(_) => warn!("Failed to parse {name} checksum header value; skipping"), + } + } +} + +pub(super) fn inject_accept_ranges_header(headers: &mut HeaderMap) { + headers.insert(http::header::ACCEPT_RANGES, HeaderValue::from_static(ACCEPT_RANGES_BYTES)); +} + +/// Derive the response-header echo pairs for an additional-checksum algorithm +/// (XXHash3/64/128, SHA-512) from the server-computed content checksum, for +/// PutObject to echo back (#1256). Returns empty for the five s3s-typed algorithms +/// (they are echoed via typed fields) and when the value is not yet materialized +/// (e.g. a trailing checksum, whose value lands after the body — covered by e2e). +pub(crate) fn additional_checksum_echo_pairs(want: &Option) -> Vec<(&'static str, String)> { + let mut out = Vec::new(); + if let Some(cs) = want + && !cs.checksum_type.is_s3s_typed() + && !cs.encoded.is_empty() + && let Some(name) = cs.checksum_type.key() + { + out.push((name, cs.encoded.clone())); + } + out +} + +/// Extract trailing-header checksum values, overriding the corresponding input fields. +pub(super) fn apply_trailing_checksums( + algorithm: Option<&str>, + trailing_headers: &Option, + checksums: &mut PutObjectChecksums, +) { + let Some(alg) = algorithm else { return }; + let Some(checksum_str) = trailing_headers.as_ref().and_then(|trailer| { + let key = match alg { + ChecksumAlgorithm::CRC32 => rustfs_rio::ChecksumType::CRC32.key(), + ChecksumAlgorithm::CRC32C => rustfs_rio::ChecksumType::CRC32C.key(), + ChecksumAlgorithm::SHA1 => rustfs_rio::ChecksumType::SHA1.key(), + ChecksumAlgorithm::SHA256 => rustfs_rio::ChecksumType::SHA256.key(), + ChecksumAlgorithm::CRC64NVME => rustfs_rio::ChecksumType::CRC64_NVME.key(), + _ => return None, + }; + trailer.read(|headers| { + headers + .get(key.unwrap_or_default()) + .and_then(|value| value.to_str().ok().map(|s| s.to_string())) + }) + }) else { + return; + }; + + match alg { + ChecksumAlgorithm::CRC32 => checksums.crc32 = checksum_str, + ChecksumAlgorithm::CRC32C => checksums.crc32c = checksum_str, + ChecksumAlgorithm::SHA1 => checksums.sha1 = checksum_str, + ChecksumAlgorithm::SHA256 => checksums.sha256 = checksum_str, + ChecksumAlgorithm::CRC64NVME => checksums.crc64nvme = checksum_str, + _ => (), + } +} + +/// Checksums resolved from stored (decrypted) metadata for a response. The five +/// legacy algorithms fill named fields; the additional algorithms land in `extra` +/// for raw-header response paths and DTOs that expose their newer typed fields. +#[derive(Default)] +pub(crate) struct ResponseChecksums { + pub(crate) crc32: Option, + pub(crate) crc32c: Option, + pub(crate) sha1: Option, + pub(crate) sha256: Option, + pub(crate) crc64nvme: Option, + pub(crate) checksum_type: Option, + pub(crate) extra: Vec<(&'static str, String)>, +} + +/// Split decrypted checksum pairs into the five legacy fields and the additional +/// algorithm values. Single source of truth for every response +/// path (GetObject / HeadObject / GetObjectAttributes / CompleteMultipartUpload), +/// replacing what used to be five copies of this match loop. +pub(crate) fn classify_response_checksums(pairs: I, is_multipart: bool) -> ResponseChecksums +where + I: IntoIterator, +{ + let mut c = ResponseChecksums::default(); + for (key, checksum) in pairs { + if key == AMZ_CHECKSUM_TYPE { + c.checksum_type = Some(ChecksumType::from(checksum)); + continue; + } + let ct = rustfs_rio::ChecksumType::from_string(key.as_str()); + match ct.base() { + rustfs_rio::ChecksumType::CRC32 => c.crc32 = Some(checksum), + rustfs_rio::ChecksumType::CRC32C => c.crc32c = Some(checksum), + rustfs_rio::ChecksumType::SHA1 => c.sha1 = Some(checksum), + rustfs_rio::ChecksumType::SHA256 => c.sha256 = Some(checksum), + rustfs_rio::ChecksumType::CRC64_NVME => c.crc64nvme = Some(checksum), + _ => { + if let Some(name) = ct.key() { + c.extra.push((name, checksum)); + } + } + } + } + if is_multipart && c.checksum_type.is_none() { + c.checksum_type = Some(ChecksumType::from("COMPOSITE".to_string())); + } + c +} + +fn build_put_object_expiration_header(event: &lifecycle::Event) -> Option { + if !event.action.delete() { + return None; + } + + let expire_time = event.due?; + + if event.rule_id.is_empty() || expire_time == OffsetDateTime::UNIX_EPOCH { + return None; + } + + let expiry_date = expire_time.format(&Rfc3339).ok()?; + Some(format!("expiry-date=\"{}\", rule-id=\"{}\"", expiry_date, event.rule_id)) +} + +pub(super) fn internal_object_info_lookup_opts(mut opts: ObjectOptions) -> ObjectOptions { + opts.http_preconditions = None; + opts +} + +pub(super) fn expected_current_version_id(headers: &HeaderMap) -> S3Result> { + headers + .get(RUSTFS_EXPECTED_CURRENT_VERSION_ID) + .map(|value| { + let value = value + .to_str() + .map(str::trim) + .map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header"))?; + if value.eq_ignore_ascii_case("null") { + return Ok(Uuid::nil().to_string()); + } + Uuid::parse_str(value) + .map(|version| version.to_string()) + .map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header")) + }) + .transpose() +} + +pub(super) fn insert_expires_metadata(metadata: &mut HashMap, expires: Option<&Timestamp>) -> S3Result<()> { + if let Some(expires) = expires { + let mut formatted = Vec::new(); + expires + .format(TimestampFormat::HttpDate, &mut formatted) + .map_err(|e| ApiError::from(StorageError::other(format!("Invalid expires timestamp: {e}"))))?; + metadata.insert("expires".to_string(), String::from_utf8_lossy(&formatted).into_owned()); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn apply_standard_object_metadata( + metadata: &mut HashMap, + cache_control: Option<&str>, + content_disposition: Option<&str>, + content_encoding: Option<&str>, + content_language: Option<&str>, + content_type: Option<&str>, + expires: Option<&Timestamp>, + website_redirect_location: Option<&str>, +) -> S3Result<()> { + if let Some(cache_control) = cache_control { + metadata.insert("cache-control".to_string(), cache_control.to_string()); + } + if let Some(content_disposition) = content_disposition { + metadata.insert("content-disposition".to_string(), content_disposition.to_string()); + } + if let Some(content_encoding) = content_encoding + && let Some(normalized_content_encoding) = normalize_content_encoding_for_storage(content_encoding) + { + metadata.insert("content-encoding".to_string(), normalized_content_encoding); + } + if let Some(content_language) = content_language { + metadata.insert("content-language".to_string(), content_language.to_string()); + } + if let Some(content_type) = content_type { + metadata.insert("content-type".to_string(), content_type.to_string()); + } + insert_expires_metadata(metadata, expires)?; + if let Some(website_redirect_location) = website_redirect_location { + metadata.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location.to_string()); + } + Ok(()) +} + +pub(super) fn response_storage_class(info: &ObjectInfo, metadata: &HashMap) -> Option { + let stored_class = info + .storage_class + .as_deref() + .or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str)); + let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE + && !info.transitioned_object.tier.is_empty()) + .then_some(info.transitioned_object.tier.as_str()); + let effective_class = storageclass::effective_class(stored_class, transitioned_tier); + + (effective_class != storageclass::STANDARD).then(|| StorageClass::from(effective_class.to_string())) +} + +pub(super) fn response_storage_class_for_object_attributes( + info: &ObjectInfo, + metadata: &HashMap, + requested: bool, +) -> Option { + if !requested { + return None; + } + + let stored_class = info + .storage_class + .as_deref() + .or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str)); + let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE + && !info.transitioned_object.tier.is_empty()) + .then_some(info.transitioned_object.tier.as_str()); + + Some(StorageClass::from( + storageclass::effective_class(stored_class, transitioned_tier).to_string(), + )) +} + +// Shared across Object Lock validation paths to keep the client-facing +// InvalidRequest message consistent. +pub(crate) const ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED: &str = + "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied"; + +pub(crate) fn build_put_like_object_lock_metadata( + bucket: &str, + object_lock_config_state: &metadata_sys::ObjectLockConfigState, + object_lock_legal_hold_status: Option, + object_lock_mode: Option, + object_lock_retain_until_date: Option, +) -> S3Result>> { + if object_lock_legal_hold_status.is_none() && object_lock_mode.is_none() && object_lock_retain_until_date.is_none() { + return Ok(None); + } + + let retention = match (object_lock_mode, object_lock_retain_until_date) { + (Some(mode), Some(retain_until_date)) => Some(ObjectLockRetention { + mode: Some(ObjectLockRetentionMode::from(mode.as_str().to_string())), + retain_until_date: Some(retain_until_date), + }), + (Some(_), None) | (None, Some(_)) => { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED.to_string(), + )); + } + (None, None) => None, + }; + + validate_bucket_object_lock_enabled_state(bucket, object_lock_config_state)?; + + let mut eval_metadata = parse_object_lock_retention(retention)?; + eval_metadata.extend(parse_object_lock_legal_hold( + object_lock_legal_hold_status.map(|status| ObjectLockLegalHold { status: Some(status) }), + )?); + + if eval_metadata.is_empty() { + return Ok(None); + } + + Ok(Some(eval_metadata)) +} + +fn put_like_write_creates_new_version(opts: &ObjectOptions) -> bool { + opts.version_id.is_none() && opts.versioned && !opts.version_suspended +} + +pub(crate) fn validate_existing_object_lock_for_write( + object_lock_config_state: &metadata_sys::ObjectLockConfigState, + existing_obj_info: &ObjectInfo, + opts: &ObjectOptions, +) -> S3Result<()> { + if put_like_write_creates_new_version(opts) { + return Ok(()); + } + // An authorized replication write may replace the locked version only + // when the set layer's commit-lock LWW will judge every locking category, + // judged against the bucket's authoritative lock state (default retention + // included) exactly like the set-layer gate, which re-checks the same + // rule under the lock. A non-authoritative state or malformed lock + // metadata fails closed here. + if opts.replication_request { + let may_pass = replication_write_may_pass_worm_gate(object_lock_config_state, existing_obj_info, opts).map_err(|_| { + S3Error::with_message(S3ErrorCode::AccessDenied, "Object Lock state could not be verified.".to_string()) + })?; + return if may_pass { + Ok(()) + } else { + Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Object is locked and the replication write carries no source lock decision for it.".to_string(), + )) + }; + } + + let legal_hold = get_object_legalhold_meta(&existing_obj_info.user_defined); + if legal_hold.is_on() { + return Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Object has a legal hold and cannot be overwritten. Remove the legal hold first.".to_string(), + )); + } + + let retention = get_object_retention_meta(&existing_obj_info.user_defined); + if let Some(mode) = retention.mode + && mode == RetentionMode::Compliance + && is_retention_active(mode, retention.retain_until_date) + { + return Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Object is under COMPLIANCE retention and cannot be overwritten.".to_string(), + )); + } + + Ok(()) +} + +pub(super) async fn resolve_put_object_expiration(bucket: &str, obj_info: &ObjectInfo) -> Option { + let Ok((lifecycle_config, _)) = metadata_sys::get_lifecycle_config(bucket).await else { + debug!(bucket, state = "config_missing", "PUT object expiration config missing"); + return None; + }; + + let obj_opts = lifecycle::object_opts_from_object_info(obj_info); + let event = predict_lifecycle_expiration(&lifecycle_config, &obj_opts).await; + debug!( + bucket, + action = ?event.action, + rule_id = %event.rule_id, + due = ?event.due, + "PUT object expiration resolved" + ); + build_put_object_expiration_header(&event) +} + +/// Cadence for the "I/O queue congestion detected" WARN. Under sustained +/// overload (client concurrency at or above the disk-read permit pool) every +/// GET observes >=80% utilization, so an unthrottled WARN floods the log +/// from the already saturated hot path; congestion metrics stay per-request. +const IO_QUEUE_CONGESTION_WARN_INTERVAL_MS: u64 = 5_000; + +/// At-most-one-WARN-per-interval limiter for the I/O queue congestion log. +/// Callers supply monotonic milliseconds so tests can drive the clock. +pub(super) struct IoQueueCongestionWarnThrottle { + /// Timestamp of the last emitted WARN; `u64::MAX` until the first one. + last_warn_ms: AtomicU64, + /// Congested requests left unlogged since the last emitted WARN. + suppressed: AtomicU64, +} + +impl IoQueueCongestionWarnThrottle { + const fn new() -> Self { + Self { + last_warn_ms: AtomicU64::new(u64::MAX), + suppressed: AtomicU64::new(0), + } + } + + /// Claim the right to emit one WARN. Returns the number of events + /// suppressed since the previous emission, or `None` while the interval + /// window is still closed (the event is counted, not logged). + pub(super) fn claim(&self, now_ms: u64) -> Option { + let last = self.last_warn_ms.load(Ordering::Relaxed); + let window_open = last == u64::MAX || now_ms.saturating_sub(last) >= IO_QUEUE_CONGESTION_WARN_INTERVAL_MS; + if window_open + && self + .last_warn_ms + .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + Some(self.suppressed.swap(0, Ordering::Relaxed)) + } else { + self.suppressed.fetch_add(1, Ordering::Relaxed); + None + } + } + + /// Monotonic milliseconds since the first call, for production callers. + pub(super) fn now_ms() -> u64 { + static ANCHOR: OnceLock = OnceLock::new(); + ANCHOR.get_or_init(std::time::Instant::now).elapsed().as_millis() as u64 + } +} + +pub(super) static IO_QUEUE_CONGESTION_WARN_THROTTLE: IoQueueCongestionWarnThrottle = IoQueueCongestionWarnThrottle::new(); + +pub(super) async fn track_object_read_setup(health: Option<&ObjectTrafficHealth>, future: F) -> F::Output +where + F: std::future::Future, +{ + let _progress = health.and_then(ObjectTrafficHealth::track_read_storage); + future.await +} + +impl DefaultObjectUsecase { + /// Headers a proxied read forwards verbatim to the replication target: + /// only the client's SSE-C key family, so the target performs the real + /// SSE-C decryption (never the replication-check exemption). HTTP + /// conditional headers (If-Match & co.) are deliberately NOT forwarded — + /// MinIO does not forward them either, and a remote 304/412 would leak a + /// conditional evaluation against a replica the local site never saw. + /// Range and part-number travel as typed SDK parameters instead. + pub(super) fn proxy_read_passthrough_headers(headers: &HeaderMap) -> HeaderMap { + const FORWARDED: &[&str] = &[ + "x-amz-server-side-encryption-customer-algorithm", + "x-amz-server-side-encryption-customer-key", + "x-amz-server-side-encryption-customer-key-md5", + ]; + let mut forwarded = HeaderMap::new(); + for name in FORWARDED { + if let Ok(header_name) = http::HeaderName::from_str(name) + && let Some(value) = headers.get(&header_name) + { + forwarded.insert(header_name, value.clone()); + } + } + forwarded + } + + /// True when a proxied SDK call failed because the target does not have + /// the object either (service-level not-found or a raw 404, which also + /// covers NoSuchVersion): the caller tries the next target silently. + pub(super) fn proxy_sdk_error_is_not_found(err: &aws_sdk_s3::error::SdkError) -> bool { + err.raw_response().is_some_and(|resp| resp.status().as_u16() == 404) + } +} + +/// Fail closed when deciding whether an object-lock-sensitive operation may +/// skip its existing-object lookup. +pub(crate) async fn object_lock_checks_required(bucket: &str) -> bool { + get_bucket_metadata(bucket) + .await + .map_or(true, |metadata| metadata.object_locking()) +} + +pub(super) fn object_lock_checks_required_for_state(state: &metadata_sys::ObjectLockConfigState) -> bool { + match state { + metadata_sys::ObjectLockConfigState::Configured { .. } | metadata_sys::ObjectLockConfigState::Fabricated => true, + metadata_sys::ObjectLockConfigState::ConfirmedAbsent => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderValue}; + use s3s::dto::{ + ObjectLockConfiguration, ObjectLockEnabled, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, + ServerSideEncryptionRule, + }; + use std::sync::Arc; + + #[test] + fn io_queue_congestion_warn_throttle_emits_once_per_interval() { + let throttle = IoQueueCongestionWarnThrottle::new(); + // The first congested request logs immediately. + assert_eq!(throttle.claim(0), Some(0)); + // Requests inside the window are counted, not logged. + assert_eq!(throttle.claim(1), None); + assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS - 1), None); + // The next emission reports how many stayed silent. + assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS), Some(2)); + assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS + 1), None); + } + + // classify_response_checksums is the single point that splits decrypted checksum + // pairs into the five s3s-typed fields and the additional-algorithm `extra` + // headers, replacing five copies of the loop. Lock its behaviour (#1252). + #[test] + fn classify_response_checksums_splits_typed_and_extra() { + // Typed algorithms fill named fields; nothing spills into extra. + let c = classify_response_checksums( + vec![ + ("CRC32".to_string(), "AAAAAA==".to_string()), + ("SHA256".to_string(), "c2hhMjU2".to_string()), + ("CRC64NVME".to_string(), "Zm9vYmFyCg==".to_string()), + ], + false, + ); + assert_eq!(c.crc32.as_deref(), Some("AAAAAA==")); + assert_eq!(c.sha256.as_deref(), Some("c2hhMjU2")); + assert_eq!(c.crc64nvme.as_deref(), Some("Zm9vYmFyCg==")); + assert!(c.extra.is_empty(), "typed algorithms must not land in extra"); + + // Additional algorithms land in extra keyed by their response-header name. + let c = classify_response_checksums( + vec![ + ("XXHASH3".to_string(), "eHhoMw==".to_string()), + ("XXHASH64".to_string(), "eHhoNjQ=".to_string()), + ("XXHASH128".to_string(), "eHhoMTI4".to_string()), + ("SHA512".to_string(), "c2hhNTEy".to_string()), + ("MD5".to_string(), "bWQ1".to_string()), + ], + false, + ); + assert!(c.crc32.is_none() && c.sha256.is_none() && c.crc64nvme.is_none()); + let names: Vec<&str> = c.extra.iter().map(|(n, _)| *n).collect(); + for expected in [ + "x-amz-checksum-xxhash3", + "x-amz-checksum-xxhash64", + "x-amz-checksum-xxhash128", + "x-amz-checksum-sha512", + "x-amz-checksum-md5", + ] { + assert!(names.contains(&expected), "extra missing {expected}: {names:?}"); + } + assert_eq!(c.extra.len(), 5); + + // The checksum-type marker is captured as the type, not mistaken for an algorithm. + let c = classify_response_checksums(vec![(AMZ_CHECKSUM_TYPE.to_string(), "COMPOSITE".to_string())], false); + assert!(c.checksum_type.is_some()); + assert!(c.extra.is_empty() && c.crc32.is_none()); + + let c = classify_response_checksums(vec![("CRC32".to_string(), "AAAAAA==-2".to_string())], true); + assert_eq!(c.checksum_type.as_ref().map(ChecksumType::as_str), Some("COMPOSITE")); + + // Empty input yields an all-default result. + let c = classify_response_checksums(Vec::<(String, String)>::new(), false); + assert!(c.crc32.is_none() && c.extra.is_empty() && c.checksum_type.is_none()); + } + + // additional_checksum_echo_pairs derives the PutObject/UploadPart response echo for + // additional algorithms from the server-computed checksum, and nothing for the + // five typed ones (those go through typed output fields). + #[test] + fn additional_checksum_echo_pairs_only_for_new_algorithms() { + // Typed algorithm → no echo pair. + let sha256 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, b"data"); + assert!(additional_checksum_echo_pairs(&sha256).is_empty()); + + // Additional algorithm → exactly one (header, value) pair matching the digest. + let xxh3 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::XXHASH3, b"data"); + let pairs = additional_checksum_echo_pairs(&xxh3); + assert_eq!(pairs.len(), 1); + assert_eq!(pairs[0].0, "x-amz-checksum-xxhash3"); + assert_eq!(pairs[0].1, xxh3.as_ref().unwrap().encoded); + + // MD5 additional checksum is echoed too. + let md5 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::MD5, b"data"); + let pairs = additional_checksum_echo_pairs(&md5); + assert_eq!(pairs.len(), 1); + assert_eq!(pairs[0].0, "x-amz-checksum-md5"); + + // None → empty. + assert!(additional_checksum_echo_pairs(&None).is_empty()); + } + + #[test] + fn inject_additional_checksum_headers_writes_all_pairs() { + let mut headers = HeaderMap::new(); + inject_additional_checksum_headers( + &mut headers, + &[ + ("x-amz-checksum-xxhash3", "eHhoMw==".to_string()), + ("x-amz-checksum-md5", "bWQ1".to_string()), + ], + ); + assert_eq!(headers.get("x-amz-checksum-xxhash3").unwrap(), "eHhoMw=="); + assert_eq!(headers.get("x-amz-checksum-md5").unwrap(), "bWQ1"); + // Empty input is a no-op. + let mut empty = HeaderMap::new(); + inject_additional_checksum_headers(&mut empty, &[]); + assert!(empty.is_empty()); + } + + #[test] + fn inject_accept_ranges_header_writes_static_bytes_value() { + let mut headers = HeaderMap::new(); + inject_accept_ranges_header(&mut headers); + + assert_eq!(headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES); + } + + #[test] + fn internal_object_info_lookup_opts_drops_http_preconditions() { + let version_id = Uuid::new_v4().to_string(); + let opts = ObjectOptions { + version_id: Some(version_id.clone()), + no_lock: true, + http_preconditions: Some(HTTPPreconditions { + if_none_match: Some("\"etag\"".to_string()), + if_match: Some("\"other\"".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let lookup_opts = internal_object_info_lookup_opts(opts); + + assert!(lookup_opts.http_preconditions.is_none()); + assert_eq!(lookup_opts.version_id.as_deref(), Some(version_id.as_str())); + assert!(lookup_opts.no_lock); + } + + fn bucket_sse_config_with(algorithm: &str, kms_key_id: Option<&str>) -> ServerSideEncryptionConfiguration { + ServerSideEncryptionConfiguration { + rules: vec![ServerSideEncryptionRule { + apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault { + sse_algorithm: ServerSideEncryption::from(String::from(algorithm)), + kms_master_key_id: kms_key_id.map(|id| SSEKMSKeyId::from(id.to_string())), + }), + bucket_key_enabled: None, + }], + } + } + + #[test] + fn resolve_bucket_default_sse_prefers_the_request_over_the_bucket_default() { + let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key")); + + let (sse, kms_key_id) = resolve_bucket_default_sse( + Some(&config), + Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + Some(SSEKMSKeyId::from("request-key".to_string())), + false, + ); + + assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); + assert_eq!(kms_key_id.as_deref(), Some("request-key")); + } + + #[test] + fn resolve_bucket_default_sse_fills_gaps_from_the_bucket_default() { + let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key")); + + let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false); + + assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AWS_KMS)); + assert_eq!(kms_key_id.as_deref(), Some("bucket-key")); + } + + #[test] + fn resolve_bucket_default_sse_falls_back_to_aes256_for_an_unknown_algorithm() { + // Reachable only through corrupt or hand-edited bucket metadata; + // PutBucketEncryption rejects unknown algorithms. All three call sites + // now share this single decision (backlog#1826). + let config = bucket_sse_config_with("garbage", None); + + let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false); + + assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); + assert!(kms_key_id.is_none()); + } + + #[test] + fn resolve_bucket_default_sse_suppresses_the_default_for_explicit_ssec() { + let config = bucket_sse_config_with(ServerSideEncryption::AES256, Some("bucket-key")); + + let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, true); + + assert!(sse.is_none(), "an SSE-C destination must not also get managed encryption"); + assert!(kms_key_id.is_none()); + } + + #[test] + fn resolve_bucket_default_sse_returns_nothing_without_a_bucket_default() { + let (sse, kms_key_id) = resolve_bucket_default_sse(None, None, None, false); + + assert!(sse.is_none()); + assert!(kms_key_id.is_none()); + } + + #[test] + fn build_put_like_object_lock_metadata_rejects_mode_without_retain_until_date() { + let err = build_put_like_object_lock_metadata( + "test-bucket", + &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, + None, + Some(ObjectLockMode::from_static(ObjectLockMode::GOVERNANCE)), + None, + ) + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); + } + + #[test] + fn object_lock_checks_required_reuses_authoritative_state() { + assert!(!object_lock_checks_required_for_state( + &metadata_sys::ObjectLockConfigState::ConfirmedAbsent + )); + + let configured = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + assert!(object_lock_checks_required_for_state(&configured)); + assert!(object_lock_checks_required_for_state(&metadata_sys::ObjectLockConfigState::Fabricated)); + } + + #[test] + fn build_put_like_object_lock_metadata_rejects_retain_until_date_without_mode() { + let retain_until = Timestamp::from(OffsetDateTime::now_utc().add(time::Duration::days(1))); + let err = build_put_like_object_lock_metadata( + "test-bucket", + &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, + None, + None, + Some(retain_until), + ) + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); + } + + const NO_BUCKET_LOCK: metadata_sys::ObjectLockConfigState = metadata_sys::ObjectLockConfigState::ConfirmedAbsent; + + fn bucket_default_retention_state(mode: &'static str) -> metadata_sys::ObjectLockConfigState { + metadata_sys::ObjectLockConfigState::Configured { + config: s3s::dto::ObjectLockConfiguration { + object_lock_enabled: Some(s3s::dto::ObjectLockEnabled::from_static(s3s::dto::ObjectLockEnabled::ENABLED)), + rule: Some(s3s::dto::ObjectLockRule { + default_retention: Some(s3s::dto::DefaultRetention { + mode: Some(ObjectLockRetentionMode::from_static(mode)), + days: Some(1), + years: None, + }), + }), + }, + updated_at: OffsetDateTime::now_utc(), + } + } + + fn object_info_with_lock_metadata(metadata: HashMap) -> ObjectInfo { + ObjectInfo { + user_defined: Arc::new(metadata), + mod_time: Some(OffsetDateTime::now_utc()), + ..Default::default() + } + } + + fn compliance_retained_object_info() -> ObjectInfo { + let mut metadata = HashMap::new(); + metadata.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()); + metadata.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()); + object_info_with_lock_metadata(metadata) + } + + fn legal_hold_object_info() -> ObjectInfo { + let mut metadata = HashMap::new(); + metadata.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), ObjectLockLegalHoldStatus::ON.to_string()); + object_info_with_lock_metadata(metadata) + } + + #[test] + fn validate_existing_object_lock_allows_versioned_new_version_with_compliance_retention() { + let opts = ObjectOptions { + versioned: true, + version_id: None, + ..Default::default() + }; + + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect("versioned put should create a new version"); + } + + #[test] + fn validate_existing_object_lock_allows_versioned_new_version_with_legal_hold() { + let opts = ObjectOptions { + versioned: true, + version_id: None, + ..Default::default() + }; + + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) + .expect("versioned put should create a new version"); + } + + #[test] + fn validate_existing_object_lock_blocks_unversioned_compliance_overwrite() { + let err = validate_existing_object_lock_for_write( + &NO_BUCKET_LOCK, + &compliance_retained_object_info(), + &ObjectOptions::default(), + ) + .expect_err("unversioned overwrite should still be blocked"); + + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + #[test] + fn validate_existing_object_lock_blocks_suspended_version_compliance_overwrite() { + let opts = ObjectOptions { + versioned: true, + version_suspended: true, + version_id: None, + ..Default::default() + }; + let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect_err("suspended versioning overwrite should still be blocked"); + + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + #[test] + fn validate_existing_object_lock_blocks_explicit_version_compliance_overwrite() { + let opts = ObjectOptions { + versioned: true, + version_id: Some(Uuid::new_v4().to_string()), + ..Default::default() + }; + let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect_err("explicit version overwrite should still be blocked"); + + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + /// The source's lock state governs the replica (rustfs/backlog#1953): + /// an authorized replication write carrying the locking category's source + /// timestamp may overwrite a locked version; the set layer's LWW then + /// decides per category. + #[test] + fn validate_existing_object_lock_allows_authorized_replication_overwrite() { + let opts = ObjectOptions { + versioned: true, + version_id: Some(Uuid::new_v4().to_string()), + replication_request: true, + replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + replication_legalhold_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + ..Default::default() + }; + + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect("replication write must bypass the destination COMPLIANCE lock"); + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) + .expect("replication write must bypass the destination legal hold"); + } + + /// Without the locking category's source timestamp the LWW merge cannot + /// judge it, so the write stays rejected instead of lifting the lock. + #[test] + fn validate_existing_object_lock_rejects_replication_overwrite_without_lock_timestamp() { + let opts = ObjectOptions { + versioned: true, + version_id: Some(Uuid::new_v4().to_string()), + replication_request: true, + replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + ..Default::default() + }; + + let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect_err("COMPLIANCE lock must hold without a retention source timestamp"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) + .expect_err("legal hold must hold without a legal-hold source timestamp"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + /// The bucket default retention locks a version without explicit + /// retention keys; the pre-check judges the same authoritative state as + /// the set-layer gate, so a tagging-only replication write is rejected + /// and one carrying the retention source timestamp passes to LWW. + #[test] + fn validate_existing_object_lock_judges_bucket_default_retention_for_replication_overwrite() { + let default_protected = object_info_with_lock_metadata(HashMap::new()); + let tagging_only = ObjectOptions { + versioned: true, + version_id: Some(Uuid::new_v4().to_string()), + replication_request: true, + replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + ..Default::default() + }; + let with_retention_decision = ObjectOptions { + replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + ..tagging_only.clone() + }; + + for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] { + let state = bucket_default_retention_state(mode); + let err = validate_existing_object_lock_for_write(&state, &default_protected, &tagging_only) + .expect_err("bucket default retention must hold without a retention source timestamp"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied, "{mode}"); + validate_existing_object_lock_for_write(&state, &default_protected, &with_retention_decision) + .expect("the retention source timestamp hands the default retention to LWW"); + } + + // Without a bucket default the same version is simply unlocked. + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &default_protected, &tagging_only) + .expect("no bucket default, no lock"); + } + + #[test] + fn aws_chunked_put_prefers_decoded_content_length() { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("aws-chunked")); + headers.insert(AMZ_DECODED_CONTENT_LENGTH, HeaderValue::from_static("71680")); + + let decoded = decoded_content_length_from_headers(&headers).expect("decoded content length should parse"); + assert!(request_uses_aws_chunked(&headers)); + assert_eq!(decoded, Some(71680)); + + let resolved = match (request_uses_aws_chunked(&headers), decoded, Some(99999)) { + (true, Some(decoded), _) => decoded, + (_, _, Some(c)) => c, + (_, Some(decoded), None) => decoded, + _ => unreachable!("test provides a valid size source"), + }; + + assert_eq!(resolved, 71680); + } + + #[test] + fn s3s_body_error_to_io_preserves_upload_stream_error_source() { + let error = s3s_body_error_to_io(Box::new(MockUploadStreamSha256Mismatch)); + + assert!(matches!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(MockUploadStreamSha256Mismatch) + )); + } + + #[test] + fn response_storage_class_reports_effective_layout_and_preserves_transition_tier() { + let metadata = HashMap::new(); + let standard_info = ObjectInfo { + storage_class: Some(storageclass::STANDARD.to_string()), + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + assert!(response_storage_class(&standard_info, &metadata).is_none()); + + let mut metadata = HashMap::new(); + metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD_IA.to_string()); + let label_only_info = ObjectInfo { + storage_class: Some(storageclass::STANDARD_IA.to_string()), + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + assert!( + response_storage_class(&label_only_info, &metadata).is_none(), + "historical STANDARD_IA labels must report the effective implicit STANDARD layout" + ); + + let rrs_info = ObjectInfo { + storage_class: Some(storageclass::RRS.to_string()), + ..Default::default() + }; + assert_eq!( + response_storage_class(&rrs_info, &HashMap::new()) + .as_ref() + .map(StorageClass::as_str), + Some(storageclass::RRS) + ); + + let mut transitioned_info = label_only_info; + transitioned_info.transitioned_object.tier = "WARM-TIER".to_string(); + assert!( + response_storage_class(&transitioned_info, &metadata).is_none(), + "a tier name without a completed transition must not override the effective local class" + ); + transitioned_info.transitioned_object.status = rustfs_filemeta::TRANSITION_COMPLETE.to_string(); + assert_eq!( + response_storage_class(&transitioned_info, &metadata) + .as_ref() + .map(StorageClass::as_str), + Some("WARM-TIER") + ); + + let mut metadata = HashMap::new(); + metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD.to_string()); + let standard_metadata_info = ObjectInfo { + storage_class: None, + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + assert!( + response_storage_class(&standard_metadata_info, &metadata).is_none(), + "STANDARD must be omitted even when it only arrives via metadata fallback" + ); + } + + #[test] + fn response_storage_class_for_object_attributes_defaults_to_standard_when_requested() { + let metadata = HashMap::new(); + let info = ObjectInfo { + storage_class: None, + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + + assert_eq!( + response_storage_class_for_object_attributes(&info, &metadata, true) + .as_ref() + .map(StorageClass::as_str), + Some(storageclass::STANDARD) + ); + + let legacy_info = ObjectInfo { + storage_class: Some(storageclass::STANDARD_IA.to_string()), + ..Default::default() + }; + assert_eq!( + response_storage_class_for_object_attributes(&legacy_info, &HashMap::new(), true) + .as_ref() + .map(StorageClass::as_str), + Some(storageclass::STANDARD) + ); + } + + #[test] + fn response_storage_class_for_object_attributes_skips_value_when_not_requested() { + let metadata = HashMap::new(); + let info = ObjectInfo { + storage_class: Some(storageclass::STANDARD_IA.to_string()), + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + + assert!( + response_storage_class_for_object_attributes(&info, &metadata, false).is_none(), + "StorageClass must only be returned when explicitly requested" + ); + } + + #[test] + fn expected_current_version_header_normalizes_uuid_and_null() { + let version = Uuid::new_v4(); + let mut headers = HeaderMap::new(); + headers.insert( + RUSTFS_EXPECTED_CURRENT_VERSION_ID, + HeaderValue::from_str(&version.to_string().to_uppercase()).unwrap(), + ); + assert_eq!(expected_current_version_id(&headers).unwrap(), Some(version.to_string())); + + headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_static(" null ")); + assert_eq!(expected_current_version_id(&headers).unwrap(), Some(Uuid::nil().to_string())); + } + + #[test] + fn expected_current_version_header_rejects_empty_and_malformed_values() { + for value in ["", "not-a-version"] { + let mut headers = HeaderMap::new(); + headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(value).unwrap()); + assert_eq!(expected_current_version_id(&headers).unwrap_err().code(), &S3ErrorCode::InvalidArgument); + } + } + + #[test] + fn build_put_object_expiration_header_returns_none_for_non_delete_events() { + let event = lifecycle::Event { + action: lifecycle::IlmAction::TransitionAction, + rule_id: "rule-1".to_string(), + due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: String::new(), + }; + + assert!(build_put_object_expiration_header(&event).is_none()); + } + + #[test] + fn build_put_object_expiration_header_formats_expected_value() { + let expire_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); + let event = lifecycle::Event { + action: lifecycle::IlmAction::DeleteAction, + rule_id: "rule-1".to_string(), + due: Some(expire_time), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: String::new(), + }; + + let expiry_date = expire_time.format(&Rfc3339).unwrap(); + let expected = format!("expiry-date=\"{}\", rule-id=\"rule-1\"", expiry_date); + assert_eq!(build_put_object_expiration_header(&event), Some(expected)); + } + + #[test] + fn build_put_object_expiration_header_requires_rule_id_and_due_time() { + let event = lifecycle::Event { + action: lifecycle::IlmAction::DeleteAction, + rule_id: String::new(), + due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: String::new(), + }; + + assert!(build_put_object_expiration_header(&event).is_none()); + + let event = lifecycle::Event { + action: lifecycle::IlmAction::DeleteAction, + rule_id: "rule-1".to_string(), + due: Some(OffsetDateTime::UNIX_EPOCH), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: String::new(), + }; + + assert!(build_put_object_expiration_header(&event).is_none()); + } + + // -- Range: u64 -> i64 lossless conversion (issue rustfs/backlog#1322) -- + + const I64_MAX_AS_U64: u64 = i64::MAX as u64; + + /// The conversion itself: s3s `Range` (u64) -> internal `HTTPRangeSpec` + /// (i64). This directly guards the suffix truncation fix. Reverting to + /// `length as i64` regresses the zero-suffix, `i64::MAX + 1` and `u64::MAX` + /// rows below. + #[test] + fn range_to_http_range_spec_is_lossless() { + // Zero-length suffix (`bytes=-0`) is unsatisfiable -> InvalidRange (416), + // never a 0-length 206. + let zero_suffix = range_to_http_range_spec(Range::Suffix { length: 0 }); + assert_eq!( + zero_suffix.as_ref().err().map(|e| e.code()), + Some(&S3ErrorCode::InvalidRange), + "bytes=-0 must map to InvalidRange (416)" + ); + + // Suffix conversions: positive `start` holds the suffix length; values + // above i64::MAX clamp to i64::MAX (they always cover the whole object). + let suffix_cases = [ + (1_u64, 1_i64), + (I64_MAX_AS_U64, i64::MAX), + (I64_MAX_AS_U64 + 1, i64::MAX), // was i64::MIN under `as i64` -> checked_neg overflow + (u64::MAX, i64::MAX), // was -1 under `as i64` -> read as "last 1 byte" + ]; + for (length, expected_start) in suffix_cases { + let spec = range_to_http_range_spec(Range::Suffix { length }) + .unwrap_or_else(|_| panic!("suffix {length} must convert losslessly")); + assert!(spec.is_suffix_length, "suffix {length} must stay a suffix spec"); + assert_eq!(spec.start, expected_start, "suffix {length} start"); + assert_eq!(spec.end, -1, "suffix {length} end"); + } + + // Int ranges: s3s already rejects first/last > i64::MAX, so the checked + // cast never truncates. first-last and open-ended must not regress. + let int_first_last = range_to_http_range_spec(Range::Int { + first: 10, + last: Some(20), + }) + .expect("first-last converts"); + assert!(!int_first_last.is_suffix_length); + assert_eq!((int_first_last.start, int_first_last.end), (10, 20)); + + let int_open = range_to_http_range_spec(Range::Int { first: 5, last: None }).expect("open-ended converts"); + assert_eq!((int_open.start, int_open.end), (5, -1)); + + let int_max = range_to_http_range_spec(Range::Int { + first: I64_MAX_AS_U64, + last: Some(I64_MAX_AS_U64), + }) + .expect("i64::MAX int converts"); + assert_eq!((int_max.start, int_max.end), (i64::MAX, i64::MAX)); + } + + /// Observable end-to-end effect the GET/HEAD handlers derive from a range + /// spec: `HTTPRangeSpec::get_offset_length` yields the (offset, length) + /// that becomes `Content-Length` and `Content-Range`, or an error that + /// surfaces as 416. Covers empty / 1-byte / normal objects. + #[test] + fn range_suffix_offset_length_matches_s3_semantics() { + // Expected outcome for a satisfiable range, or `None` for 416. + #[derive(Debug, PartialEq)] + enum Outcome { + /// (offset, content_length, content_range) + Partial(usize, i64, String), + Unsatisfiable, + } + + fn derive(range: Range, size: i64) -> Outcome { + let spec = match range_to_http_range_spec(range) { + Ok(spec) => spec, + Err(_) => return Outcome::Unsatisfiable, + }; + match spec.get_offset_length(size) { + Ok((offset, len)) => { + let content_range = format!("bytes {}-{}/{}", offset, offset as i64 + len - 1, size); + Outcome::Partial(offset, len, content_range) + } + Err(_) => Outcome::Unsatisfiable, + } + } + + let suffix = |length: u64| Range::Suffix { length }; + + // size, range, expected + let normal = 100_i64; + let cases = [ + // Zero suffix is always 416, whatever the size. + (0_i64, suffix(0), Outcome::Unsatisfiable), + (1, suffix(0), Outcome::Unsatisfiable), + (normal, suffix(0), Outcome::Unsatisfiable), + // Suffix within the object returns the trailing bytes. + (normal, suffix(1), Outcome::Partial(99, 1, "bytes 99-99/100".into())), + (normal, suffix(normal as u64), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + // Suffix >= size returns the whole object (never a truncated tail). + (normal, suffix(normal as u64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + (normal, suffix(I64_MAX_AS_U64), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + (normal, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + (normal, suffix(u64::MAX), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + // 1-byte object: any non-zero suffix returns that single byte. + (1, suffix(1), Outcome::Partial(0, 1, "bytes 0-0/1".into())), + (1, suffix(2), Outcome::Partial(0, 1, "bytes 0-0/1".into())), + (1, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 1, "bytes 0-0/1".into())), + (1, suffix(u64::MAX), Outcome::Partial(0, 1, "bytes 0-0/1".into())), + // Normal first-last and open-ended int ranges must not regress. + ( + normal, + Range::Int { + first: 10, + last: Some(19), + }, + Outcome::Partial(10, 10, "bytes 10-19/100".into()), + ), + ( + normal, + Range::Int { first: 90, last: None }, + Outcome::Partial(90, 10, "bytes 90-99/100".into()), + ), + ]; + + for (size, range, expected) in cases { + let got = derive(range, size); + assert_eq!(got, expected, "size={size} range={range:?}"); + } + } + + fn quota_result(allowed: bool) -> QuotaCheckResult { + QuotaCheckResult { + allowed, + current_usage: Some(1024), + quota_limit: Some(2048), + operation_size: 512, + remaining: Some(512), + uses_durable_reservations: true, + } + } + + #[test] + fn quota_admission_allows_within_limit() { + let result = map_quota_check_outcome("bucket", Ok(quota_result(true))).expect("an allowed result admits the write"); + + assert_eq!(result.current_usage, Some(1024)); + assert_eq!(result.quota_limit, Some(2048)); + assert_eq!(result.operation_size, 512); + assert_eq!(result.remaining, Some(512)); + } + + #[test] + fn quota_admission_rejects_over_limit() { + let err = map_quota_check_outcome("bucket", Ok(quota_result(false))).expect_err("an over-limit result rejects the write"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[test] + fn legacy_quota_admission_rejects_already_over_limit() { + let result = QuotaCheckResult { + allowed: true, + current_usage: Some(6), + quota_limit: Some(5), + operation_size: 0, + remaining: Some(0), + uses_durable_reservations: false, + }; + let mut opts = ObjectOptions::default(); + let err = + apply_quota_admission(&mut opts, &result).expect_err("legacy completion must not bypass an already exceeded quota"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[test] + fn quota_admission_fails_closed_on_checker_error() { + // A configured hard quota must never be bypassed by an internal fault: a checker error becomes a retryable ServiceUnavailable, not a silent allow. + let err = map_quota_check_outcome( + "bucket", + Err(QuotaError::InvalidConfig { + reason: "corrupt quota config".to_string(), + }), + ) + .expect_err("a checker fault must fail closed"); + assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable); + } + + #[test] + fn early_quota_filter_rejects_only_an_individually_impossible_object() { + let stale_full_usage = QuotaCheckResult { + allowed: true, + current_usage: Some(4096), + quota_limit: Some(4096), + operation_size: 0, + remaining: Some(0), + uses_durable_reservations: true, + }; + + ensure_object_size_within_quota(&stale_full_usage, 4096) + .expect("commit-time ledger must decide whether stale usage was reclaimed"); + let err = ensure_object_size_within_quota(&stale_full_usage, 4097) + .expect_err("an object larger than the whole quota can never fit"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[test] + fn quota_admission_fails_closed_on_unknown_authoritative_usage() { + let err = map_quota_check_outcome( + "bucket", + Err(QuotaError::UsageUnavailable { + bucket: "bucket".to_string(), + }), + ) + .expect_err("unknown authoritative usage must not admit a quota-controlled write"); + assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable); + } +} diff --git a/rustfs/src/app/object/test_support.rs b/rustfs/src/app/object/test_support.rs new file mode 100644 index 000000000..61b368cf6 --- /dev/null +++ b/rustfs/src/app/object/test_support.rs @@ -0,0 +1,98 @@ +// 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. + +//! Test-only scaffolding shared by the object use-case test modules. + +use super::*; +use http::{Extensions, HeaderMap, Method, Uri}; +use std::sync::Arc; + +#[derive(Debug)] +pub(super) struct MockUploadStreamSha256Mismatch; + +impl std::fmt::Display for MockUploadStreamSha256Mismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("UploadStreamError: Sha256Mismatch") + } +} + +impl std::error::Error for MockUploadStreamSha256Mismatch {} + +pub(super) fn build_request(input: T, method: Method) -> S3Request { + S3Request { + input, + method, + uri: Uri::from_static("/"), + headers: HeaderMap::new(), + extensions: Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } +} + +pub(super) async fn real_cold_fill_test_context() -> (Arc, Arc) { + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let ambient = current_app_context().expect("real cold-fill tests require an ambient AppContext"); + let context = temp_env::with_vars( + [ + (rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE, Some("true")), + (rustfs_config::ENV_OBJECT_DATA_CACHE_MODE, Some("fill_materialize_enabled")), + (rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_BYTES, Some("8388608")), + (rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_ENTRY_BYTES, Some("2097152")), + (rustfs_config::ENV_OBJECT_DATA_CACHE_MIN_FREE_MEMORY_PERCENT, Some("0")), + ], + || Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())), + ); + assert!(context.object_data_cache().materialize_fill_enabled()); + (store, context) +} + +pub(super) async fn put_real_cold_fill_object(store: &Arc, bucket: &str, object: &str, body: &[u8]) -> ObjectInfo { + let mut reader = PutObjReader::from_vec(body.to_vec()); + store + .put_object(bucket, object, &mut reader, &ObjectOptions::default()) + .await + .expect("real cold-fill test object must be written") +} + +pub(super) fn real_cold_fill_plan( + adapter: &ObjectDataCacheAdapter, + bucket: &str, + object: &str, + info: &ObjectInfo, +) -> rustfs_object_data_cache::ObjectDataCacheGetPlan { + let length = info + .get_actual_size() + .expect("real cold-fill test metadata must expose plaintext size"); + let GetObjectBodyCachePlan::Cacheable(plan) = build_get_object_body_cache_plan( + adapter, + GetObjectBodyCacheRequest { + bucket, + key: object, + info, + response_content_length: length, + has_range: false, + part_number: None, + encryption_applied: false, + }, + ) else { + panic!("real cold-fill test object must be cacheable"); + }; + plan +} diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 79c379651..80857ef7d 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -13,19695 +13,9 @@ // limitations under the License. //! Object application use-case contracts. +//! +//! The implementation lives in [`crate::app::object`], split per S3 operation +//! (backlog#1841). This module stays as a thin re-export so existing +//! `crate::app::object_usecase::*` paths keep working. -// Performance metrics recording (with zero-copy-metrics integration) -use rustfs_io_metrics::buffered_write; - -use crate::storage_api::table::get_bucket_metadata; - -use super::storage_api::object_usecase::access::{ - PostObjectRequestMarker, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_request, - has_bypass_governance_header, load_bucket_generation_from_store, recursive_force_delete_is_authorized, - replication_request_authorized, req_info_mut, req_info_ref, -}; -#[cfg(test)] -use super::storage_api::object_usecase::bucket::quota::BucketQuota; -use super::storage_api::object_usecase::bucket::quota::checker::QuotaChecker; -#[cfg(test)] -use super::storage_api::object_usecase::bucket::replication::{ReplicationState, replication_statuses_map}; -use super::storage_api::object_usecase::bucket::{ - VersioningConfigExt as _, - lifecycle::{ - bucket_lifecycle_audit::LcEventSrc, - bucket_lifecycle_ops::{enqueue_transition_immediate, post_restore_opts}, - lifecycle::{self, TransitionOptions}, - }, - metadata_sys, - object_lock::{ - objectlock::{get_object_legalhold_meta, get_object_retention_meta}, - objectlock_sys::{check_object_lock_for_deletion, is_retention_active, replication_write_may_pass_worm_gate}, - types::RetentionMode, - }, - predict_lifecycle_expiration, - quota::{QuotaCheckResult, QuotaError, QuotaOperation}, - replication::{ - DeleteReplicationConfigSnapshot, REPLICATE_INCOMING_DELETE, ReplicationStatusType, commit_force_delete_intent, - delete_replication_state_from_config, delete_replication_version_id, deleted_object_has_pending_replication_delete, - force_delete_target_set, get_read_proxy_targets, has_active_delete_rule, load_delete_config_snapshot, - must_replicate_object, persist_force_delete_intent, record_replication_proxy, schedule_object_replication, - schedule_replication_delete, schedule_replication_deletes, set_deleted_object_replication_state, - should_schedule_delete_replication, should_use_existing_delete_replication_info, - }, - tagging::decode_tags, - validate_restore_request, - versioning_sys::BucketVersioningSys, -}; -use super::storage_api::object_usecase::compression::{MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible}; -use super::storage_api::object_usecase::concurrency::{ - self, ConcurrencyManager, DiskReadAdmission, GetObjectGuard, PutObjectAdmission, PutObjectGuard, - get_concurrency_aware_buffer_size, get_concurrency_manager, get_put_concurrency_aware_buffer_size, -}; -#[cfg(test)] -use super::storage_api::object_usecase::contract::http::HTTPPreconditions; -use super::storage_api::object_usecase::contract::namespace::NamespaceLocking; -use super::storage_api::object_usecase::contract::object::{ObjectIO as _, ObjectOperations as _}; -use super::storage_api::object_usecase::contract::range::HTTPRangeSpec; -use super::storage_api::object_usecase::data_usage::{ - quota_object_size, record_bucket_delete_marker_memory, record_bucket_object_delete_memory, - record_bucket_object_version_write_memory, record_bucket_object_write_memory, - record_bucket_object_write_unknown_previous_memory, -}; -use super::storage_api::object_usecase::deadlock_detector; -use super::storage_api::object_usecase::ecfs::FS; -use super::storage_api::object_usecase::error::{ - Error as EcstoreError, StorageError, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found, -}; -use super::storage_api::object_usecase::head_prefix::{head_prefix_not_found_message, probe_prefix_has_children}; -use super::storage_api::object_usecase::helper::{OperationHelper, build_event_resp_elements, spawn_background_with_context}; -use super::storage_api::object_usecase::io::{DynReader, HashReader, WritePlan, compression_metadata_value, wrap_reader}; -#[cfg(test)] -use super::storage_api::object_usecase::object_cache::GetObjectBodySource; -#[cfg(test)] -use super::storage_api::object_usecase::object_cache::lookup_get_object_body_cache_hook; -use super::storage_api::object_usecase::object_cache::{GetObjectBodyCacheHookLookup, get_object_body_cache_plaintext_len}; -use super::storage_api::object_usecase::object_utils::to_s3s_etag; -use super::storage_api::object_usecase::options::{ - copy_dst_opts_with_replication_authorization, copy_src_opts, del_opts_with_versioning, extract_metadata, - extract_metadata_from_mime_with_object_name, filter_object_metadata, get_content_sha256_with_query, get_opts, - has_replication_retention_update, namespace_reserved_user_metadata, normalize_content_encoding_for_storage, - preserve_unclassified_user_metadata, put_opts_with_replication_authorization, validate_archive_content_encoding, -}; -use super::storage_api::object_usecase::request_context::{self, spawn_traced, spawn_traced_join}; -use super::storage_api::object_usecase::s3_api::multipart::parse_list_parts_params; -use super::storage_api::object_usecase::set_disk::{ - get_lock_acquire_timeout, get_object_disk_read_timeout, is_valid_storage_class, -}; -use super::storage_api::object_usecase::sse::{ - DecryptionRequest, EncryptionRequest, SseKmsPrincipal, apply_bucket_default_lock_retention, authorize_sse_kms_object_read, - bucket_default_write_sse, build_ssec_read_headers, classify_sse_read_response, encryption_material_to_metadata, - extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers, - get_buffer_size_opt_in, load_bucket_object_lock_config_state, map_get_object_reader_error, sse_encryption, - validate_bucket_object_lock_enabled_state, -}; -use super::storage_api::object_usecase::storage_class as storageclass; -use super::storage_api::object_usecase::timeout_wrapper::{GetObjectTimeoutPolicy, RequestTimeoutWrapper}; -use super::storage_api::object_usecase::{ECStore, OldCurrentSize}; -use super::storage_api::object_usecase::{ - RFC1123, check_preconditions, parse_object_lock_legal_hold, parse_object_lock_retention, parse_part_number_i32_to_usize, - remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata, validate_bucket_exists, validate_object_key, - validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read, wrap_response_with_cors, -}; -use crate::app::runtime_sources::{ - AppContext, current_app_context, current_notify_interface_for_context, current_object_data_cache_for_context, - current_object_store_handle_for_context, -}; -use crate::config::RustFSBufferConfig; -use crate::delete_tail_activity::{DeleteTailActivityGuard, DeleteTailStage}; -use crate::error::ApiError; -use crate::shared_types::convert_ecstore_object_info; -use crate::table_catalog; -use bytes::{BufMut as _, Bytes, BytesMut}; -use futures::{Stream, StreamExt, TryStreamExt}; -use http::{HeaderMap, HeaderValue, StatusCode}; -use md5::{Digest as Md5Digest, Md5}; -use metrics::{counter, histogram}; -use pin_project_lite::pin_project; -use rustfs_audit::ObjectVersion as AuditObjectVersion; -use rustfs_concurrency::GetObjectQueueSnapshot; -use rustfs_config::MI_B; -use rustfs_filemeta::{NULL_VERSION_ID, RestoreStatusOps, parse_restore_obj_status}; -use rustfs_io_core::{BytesPool, PooledBuffer}; -use rustfs_io_metrics; -use rustfs_lock::NamespaceLockGuard; -use rustfs_notify::EventArgsBuilder; -use rustfs_object_capacity::capacity_manager::get_capacity_manager; -use rustfs_policy::policy::action::{Action, S3Action}; -use rustfs_s3_ops::{S3Operation, delete_event_name_for_marker, put_event_name_for_post_object}; -use rustfs_targets::{EventName, get_request_host, get_request_port, get_request_user_agent}; -use rustfs_utils::CompressionAlgorithm; -#[cfg(test)] -use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER}; -#[cfg(test)] -use rustfs_utils::http::insert_header; -use rustfs_utils::http::{ - AMZ_BUCKET_REPLICATION_STATUS, AMZ_CHECKSUM_MODE, AMZ_CHECKSUM_TYPE, AMZ_WEBSITE_REDIRECT_LOCATION, CONTENT_TYPE, - SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICA_STATUS, SUFFIX_REPLICA_TIMESTAMP, - SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_RESTORE_OPERATION_ID, SUFFIX_SOURCE_REPLICATION_CHECK, - SUFFIX_SOURCE_REPLICATION_REQUEST, get_header, - headers::{ - AMZ_CONTENT_SHA256, AMZ_DECODED_CONTENT_LENGTH, AMZ_MINIO_SNOWBALL_IGNORE_DIRS, AMZ_MINIO_SNOWBALL_IGNORE_ERRORS, - AMZ_MINIO_SNOWBALL_PREFIX, AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE, - AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, - AMZ_OBJECT_TAGGING, AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE, AMZ_RUSTFS_SNOWBALL_IGNORE_DIRS, - AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, AMZ_RUSTFS_SNOWBALL_PREFIX, AMZ_SERVER_SIDE_ENCRYPTION, - AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, AMZ_SNOWBALL_EXTRACT, - AMZ_SNOWBALL_IGNORE_DIRS, AMZ_SNOWBALL_IGNORE_ERRORS, AMZ_SNOWBALL_PREFIX, AMZ_STORAGE_CLASS, AMZ_TAG_COUNT, - }, - insert_str, project_ssec_transport_headers, remove_str, -}; -use rustfs_utils::path::{encode_dir_object, is_dir_object, path_join_buf}; -use rustfs_utils::retry::{DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, RetryTimer}; -use rustfs_zip::{ArchiveLimits, CompressionFormat}; -use s3s::StdError; -use s3s::dto::{ - CacheControl, Checksum, ChecksumAlgorithm, ChecksumType, ContentDisposition, ContentEncoding, ContentLanguage, ContentType, - CopyObjectInput, CopyObjectOutput, CopyObjectResult, CopySource, DeleteObjectInput, DeleteObjectOutput, DeleteObjectsInput, - DeleteObjectsOutput, DeletedObject, ETag, GetObjectAttributesInput, GetObjectAttributesOutput, GetObjectAttributesParts, - GetObjectInput, GetObjectOutput, HeadObjectInput, HeadObjectOutput, MetadataDirective, ObjectAttributes, ObjectLockLegalHold, - ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, ObjectPart, PutObjectInput, - PutObjectOutput, Range, RequestCharged, RestoreObjectInput, RestoreObjectOutput, RestoreStatus, SSECustomerAlgorithm, - SSECustomerKeyMD5, SSEKMSKeyId, SelectObjectContentInput, SelectObjectContentOutput, ServerSideEncryption, - ServerSideEncryptionConfiguration, StorageClass, StreamingBlob, TaggingDirective, TaggingHeader, Timestamp, TimestampFormat, - WebsiteRedirectLocation, -}; -use s3s::header::{X_AMZ_RESTORE, X_AMZ_RESTORE_OUTPUT_PATH}; -use s3s::stream::{ByteStream, DynByteStream, RemainingLength}; -use s3s::{S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; - -const DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES: i64 = 32 * 1024 * 1024; -const RUSTFS_EXPECTED_CURRENT_VERSION_ID: &str = "x-rustfs-expected-current-version-id"; -const ENV_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: &str = "RUSTFS_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES"; -const DEFAULT_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: usize = 16 * 1024 * 1024; -const PUT_EAGER_STATUS_ELIGIBLE: &str = "eligible"; -const PUT_EAGER_STATUS_EXTRACT: &str = "extract"; -const PUT_EAGER_STATUS_COMPRESSED: &str = "compressed"; -const PUT_EAGER_STATUS_ENCRYPTED: &str = "encrypted"; -const PUT_EAGER_STATUS_INVALID_SIZE: &str = "invalid_size"; -const PUT_EAGER_STATUS_ABOVE_EAGER_MAX: &str = "above_eager_max"; -const PUT_EAGER_STATUS_ZERO_COPY_INELIGIBLE: &str = "zero_copy_ineligible"; -const PUT_EAGER_STATUS_AWS_CHUNKED_MISSING_DECODED_LENGTH: &str = "aws_chunked_missing_decoded_length"; -static CACHED_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: std::sync::OnceLock = std::sync::OnceLock::new(); -use std::collections::HashMap; -use std::io; -use std::ops::Add; -use std::path::Path; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use std::str::FromStr; -#[cfg(test)] -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{Duration, Instant}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use tokio::io::{AsyncRead, ReadBuf}; -use tokio::sync::{OwnedSemaphorePermit, RwLock}; -use tokio_tar::Archive; -#[cfg(test)] -use tokio_util::io::ReaderStream; -use tokio_util::io::{StreamReader, poll_read_buf}; -use tracing::{debug, error, instrument, warn}; -use uuid::Uuid; - -use super::storage_api::object_usecase::{ - BUCKET_LIFECYCLE_LOCK_OBJECT, GetObjectReader, StorageDeletedObject, StorageObjectInfo as ObjectInfo, - StorageObjectLockDeleteOptions, StorageObjectOptions as ObjectOptions, StorageObjectToDelete as ObjectToDelete, - StoragePutObjReader as PutObjReader, -}; -use crate::app::object_data_cache::{ - ColdFillCoordinateOutcome, ColdFillDiskPermitOwner, ColdFillError, ColdFillProducer, GetObjectBodyCacheLookup, - GetObjectBodyCachePlan, GetObjectBodyCacheRequest, ObjectDataCacheAdapter, build_get_object_body_cache_plan, - build_get_object_body_cache_plan_for_revalidation, coordinate_cold_fill, current_cold_fill_disk_permit_owner, - fill_get_object_body_cache_from_buffered_body, fill_get_object_body_cache_from_materialized_body, - invalidate_object_data_cache_after_copy_success, invalidate_object_data_cache_after_delete_success, - invalidate_object_data_cache_after_put_success, invalidate_object_data_cache_before_mutation, - invalidate_object_data_cache_objects_after_delete_success, invalidate_object_data_cache_objects_before_mutation, - invalidate_object_data_cache_prefix_after_delete, invalidate_object_data_cache_prefix_before_mutation, - lookup_get_object_body_cache_hit, lookup_preplanned_get_object_body_cache_hook, -}; -#[cfg(test)] -use crate::app::object_data_cache::{ColdFillRole, ColdFillWaitOutcome, scope_cold_fill_disk_permit_owner_for_test}; -use crate::app::object_traffic_health::ObjectTrafficHealth; - -type S3StdError = Box; - -pub(crate) fn s3s_body_error_to_io(err: StdError) -> io::Error { - io::Error::other(err) -} - -struct ColdFillDiskPermitMetric { - owner: ColdFillDiskPermitOwner, - metric_recorded: bool, -} - -#[cfg(test)] -static COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST: AtomicU64 = AtomicU64::new(0); - -#[cfg(test)] -struct ColdFillPublicationBarrier { - reached: tokio::sync::Semaphore, - release: tokio::sync::Semaphore, -} - -#[cfg(test)] -type ColdFillPublicationBarrierState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc)>; - -#[cfg(test)] -static COLD_FILL_PUBLICATION_BARRIER: OnceLock> = OnceLock::new(); - -#[cfg(test)] -type ColdFillReaderOpenProbeState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc)>; - -#[cfg(test)] -static COLD_FILL_READER_OPEN_PROBE: OnceLock> = OnceLock::new(); - -fn adjust_cold_fill_disk_permit_metric(owner: ColdFillDiskPermitOwner, acquired: bool) { - macro_rules! adjust_gauge { - ($name:literal) => {{ - #[cfg(not(test))] - let gauge = { - static HANDLE: std::sync::LazyLock = std::sync::LazyLock::new(|| metrics::gauge!($name)); - &*HANDLE - }; - #[cfg(test)] - let gauge = metrics::gauge!($name); - if acquired { - gauge.increment(1.0); - } else { - gauge.decrement(1.0); - } - }}; - } - - match owner { - ColdFillDiskPermitOwner::Producer => { - adjust_gauge!("rustfs_object_data_cache_cold_fill_producer_disk_permits"); - } - ColdFillDiskPermitOwner::Follower => { - adjust_gauge!("rustfs_object_data_cache_cold_fill_follower_disk_permits"); - } - } -} - -#[cfg(test)] -async fn wait_cold_fill_publication_barrier(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) { - let Some(key) = plan.key() else { - return; - }; - let barrier = COLD_FILL_PUBLICATION_BARRIER - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .filter(|(barrier_key, _)| barrier_key == key) - .map(|(_, barrier)| Arc::clone(barrier)); - if let Some(barrier) = barrier { - barrier.reached.add_permits(1); - if let Ok(permit) = barrier.release.acquire().await { - permit.forget(); - } - } -} - -#[cfg(test)] -fn record_cold_fill_reader_open_for_test(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) { - let Some(key) = plan.key() else { - return; - }; - let probe = COLD_FILL_READER_OPEN_PROBE - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .filter(|(probe_key, _)| probe_key == key) - .map(|(_, count)| Arc::clone(count)); - if let Some(count) = probe { - count.fetch_add(1, Ordering::Relaxed); - } -} - -impl ColdFillDiskPermitMetric { - fn new(owner: ColdFillDiskPermitOwner) -> Self { - let metric_recorded = rustfs_io_metrics::metrics_enabled(); - if metric_recorded { - adjust_cold_fill_disk_permit_metric(owner, true); - } - #[cfg(test)] - if matches!(owner, ColdFillDiskPermitOwner::Follower) { - COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_add(1, Ordering::Relaxed); - } - Self { owner, metric_recorded } - } -} - -impl Drop for ColdFillDiskPermitMetric { - fn drop(&mut self) { - if self.metric_recorded { - adjust_cold_fill_disk_permit_metric(self.owner, false); - } - #[cfg(test)] - if matches!(self.owner, ColdFillDiskPermitOwner::Follower) { - COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_sub(1, Ordering::Relaxed); - } - } -} - -struct GetObjectDiskPermit { - permit: Option, - metric: Option, -} - -impl GetObjectDiskPermit { - fn new(permit: OwnedSemaphorePermit) -> Self { - Self { - permit: Some(permit), - metric: current_cold_fill_disk_permit_owner().map(ColdFillDiskPermitMetric::new), - } - } - - fn release(&mut self) { - self.permit.take(); - self.metric.take(); - } -} - -impl From for GetObjectDiskPermit { - fn from(permit: OwnedSemaphorePermit) -> Self { - Self::new(permit) - } -} - -impl Drop for GetObjectDiskPermit { - fn drop(&mut self) { - self.release(); - } -} - -const ACCEPT_RANGES_BYTES: &str = "bytes"; -const COLD_FILL_HARD_MAX_DURATION: Duration = Duration::from_secs(10 * 60); -pub(crate) const MAX_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 64 * 1024 * 1024; -const MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 8 * 1024 * 1024; -const HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 4 * 1024 * 1024; -const VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 1024 * 1024; -const LOG_COMPONENT_APP: &str = "app"; -const LOG_SUBSYSTEM_OBJECT: &str = "object"; -const EVENT_PUT_OBJECT_STORE_INFLIGHT_SLOW: &str = "put_object_store_inflight_slow"; -const EVENT_PUT_OBJECT_STORE_RETURNED: &str = "put_object_store_returned"; -const EVENT_PUT_OBJECT_COMMIT_OWNER_DEADLINE: &str = "put_object_commit_owner_deadline"; -const EVENT_GET_OBJECT_STREAM_BODY: &str = "get_object_stream_body"; -const EVENT_PUT_OBJECT_BODY_READ_STALLED: &str = "put_object_body_read_stalled"; -const GET_OBJECT_STAGE_PATH_S3_HANDLER: &str = "s3_handler"; -const GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT: &str = "request_ingress_to_context"; -const GET_OBJECT_STAGE_OUTPUT_STRATEGY: &str = "output_strategy"; -const GET_OBJECT_STAGE_BODY_BUILD: &str = "body_build"; -const GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ: &str = "body_encrypted_buffer_read"; -const GET_OBJECT_STAGE_BODY_MEMORY_BLOB: &str = "body_memory_blob"; -const GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ: &str = "body_seek_buffer_read"; -const GET_OBJECT_STAGE_BODY_STREAM_STRATEGY: &str = "body_stream_strategy"; -const GET_OBJECT_STAGE_BODY_STREAMING_BLOB: &str = "body_streaming_blob"; -const GET_OBJECT_STAGE_CHECKSUM_HEADERS: &str = "checksum_headers"; -const GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION: &str = "lifecycle_expiration"; -const GET_OBJECT_STAGE_METADATA_FILTER: &str = "metadata_filter"; -const PUT_OBJECT_STORE_WARN_THRESHOLD: Duration = Duration::from_secs(5); -// Eager PUT bodies are fully materialized before the storage owner starts. On -// request cancellation, keep the commit/publication tail alive briefly, then -// request pre-commit rollback and await cleanup so its write-health guard is -// reaped without abandoning staged shards. -const EAGER_PUT_COMMIT_CANCELLATION_GRACE: Duration = - Duration::from_secs(rustfs_config::DEFAULT_DRIVE_MAX_TIMEOUT_DURATION_SECS * 4); -const GET_OBJECT_STREAM_WARN_THRESHOLD: Duration = Duration::from_secs(5); -static GET_OBJECT_BUFFER_THRESHOLD_WARNED: AtomicBool = AtomicBool::new(false); - -fn record_get_object_s3_handler_stage_duration(stage: &'static str, start: Option) { - if let Some(start) = start { - rustfs_io_metrics::record_get_object_stage_duration( - GET_OBJECT_STAGE_PATH_S3_HANDLER, - stage, - start.elapsed().as_secs_f64(), - ); - } -} - -fn decoded_content_length_from_headers(headers: &HeaderMap) -> S3Result> { - let Some(val) = headers.get(AMZ_DECODED_CONTENT_LENGTH) else { - return Ok(None); - }; - - match atoi::atoi::(val.as_bytes()) { - Some(x) => Ok(Some(x)), - None => Err(s3_error!(UnexpectedContent)), - } -} - -/// Losslessly convert an s3s [`Range`] into the internal [`HTTPRangeSpec`]. -/// -/// Shared by GET and HEAD so both apply identical range semantics. s3s parses -/// `first`/`last` as `u64`, but its own parser already rejects any value greater -/// than `i64::MAX`, so the int branch is a checked cast that never truncates. -/// -/// The suffix length, however, is an unchecked `u64`. A naive `length as i64` -/// truncates deterministically: `bytes=-18446744073709551615` wraps to `-1` and -/// is then read as "last 1 byte", and `bytes=-0` yields a 0-length 206 instead -/// of a 416. This function instead mirrors s3s [`Range::check`] semantics: -/// * a zero-length suffix is rejected with `InvalidRange` (416), matching AWS -/// S3 and MinIO; -/// * a suffix larger than `i64::MAX` is clamped to `i64::MAX`. Object sizes in -/// this system are bounded by `i64::MAX`, so such a suffix always covers the -/// whole object, and [`HTTPRangeSpec::get_length`] clamps it to the real -/// size once the object is known. -fn range_to_http_range_spec(range: Range) -> S3Result { - match range { - Range::Int { first, last } => { - let start = i64::try_from(first).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))?; - let end = match last { - Some(last) => { - i64::try_from(last).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))? - } - None => -1, - }; - Ok(HTTPRangeSpec { - is_suffix_length: false, - start, - end, - }) - } - Range::Suffix { length } => { - if length == 0 { - return Err(s3_error!(InvalidRange, "The requested range is not satisfiable")); - } - // Clamp to i64::MAX: any suffix >= object size returns the whole - // object, and object sizes never exceed i64::MAX. - let start = i64::try_from(length).unwrap_or(i64::MAX); - Ok(HTTPRangeSpec { - is_suffix_length: true, - start, - end: -1, - }) - } - } -} - -/// Resolve the authoritative object length that bucket-quota admission (and downstream sizing) must use. -/// -/// `Content-Encoding: aws-chunked` alone only *declares* the encoding; whether the body actually arrived chunk-framed is signalled by a `STREAMING-*` `x-amz-content-sha256`, and the S3 auth layer both requires `x-amz-decoded-content-length` for those requests and hands the body down already de-framed. So when a decoded length is present it is authoritative (the wire `Content-Length` counts chunk framing and would overcount); a framed body without a decoded length is rejected rather than falling back to the framed wire length. A declared-only aws-chunked request (issue #1857 clients) carries an unframed body, so its wire `Content-Length` is the authoritative size, exactly as for a plain PUT. A negative or otherwise unknown length is rejected so it can never be reinterpreted as an enormous unsigned size downstream. -fn resolve_put_object_authoritative_size(headers: &HeaderMap, content_length: Option) -> S3Result { - let decoded_content_length = decoded_content_length_from_headers(headers)?; - let aws_chunked = request_uses_aws_chunked(headers) || request_body_is_aws_chunked_framed(headers); - let size = match (aws_chunked, decoded_content_length, content_length) { - (true, Some(decoded), _) => decoded, - // Declared aws-chunked without a streaming payload: the body is not framed (the auth - // layer only de-frames STREAMING-* payloads, which always carry a decoded length), so - // the wire Content-Length is the real object size. - (true, None, Some(raw)) if !request_body_is_aws_chunked_framed(headers) => raw, - (true, None, _) => return Err(s3_error!(UnexpectedContent)), - (false, _, Some(raw)) => raw, - (false, Some(decoded), None) => decoded, - (false, None, None) => return Err(s3_error!(UnexpectedContent)), - }; - - if size < 0 { - return Err(s3_error!(UnexpectedContent)); - } - - Ok(size) -} - -/// True when the request body actually arrived chunk-framed on the wire, i.e. the payload was -/// signed as a SigV4 streaming upload (`x-amz-content-sha256: STREAMING-*`). This is the only -/// case in which the auth layer de-frames the body; `Content-Encoding: aws-chunked` without a -/// streaming payload is just a declared encoding over an unframed body. -fn request_body_is_aws_chunked_framed(headers: &HeaderMap) -> bool { - headers - .get(AMZ_CONTENT_SHA256) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.len() >= 10 && value[..10].eq_ignore_ascii_case("STREAMING-")) -} - -/// Map a bucket-quota checker outcome onto the S3 admission result. -/// -/// Hard is the only supported quota type, so a checker fault (bucket-config read, config parse, or usage lookup) must fail closed rather than admit the write: allowing it would silently bypass a configured hard quota. The no-quota happy path never reaches the error arm — `QuotaChecker::check_quota` returns `Ok(allowed)` via the zero-extra-I/O fast path when no quota is configured, so failing closed here cannot penalise buckets without a quota. A fault surfaces as a retryable `ServiceUnavailable` and is counted; the client-facing message stays generic so internal config/usage details are not leaked. -pub(super) fn map_quota_check_outcome(bucket: &str, outcome: Result) -> S3Result { - match outcome { - Ok(result) if !result.allowed => Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!( - "Bucket quota exceeded. Current usage: {} bytes, limit: {} bytes", - result.current_usage.unwrap_or(0), - result.quota_limit.unwrap_or(0) - ), - )), - Err(e) => { - counter!("rustfs_bucket_quota_check_failed_total").increment(1); - if matches!(&e, QuotaError::UsageUnavailable { .. }) { - debug!(bucket, error = %e, state = "usage_pending", "Bucket quota check waiting for authoritative usage"); - } else { - warn!(bucket, error = %e, state = "checker_failed", "Bucket quota check failed closed"); - } - Err(S3Error::with_message( - S3ErrorCode::ServiceUnavailable, - "Bucket quota check temporarily unavailable, please retry".to_string(), - )) - } - Ok(result) => Ok(result), - } -} - -pub(super) fn apply_quota_admission(opts: &mut ObjectOptions, result: &QuotaCheckResult) -> S3Result<()> { - if result.uses_durable_reservations { - return Ok(()); - } - let Some(quota_limit) = result.quota_limit else { - return Ok(()); - }; - let Some(current_usage) = result.current_usage else { - return Err(S3Error::with_message( - S3ErrorCode::ServiceUnavailable, - "Bucket quota check temporarily unavailable, please retry".to_string(), - )); - }; - if current_usage > quota_limit { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), - )); - } - let _ = opts.set_quota_admission(current_usage, quota_limit); - Ok(()) -} - -fn ensure_object_size_within_quota(result: &QuotaCheckResult, new_size: u64) -> S3Result<()> { - let (Some(current_usage), Some(quota_limit)) = (result.current_usage, result.quota_limit) else { - return Ok(()); - }; - if new_size > quota_limit { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), - )); - } - Ok(()) -} - -fn ensure_legacy_archive_size_within_quota(result: &QuotaCheckResult, total_unpacked_size: u64) -> S3Result<()> { - if result.uses_durable_reservations { - return Ok(()); - } - let (Some(current_usage), Some(quota_limit)) = (result.current_usage, result.quota_limit) else { - return Ok(()); - }; - let expected_usage = current_usage - .checked_add(total_unpacked_size) - .ok_or_else(|| s3_error!(InvalidArgument, "Archive total size overflowed quota accounting"))?; - if expected_usage > quota_limit { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), - )); - } - Ok(()) -} - -fn quota_accounting_object_size(info: &ObjectInfo, fail_closed: bool) -> S3Result { - match quota_object_size(info) { - Ok(size) => Ok(size), - Err(err) if fail_closed => Err(ApiError::from(err).into()), - Err(_) => Ok(info.size.max(0) as u64), - } -} - -fn request_uses_aws_chunked(headers: &HeaderMap) -> bool { - let has_aws_chunked = |header_name: &str| { - headers - .get(header_name) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.split(',').any(|part| part.trim().eq_ignore_ascii_case("aws-chunked"))) - }; - - has_aws_chunked("content-encoding") || has_aws_chunked("transfer-encoding") -} - -async fn validate_table_catalog_object_mutation(bucket: &str, key: &str) -> S3Result<()> { - table_catalog::validate_bucket_object_mutation(bucket, key) - .await - .map_err(|_| s3_error!(InvalidRequest, "{}", table_catalog::RESERVED_CATALOG_OBJECT_MESSAGE)) -} - -struct DeadlockRequestGuard { - deadlock_detector: Arc, - request_id: String, -} - -impl DeadlockRequestGuard { - fn new(deadlock_detector: Arc, request_id: String) -> Self { - Self { - deadlock_detector, - request_id, - } - } - - fn register_if_enabled( - deadlock_detector: Arc, - request_id: &str, - description: F, - ) -> Option - where - F: FnOnce() -> String, - { - if !deadlock_detector.is_enabled() { - return None; - } - - let request_id = request_id.to_string(); - deadlock_detector.register_request(&request_id, description()); - Some(Self::new(deadlock_detector, request_id)) - } -} - -impl Drop for DeadlockRequestGuard { - fn drop(&mut self) { - self.deadlock_detector.unregister_request(&self.request_id); - } -} - -struct GetObjectBootstrap { - timeout_config: GetObjectTimeoutPolicy, - wrapper: RequestTimeoutWrapper, - request_start: std::time::Instant, - request_guard: GetObjectGuard, - _deadlock_request_guard: Option, - concurrent_requests: usize, -} - -struct GetObjectIoPlanning { - /// `None` when inline fast path skips disk I/O semaphore. - disk_permit: Option, - permit_wait_duration: Duration, - queue_status: concurrency::IoQueueStatus, - queue_utilization: f64, -} - -#[derive(Clone, Copy)] -struct GetObjectRequestTimeout<'a> { - wrapper: &'a RequestTimeoutWrapper, - policy: &'a GetObjectTimeoutPolicy, -} - -struct GetObjectRequestContext { - bucket: String, - key: String, - version_id_for_event: String, - part_number: Option, - rs: Option, - opts: ObjectOptions, -} - -/// Request fields that passed the cheap GET validations, ready for the -/// bucket-metadata work in [`DefaultObjectUsecase::prepare_get_object_request_context`]. -struct GetObjectValidatedRequest { - bucket: String, - key: String, - version_id: Option, - part_number: Option, - rs: Option, -} - -struct GetObjectReadSetup { - info: ObjectInfo, - final_stream: DynReader, - buffered_body: Option, - /// ODC-16: `buffered_body` is the body the ecstore cache hook served, so the - /// app layer serves it as the object-data-cache source without a re-lookup. - cache_hook_served: bool, - /// ODC-16: the cache hook probed this read (served or missed), so the app - /// layer must skip its own lookup. - cache_hook_probed: bool, - cache_fill_allowed: bool, - rs: Option, - content_type: Option, - last_modified: Option, - response_content_length: i64, - content_range: Option, - server_side_encryption: Option, - sse_customer_algorithm: Option, - sse_customer_key_md5: Option, - ssekms_key_id: Option, - encryption_applied: bool, - /// Resolved plaintext start offset of the committed response body - /// (`get_offset_length` output; 0 for a full-object read). Feeds the - /// mid-stream resume offset. - resume_range_start: i64, - /// Resolved inclusive plaintext end offset of the committed response body; - /// -1 when the committed body runs to the end of the object. - resume_range_end: i64, -} - -struct GetObjectPreparedRead { - io_planning: GetObjectIoPlanning, - read_setup: GetObjectReadSetup, -} - -struct GetObjectStrategyContext { - #[allow(dead_code, reason = "written but never read back (backlog#1823)")] - io_strategy: concurrency::IoStrategy, - optimal_buffer_size: usize, - enable_readahead: bool, -} - -struct GetObjectOutputContext { - output: GetObjectOutput, - event_info: Option, - response_content_length: i64, - optimal_buffer_size: usize, - extra_checksum_headers: Vec<(&'static str, String)>, -} - -enum GetObjectTimeoutStage { - BeforeProcessing, - DiskPermitWait { permit_wait_duration: Duration }, - BeforeRead, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum GetObjectStreamStrategy { - Standard, - LargeSequentialReadahead, -} - -impl GetObjectStreamStrategy { - fn as_str(self) -> &'static str { - match self { - Self::Standard => "standard", - Self::LargeSequentialReadahead => "large_sequential_readahead", - } - } -} - -const LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES: i64 = 1024 * 1024 * 1024; -const LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES: usize = 4 * MI_B; -const LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER: usize = 2; -const LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = MI_B; -const LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = 4 * MI_B as i64; -const MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = 512 * 1024; -const MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = MI_B as i64; -const ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE: &str = "RUSTFS_GET_SEEK_BUFFER_ENABLE"; -const ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE: &str = "RUSTFS_GET_READER_STREAM_BUFFER_SIZE"; -const ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE: &str = "RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE"; -const ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE: &str = "RUSTFS_GET_SMALL_BODY_ONCE_ENABLE"; -const GET_READER_STREAM_BUFFER_SOURCE_SELECTED: &str = "selected"; -const GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE: &str = "env_override"; -const GET_READER_STREAM_POLL_PENDING: &str = "pending"; -const GET_READER_STREAM_POLL_READY_DATA: &str = "ready_data"; -const GET_READER_STREAM_POLL_READY_EMPTY: &str = "ready_empty"; -const GET_READER_STREAM_POLL_READY_ERROR: &str = "ready_error"; -const GET_STREAMING_BODY_FAILURE_STAGE_READER_STREAM: &str = "reader_stream"; -const GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR: &str = "reader_error"; -const GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF: &str = "short_eof"; -const GET_MEMORY_BODY_SOURCE_BUFFERED_BODY: &str = "buffered_body"; -const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE: &str = "object_data_cache"; -const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED: &str = "object_data_cache_materialized"; -const GET_MEMORY_BODY_SOURCE_SEEK_BUFFER: &str = "seek_buffer"; -const GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER: &str = "encrypted_buffer"; -const GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ: &str = "body_cache_materialize_read"; - -fn get_reader_stream_buffer_size_override() -> Option { - static GET_READER_STREAM_BUFFER_SIZE_OVERRIDE: OnceLock> = OnceLock::new(); - *GET_READER_STREAM_BUFFER_SIZE_OVERRIDE.get_or_init(|| { - std::env::var(ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE) - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0) - }) -} - -fn is_get_output_handoff_attribution_enabled() -> bool { - static ENABLED: OnceLock = OnceLock::new(); - *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE, false)) -} - -fn is_get_small_body_once_enabled() -> bool { - #[cfg(test)] - { - rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false) - } - #[cfg(not(test))] - { - static ENABLED: OnceLock = OnceLock::new(); - *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false)) - } -} - -fn is_get_seek_buffer_enabled() -> bool { - static ENABLED: OnceLock = OnceLock::new(); - *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE, false)) -} - -fn resolve_reader_stream_buffer_size(selected_size: usize, override_size: Option) -> (usize, &'static str) { - if let Some(override_size) = override_size.filter(|value| *value > 0) { - return (override_size, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE); - } - - (selected_size.max(1), GET_READER_STREAM_BUFFER_SOURCE_SELECTED) -} - -fn tune_reader_stream_buffer_size( - selected_size: usize, - response_content_length: i64, - stream_strategy: GetObjectStreamStrategy, -) -> usize { - if stream_strategy == GetObjectStreamStrategy::Standard - && response_content_length >= LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES - { - return selected_size.max(LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES); - } - - if stream_strategy == GetObjectStreamStrategy::Standard - && response_content_length >= MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES - { - return selected_size.max(MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES); - } - - selected_size -} - -fn get_object_stream_size_bucket(expected: usize) -> &'static str { - rustfs_io_metrics::get_object_size_bucket(i64::try_from(expected).unwrap_or(i64::MAX)) -} - -fn classify_get_object_stream_read_error(err: &std::io::Error) -> &'static str { - if let Some(inner) = err.get_ref() { - if inner.is::() { - return "short_eof"; - } - - if inner.is::() { - return "bitrot"; - } - - let error_msg = inner.to_string().to_lowercase(); - if error_msg.contains("bitrot") { - return "bitrot"; - } - if error_msg.contains("read quorum") || error_msg.contains("insufficient read quorum") || error_msg.contains("erasure") { - return "read_quorum"; - } - } - - match err.kind() { - std::io::ErrorKind::UnexpectedEof => "short_eof", - std::io::ErrorKind::TimedOut => "timeout", - std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => "range_or_length_invalid", - _ => "io", - } -} - -fn get_object_stream_failure_reason(error_class: &'static str) -> &'static str { - if error_class == "short_eof" { - GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF - } else { - GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR - } -} - -fn record_get_object_reader_stream_failure( - reason: &'static str, - error_class: &'static str, - strategy: &'static str, - buffer_source: &'static str, - expected: usize, - emitted: usize, - remaining: usize, -) { - rustfs_io_metrics::record_get_object_streaming_body_failure(rustfs_io_metrics::GetObjectStreamingBodyFailure { - stage: GET_STREAMING_BODY_FAILURE_STAGE_READER_STREAM, - reason, - error_class, - strategy, - buffer_source, - size_bucket: get_object_stream_size_bucket(expected), - emitted_bytes: emitted, - remaining_bytes: remaining, - }); -} - -pin_project! { - struct ExtractArchiveEtagReader { - #[pin] - inner: R, - md5: Md5, - finished: bool, - etag: Arc>>, - } -} - -struct MemoryTrackedBytesStream { - bytes: Option, - emitted: bool, - completed: bool, - expected: usize, - /// Set when the materialized buffer length disagrees with the declared - /// content length. Such a body would be truncated (short) or over-long - /// relative to the already-committed `Content-Length`, so the stream must - /// surface an error instead of a clean short/over-long body. See #1324. - length_mismatch: bool, - started: std::time::Instant, - source: &'static str, - _guard: Option, - lifecycle: GetObjectBodyLifecycle, -} - -struct MemoryOnceBodyOwner { - bytes: Bytes, - _guard: Option, - // Body::Once has no poll hook, so this opt-in path only holds the request - // guard until the bytes are dropped; the result status remains unknown. - _lifecycle: GetObjectBodyLifecycle, -} - -impl MemoryOnceBodyOwner { - fn new(bytes: Bytes, guard: Option, lifecycle: GetObjectBodyLifecycle) -> Self { - Self { - bytes, - _guard: guard, - _lifecycle: lifecycle, - } - } -} - -impl AsRef<[u8]> for MemoryOnceBodyOwner { - fn as_ref(&self) -> &[u8] { - self.bytes.as_ref() - } -} - -#[derive(Default)] -struct GetObjectBodyLifecycle { - request_guard: Option, -} - -impl GetObjectBodyLifecycle { - fn tracked(request_guard: GetObjectGuard) -> Self { - Self { - request_guard: Some(request_guard), - } - } - - #[cfg(test)] - fn disabled() -> Self { - Self { request_guard: None } - } - - fn is_finished(&self) -> bool { - self.request_guard.is_none() - } - - fn finish_ok(&mut self) { - if let Some(mut request_guard) = self.request_guard.take() { - request_guard.finish_ok(); - } - } - - fn finish_err(&mut self) { - if let Some(mut request_guard) = self.request_guard.take() { - request_guard.finish_err(); - } - } -} - -pin_project! { - // Keep the disk-read admission permit tied to the response body. This is - // intentionally conservative backpressure: a streaming GET should occupy a - // read slot until the client drains or drops the body. - struct DiskReadPermitReader { - #[pin] - inner: R, - disk_permit: Option, - } -} - -impl DiskReadPermitReader { - fn new(inner: R, disk_permit: GetObjectDiskPermit) -> Self { - Self { - inner, - disk_permit: Some(disk_permit), - } - } -} - -impl AsyncRead for DiskReadPermitReader -where - R: AsyncRead, -{ - fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let this = self.project(); - let had_capacity = buf.remaining() > 0; - let filled_before = buf.filled().len(); - let poll = this.inner.poll_read(cx, buf); - // EOF: no more disk reads can happen through this stream, so release - // the permit instead of holding it until the client drops the body. - if had_capacity - && matches!(poll, Poll::Ready(Ok(()))) - && buf.filled().len() == filled_before - && let Some(mut disk_permit) = this.disk_permit.take() - { - disk_permit.release(); - } - poll - } -} - -pin_project! { - struct GetObjectReaderStream { - #[pin] - reader: Option, - capacity: usize, - strategy: &'static str, - buffer_source: &'static str, - remaining: usize, - emitted: usize, - expected: usize, - // Diagnostic-only identity for the body this stream is serving. Unset in - // unit tests that drive the stream over a bare reader; every production - // body carries it via `with_diagnostics`. - diagnostics: GetObjectReaderStreamDiagnostics, - } -} - -/// Object identity carried alongside a streaming GET body purely so a -/// mid-stream failure names the object it happened on. -#[derive(Clone, Default)] -struct GetObjectReaderStreamDiagnostics { - bucket: String, - object: String, - request_id: String, -} - -impl MemoryTrackedBytesStream { - fn new( - bytes: Bytes, - expected: usize, - source: &'static str, - guard: Option, - lifecycle: GetObjectBodyLifecycle, - ) -> Self { - let length_mismatch = bytes.len() != expected; - Self { - bytes: Some(bytes), - emitted: false, - completed: !length_mismatch && expected == 0, - expected, - length_mismatch, - started: std::time::Instant::now(), - source, - _guard: guard, - lifecycle, - } - } - - fn finish_ok(&mut self) { - self.completed = true; - self.lifecycle.finish_ok(); - } - - fn finish_err(&mut self) { - self.lifecycle.finish_err(); - } -} - -impl GetObjectReaderStream -where - R: AsyncRead, -{ - fn new(reader: R, capacity: usize, remaining: usize, strategy: &'static str, buffer_source: &'static str) -> Self { - if is_get_output_handoff_attribution_enabled() { - rustfs_io_metrics::record_get_object_reader_stream_buffer_size(strategy, buffer_source, capacity); - } - Self { - reader: Some(reader), - capacity, - strategy, - buffer_source, - remaining, - emitted: 0, - expected: remaining, - diagnostics: GetObjectReaderStreamDiagnostics::default(), - } - } - - /// Attach the object identity a failed body should be reported against. - fn with_diagnostics(mut self, bucket: &str, object: &str, request_id: &str) -> Self { - self.diagnostics = GetObjectReaderStreamDiagnostics { - bucket: bucket.to_string(), - object: object.to_string(), - request_id: request_id.to_string(), - }; - self - } -} - -impl futures::Stream for MemoryTrackedBytesStream { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - let poll_start = is_get_output_handoff_attribution_enabled().then(std::time::Instant::now); - if this.emitted { - if let Some(poll_start) = poll_start { - rustfs_io_metrics::record_get_object_memory_body_stream_poll( - this.source, - GET_READER_STREAM_POLL_READY_EMPTY, - 0, - poll_start.elapsed().as_secs_f64(), - ); - } - return Poll::Ready(None); - } - - // Strict materialization guard (#1324): a body whose length disagrees - // with the declared content length must fail the transfer rather than be - // delivered as a clean short body (truncation) or an over-long body - // (protocol violation). The HTTP layer has already committed to - // `Content-Length == expected`, so there is no safe way to serve a - // differently sized body. This is a defense-in-depth backstop; the - // buffered/cache callers reject the mismatch before headers are sent. - if this.length_mismatch { - let actual = this.bytes.as_ref().map_or(0, Bytes::len); - this.emitted = true; - this.finish_err(); - return Poll::Ready(Some(Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("materialized GET body length mismatch: expected {}, got {}", this.expected, actual), - ) - .into()))); - } - - let Some(bytes) = this.bytes.take() else { - return Poll::Ready(None); - }; - let bytes_len = bytes.len(); - let first_byte_elapsed = (!bytes.is_empty()).then(|| this.started.elapsed()); - this.emitted = true; - if let Some(elapsed) = first_byte_elapsed { - rustfs_io_metrics::record_get_object_first_byte_latency(GET_OBJECT_STAGE_PATH_S3_HANDLER, elapsed.as_secs_f64()); - } - if bytes_len >= this.expected { - this.finish_ok(); - } - if let Some(poll_start) = poll_start { - rustfs_io_metrics::record_get_object_memory_body_stream_poll( - this.source, - GET_READER_STREAM_POLL_READY_DATA, - bytes_len, - poll_start.elapsed().as_secs_f64(), - ); - } - Poll::Ready(Some(Ok(bytes))) - } -} - -impl ByteStream for MemoryTrackedBytesStream { - fn remaining_length(&self) -> RemainingLength { - if self.emitted || self.bytes.is_none() { - RemainingLength::new_exact(0) - } else { - RemainingLength::new_exact(self.expected) - } - } -} - -impl Drop for MemoryTrackedBytesStream { - fn drop(&mut self) { - if self.lifecycle.is_finished() { - return; - } - - if self.completed { - self.finish_ok(); - } else { - self.finish_err(); - } - } -} - -/// Failure modes of strictly materializing an object body into memory (#1324). -#[derive(Debug)] -enum StrictMaterializeError { - /// The reader produced a different number of bytes than the declared content - /// length (short or over-long). The response has already committed to - /// `Content-Length == expected`, so any other length is an unrecoverable, - /// broken HTTP response and must fail before headers are sent. - LengthMismatch { expected: usize, actual: usize }, - /// A read error occurred after `consumed` bytes were already drained from the - /// reader. The caller MUST NOT fall back to streaming the same reader: the - /// drained prefix is gone, so streaming would ship a body missing its prefix - /// (the seek-buffer prefix-misalignment bug this issue closes). - Read { consumed: usize, source: std::io::Error }, -} - -impl std::fmt::Display for StrictMaterializeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::LengthMismatch { expected, actual, .. } => { - write!(f, "materialized length mismatch: expected {expected}, got {actual}") - } - Self::Read { consumed, source } => { - write!(f, "read failed after {consumed} bytes: {source}") - } - } - } -} - -impl StrictMaterializeError { - fn into_storage_error(self) -> StorageError { - match self { - Self::LengthMismatch { expected, actual, .. } if actual < expected => StorageError::LessData, - Self::LengthMismatch { .. } => StorageError::MoreData, - Self::Read { source, .. } if source.kind() == std::io::ErrorKind::TimedOut => StorageError::Timeout, - Self::Read { source, .. } => StorageError::Io(std::io::Error::new(source.kind(), "object body read failed")), - } - } - - fn into_s3_error(self, _response_content_length: i64) -> S3Error { - ApiError::from(self.into_storage_error()).into() - } -} - -/// Strictly materialize an object body into memory, enforcing an exact-length -/// contract (#1324). -/// -/// Reads at most `expected + 1` bytes so an over-long stream is detected without -/// buffering it unbounded, then requires `bytes_read == expected`. A short read -/// (clean EOF before `expected`), an over-long read, or a mid-stream read error -/// all return an error; only an exact-length read yields the buffer. Because the -/// HTTP response commits to `Content-Length == expected` before the body is -/// produced, this mirrors the streaming path (which already fails a short read -/// with `UnexpectedEof`) and the ODC materialize-fill path, closing the -/// warn-and-serve holes in the encrypted, seek, and cache memory branches. -/// -/// On error the reader has already been (partially) consumed, so callers must -/// propagate the error rather than fall back to streaming the same reader. -async fn strict_materialize_object_body( - reader: R, - expected: usize, - stage: &'static str, -) -> Result, StrictMaterializeError> -where - R: AsyncRead + Unpin, -{ - // Stop filling before the Vec reaches capacity. Calling `read_to_end` on a - // bounded reader can still reserve beyond `expected` before observing EOF. - // The over-long probe below stays outside this Vec so the admitted body - // allocation remains exactly `expected` bytes. - let mut buf = Vec::with_capacity(expected); - let mut reader = reader; - let read_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let read_result = loop { - if buf.len() == expected { - break Ok(()); - } - match tokio::io::AsyncReadExt::read_buf(&mut reader, &mut buf).await { - Ok(0) => break Ok(()), - Ok(_) => {} - Err(source) => break Err(source), - } - }; - let actual = buf.len(); - let probe_result = if read_result.is_ok() && actual == expected { - let mut probe = [0_u8; 1]; - tokio::io::AsyncReadExt::read(&mut reader, &mut probe).await - } else { - Ok(0) - }; - record_get_object_s3_handler_stage_duration(stage, read_start); - match (read_result, probe_result) { - (Ok(_), Ok(extra)) => { - let actual = actual.saturating_add(extra); - if actual == expected { - Ok(buf) - } else { - Err(StrictMaterializeError::LengthMismatch { expected, actual }) - } - } - (Err(source), _) | (_, Err(source)) => Err(StrictMaterializeError::Read { - consumed: actual, - source, - }), - } -} - -struct ColdFillProducerExecution { - expected: usize, - deadline: Option, - adapter: Arc, - engine_plan: rustfs_object_data_cache::ObjectDataCacheGetPlan, -} - -enum ColdFillStartupWaitError { - Cancelled, - DeadlineExceeded, -} - -async fn await_cold_fill_startup( - future: F, - cancellation: &tokio_util::sync::CancellationToken, - deadline: Option, -) -> Result -where - F: Future, -{ - tokio::pin!(future); - match deadline { - Some(deadline) => { - tokio::select! { - biased; - _ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled), - result = tokio::time::timeout_at(deadline, &mut future) => { - result.map_err(|_| ColdFillStartupWaitError::DeadlineExceeded) - } - } - } - None => { - tokio::select! { - biased; - _ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled), - result = &mut future => Ok(result), - } - } - } -} - -async fn start_cold_fill_producer( - producer: ColdFillProducer, - reservation: Option, - acquire_io: AcquireIo, - open_reader: OpenReader, - execution: ColdFillProducerExecution, -) where - AcquireIo: FnOnce() -> AcquireIoFuture, - AcquireIoFuture: Future>, - OpenReader: FnOnce() -> OpenReaderFuture, - OpenReaderFuture: Future>, -{ - let ColdFillProducerExecution { - expected, - deadline, - adapter, - engine_plan, - } = execution; - let hard_deadline = tokio::time::Instant::now() + COLD_FILL_HARD_MAX_DURATION; - let deadline = deadline.map_or(hard_deadline, |request_deadline| request_deadline.min(hard_deadline)); - let cancellation = producer.cancellation_token(); - let Some(reservation) = reservation else { - producer.bypass(); - return; - }; - let acquire = acquire_io(); - tokio::pin!(acquire); - let producer_io = tokio::select! { - _ = cancellation.cancelled() => { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - result = tokio::time::timeout_at(deadline, &mut acquire) => match result { - Ok(result) => result, - Err(_) => { - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - return; - } - } - }; - let producer_io = match producer_io { - Ok(io) => io, - Err(err) => { - producer.relinquish_or_finish(err); - return; - } - }; - - let open = open_reader(); - tokio::pin!(open); - let reader = match tokio::select! { - _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), - result = tokio::time::timeout_at(deadline, &mut open) => { - result.unwrap_or(Err(StorageError::Timeout)) - } - } { - Ok(reader) => reader, - Err(err) => { - producer.relinquish_or_finish(ColdFillError::Storage(err)); - return; - } - }; - producer.mark_reader_started(); - let materialize = async move { - let GetObjectReader { - stream, buffered_body, .. - } = reader; - let body = if let Some(body) = buffered_body { - if body.len() == expected { - body - } else { - return Err(StorageError::other(format!( - "cold-fill buffered body length mismatch: expected {expected}, got {}", - body.len() - ))); - } - } else { - let stream = if let Some(permit) = producer_io.disk_permit { - wrap_reader(DiskReadPermitReader::new(stream, permit)) - } else { - stream - }; - Bytes::from( - strict_materialize_object_body(stream, expected, GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ) - .await - .map_err(StrictMaterializeError::into_storage_error)?, - ) - }; - Ok::<_, StorageError>((body, reservation)) - }; - let materialized = tokio::select! { - _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), - result = tokio::time::timeout_at(deadline, materialize) => { - result.unwrap_or(Err(StorageError::Timeout)) - } - }; - let result = match materialized { - Ok((body, reservation)) => { - if cancellation.is_cancelled() { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - if deadline <= tokio::time::Instant::now() { - producer.finish(Err(StorageError::Timeout)); - return; - } - let reserved = reservation.wrap_bytes(body); - let shared = reserved.bytes(); - let publish = async { - #[cfg(test)] - wait_cold_fill_publication_barrier(&engine_plan).await; - adapter.fill_reserved_body(&engine_plan, reserved).await - }; - tokio::pin!(publish); - tokio::select! { - _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), - _ = tokio::time::sleep_until(deadline) => { - Err(StorageError::Timeout) - } - _ = &mut publish => Ok(shared), - } - } - Err(err) => Err(err), - }; - producer.finish(result); -} - -fn cold_fill_deadline( - wrapper: &RequestTimeoutWrapper, - timeout_config: &GetObjectTimeoutPolicy, - response_size: u64, -) -> Option { - if !timeout_config.is_timeout_enabled() { - return None; - } - Some(tokio::time::Instant::now() + wrapper.remaining_time_for_size(Some(response_size)).unwrap_or(Duration::ZERO)) -} - -fn cold_fill_producer_deadline(timeout_config: &GetObjectTimeoutPolicy, response_size: u64) -> tokio::time::Instant { - let now = tokio::time::Instant::now(); - let hard_deadline = now + COLD_FILL_HARD_MAX_DURATION; - if timeout_config.is_timeout_enabled() { - (now + timeout_config.calculate_timeout_for_size(response_size)).min(hard_deadline) - } else { - hard_deadline - } -} - -async fn lookup_cold_fill_second_chance( - adapter: &ObjectDataCacheAdapter, - plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan, -) -> Option { - match adapter.peek_body_untracked(plan).await { - rustfs_object_data_cache::ObjectDataCacheLookup::Hit(body) => Some(body), - _ => None, - } -} - -fn retain_cold_fill_producer_for_matching_plan( - producer: ColdFillProducer, - current: &GetObjectBodyCachePlan, - expected: &rustfs_object_data_cache::ObjectDataCacheGetPlan, -) -> Option { - if current == &GetObjectBodyCachePlan::Cacheable(expected.clone()) { - Some(producer) - } else { - producer.bypass(); - None - } -} - -impl futures::Stream for GetObjectReaderStream -where - R: AsyncRead, -{ - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - if *this.remaining == 0 { - return Poll::Ready(None); - } - - let remaining_before = *this.remaining; - let attribution_enabled = is_get_output_handoff_attribution_enabled(); - let poll_start = attribution_enabled.then(std::time::Instant::now); - let reader = match this.reader.as_mut().as_pin_mut() { - Some(reader) => reader, - None => return Poll::Ready(None), - }; - let read_capacity = (*this.capacity).min(*this.remaining); - let mut buf = BytesMut::with_capacity(read_capacity); - let poll_read = poll_read_buf(reader, cx, &mut buf); - - let result: Poll> = match poll_read { - Poll::Ready(Ok(bytes_read)) if bytes_read > 0 => { - let bytes = buf.freeze(); - *this.remaining -= bytes.len(); - *this.emitted += bytes.len(); - #[cfg(feature = "tracing-chunk-debug")] - { - tracing::debug!( - emitted = *this.emitted, - expected = *this.expected, - chunk_len = bytes.len(), - "GetObject ReaderStream emitted bytes" - ); - } - if bytes.is_empty() { - Poll::Ready(None) - } else { - Poll::Ready(Some(Ok(bytes))) - } - } - Poll::Ready(Ok(_)) => { - this.reader.set(None); - let remaining = i64::try_from(*this.remaining).unwrap_or(i64::MAX); - let err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, rustfs_rio::IncompleteBody { remaining }); - record_get_object_reader_stream_failure( - GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF, - "short_eof", - this.strategy, - this.buffer_source, - *this.expected, - *this.emitted, - *this.remaining, - ); - // The inner GetObjectStreamingReader is what normally reports a - // short body, so reaching this arm means the reader signalled a - // clean EOF while this layer still owed bytes against an - // already-committed Content-Length. That disagreement is a data - // plane fault, not chunk noise: log it unconditionally so the - // truncated object is named in the operator's log rather than - // only in a metric counter (issue #4784). - error!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %this.diagnostics.bucket, - object = %this.diagnostics.object, - request_id = %this.diagnostics.request_id, - size_bucket = get_object_stream_size_bucket(*this.expected), - expected = *this.expected, - emitted = *this.emitted, - remaining = *this.remaining, - strategy = this.strategy, - buffer_source = this.buffer_source, - state = "reader_stream_short_eof", - error = %err, - "GetObject reader stream ended before the committed content length" - ); - Poll::Ready(Some(Err(Box::new(err) as S3StdError))) - } - Poll::Ready(Err(err)) => { - this.reader.set(None); - let error_class = classify_get_object_stream_read_error(&err); - record_get_object_reader_stream_failure( - get_object_stream_failure_reason(error_class), - error_class, - this.strategy, - this.buffer_source, - *this.expected, - *this.emitted, - *this.remaining, - ); - // Deliberately not logged at warn here: every production body - // wraps a GetObjectStreamingReader, and that layer already - // reports this same error once with `state = "read_failed"` and - // the object identity. A second unconditional line per failed - // GET would read as two distinct faults. The chunk-debug build - // still gets this layer's view of the same error. - #[cfg(feature = "tracing-chunk-debug")] - tracing::error!( - emitted = *this.emitted, - expected = *this.expected, - error_class = error_class, - error = %err, - "GetObject ReaderStream returned error" - ); - Poll::Ready(Some(Err(Box::new(err) as S3StdError))) - } - Poll::Pending => Poll::Pending, - }; - - let emitted_bytes = match &result { - Poll::Ready(Some(Ok(bytes))) => bytes.len(), - _ => 0, - }; - let outcome = match &result { - Poll::Ready(Some(Ok(bytes))) if !bytes.is_empty() => GET_READER_STREAM_POLL_READY_DATA, - Poll::Ready(Some(Ok(_))) | Poll::Ready(None) => GET_READER_STREAM_POLL_READY_EMPTY, - Poll::Ready(Some(Err(_))) => GET_READER_STREAM_POLL_READY_ERROR, - Poll::Pending => GET_READER_STREAM_POLL_PENDING, - }; - if attribution_enabled { - rustfs_io_metrics::record_get_object_reader_stream_poll( - this.strategy, - this.buffer_source, - outcome, - remaining_before, - emitted_bytes, - poll_start.map_or(0.0, |start| start.elapsed().as_secs_f64()), - ); - } - - result - } - - fn size_hint(&self) -> (usize, Option) { - if self.remaining == 0 || self.reader.is_none() { - (0, Some(0)) - } else { - (1, None) - } - } -} - -impl ByteStream for GetObjectReaderStream -where - R: AsyncRead, -{ - fn remaining_length(&self) -> RemainingLength { - RemainingLength::new_exact(self.remaining) - } -} - -struct GetObjectStreamingReader { - inner: Option, - // bucket/object + request_id + optional content_range are only used for diagnostic - // correlation and failure bucketing; they do not alter stream behavior. The object - // identity is what turns a mid-stream failure into an actionable report: a request_id - // alone cannot tell an operator which object reads short (issue #4784). - bucket: String, - object: String, - request_id: String, - content_range: Option, - expected: usize, - emitted: usize, - timeout: Duration, - timer: Option>>, - started: std::time::Instant, - first_byte_reported: bool, - completed: bool, - lifecycle: GetObjectBodyLifecycle, - resume: Option>, - _foreground_read_guard: rustfs_scanner::ForegroundReadGuard, -} - -impl GetObjectStreamingReader { - #[allow(clippy::too_many_arguments)] - fn new( - inner: R, - bucket: &str, - key: &str, - request_id: &str, - content_range: Option, - expected: usize, - timeout: Duration, - lifecycle: GetObjectBodyLifecycle, - resume: Option>, - ) -> Self { - Self { - inner: Some(inner), - bucket: bucket.to_string(), - object: key.to_string(), - request_id: request_id.to_string(), - content_range, - expected, - emitted: 0, - timeout, - timer: None, - started: std::time::Instant::now(), - first_byte_reported: false, - completed: expected == 0, - lifecycle, - resume, - _foreground_read_guard: rustfs_scanner::ForegroundReadGuard::new(), - } - } - - fn elapsed(&self) -> Duration { - self.started.elapsed() - } - - // Classify transport/read failures before logging so operators can quickly - // distinguish truncated upstream bodies, corruption, quorum issues, and - // genuine downstream-close disconnects. - fn classify_read_error(err: &std::io::Error) -> &'static str { - classify_get_object_stream_read_error(err) - } - - fn finish_ok(&mut self) { - self.completed = true; - self.lifecycle.finish_ok(); - } - - fn finish_err(&mut self) { - self.lifecycle.finish_err(); - } - - fn resume_in_flight(&self) -> bool { - matches!( - self.resume.as_ref().map(|resume| &resume.stage), - Some(GetObjectResumeStage::Backoff | GetObjectResumeStage::Reopening(_)) - ) - } - - fn begin_resume(&mut self, error: std::io::Error) { - let Some(resume) = self.resume.as_mut() else { - return; - }; - self.inner.take(); - resume.begin(error); - } - - // Drive the armed resume flow: backoff ticks gate each reopen attempt, and - // a successful reopen swaps the failed stream out for the replacement. - fn poll_resume(&mut self, cx: &mut Context<'_>) -> GetObjectResumePoll { - let Some(mut resume) = self.resume.take() else { - // resume_in_flight guards every call site. - unreachable!("poll_resume requires an armed resume control"); - }; - let outcome = loop { - let stage = std::mem::replace(&mut resume.stage, GetObjectResumeStage::Idle); - match stage { - GetObjectResumeStage::Idle => unreachable!("resume control is only polled while armed"), - GetObjectResumeStage::Backoff => match Pin::new(&mut resume.timer).poll_next(cx) { - Poll::Ready(Some(())) => { - resume.attempts += 1; - resume.stage = GetObjectResumeStage::Reopening(Mutex::new((resume.reopen)(self.emitted))); - } - Poll::Ready(None) => { - let error = resume.take_trigger_error(); - break GetObjectResumePoll::Failed { - error, - attempts: resume.attempts, - }; - } - Poll::Pending => { - resume.stage = GetObjectResumeStage::Backoff; - break GetObjectResumePoll::Pending; - } - }, - GetObjectResumeStage::Reopening(reopening) => { - let poll = match reopening.try_lock() { - Ok(mut reopening) => reopening.as_mut().poll(cx), - // Only reachable when a poll of the reopen future - // panicked and poisoned the mutex: fail closed with the - // original trigger error instead of polling it again. - Err(_) => { - let error = resume.take_trigger_error(); - break GetObjectResumePoll::Failed { - error, - attempts: resume.attempts, - }; - } - }; - match poll { - Poll::Ready(Ok(reader)) => { - self.inner = Some(reader); - break GetObjectResumePoll::Resumed { - attempts: resume.attempts, - }; - } - Poll::Ready(Err(GetObjectResumeFailure::Retryable)) => { - resume.stage = GetObjectResumeStage::Backoff; - } - Poll::Ready(Err(GetObjectResumeFailure::Fatal)) => { - let error = resume.take_trigger_error(); - break GetObjectResumePoll::Failed { - error, - attempts: resume.attempts, - }; - } - Poll::Pending => { - resume.stage = GetObjectResumeStage::Reopening(reopening); - break GetObjectResumePoll::Pending; - } - } - } - } - }; - if matches!(outcome, GetObjectResumePoll::Resumed { .. } | GetObjectResumePoll::Pending) { - self.resume = Some(resume); - } - outcome - } - - fn poll_stall_timeout(&mut self, cx: &mut Context<'_>) -> Poll> { - if self.timeout.is_zero() { - return Poll::Pending; - } - - if self.timer.is_none() { - self.timer = Some(Box::pin(tokio::time::sleep(self.timeout))); - } - - if let Some(timer) = self.timer.as_mut() - && std::future::Future::poll(timer.as_mut(), cx).is_ready() - { - self.timer = None; - warn!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - timeout_ms = self.timeout.as_millis(), - state = "stall_timeout", - "GetObject streaming body stalled" - ); - self.finish_err(); - return Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "get object streaming body stall timeout", - ))); - } - - Poll::Pending - } -} - -impl AsyncRead for GetObjectStreamingReader { - fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let filled_before = buf.filled().len(); - - loop { - // An armed resume owns the reader until it swaps in a reopened - // stream or exhausts its budget; the failed inner stream is never - // polled again. - if self.resume_in_flight() { - match self.poll_resume(cx) { - GetObjectResumePoll::Resumed { attempts } => { - debug!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - resume_attempts = attempts, - state = "resumed", - "GetObject streaming body resumed from a reopened object read" - ); - // The replacement stream starts a fresh stall window. - self.timer = None; - continue; - } - GetObjectResumePoll::Pending => return self.poll_stall_timeout(cx), - GetObjectResumePoll::Failed { error, attempts } => { - self.timer = None; - let failure_reason = Self::classify_read_error(&error); - self.finish_err(); - error!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - state = "read_failed", - failure_reason = failure_reason, - resume_attempts = attempts, - error = %error, - "GetObject streaming body read failed; mid-stream resume did not recover" - ); - return Poll::Ready(Err(error)); - } - } - } - - let Some(inner) = self.inner.as_mut() else { - self.finish_err(); - return Poll::Ready(Err(std::io::Error::other( - "get object streaming reader lost its active read outside resume", - ))); - }; - match Pin::new(inner).poll_read(cx, buf) { - Poll::Ready(Ok(())) => { - self.timer = None; - let produced = buf.filled().len().saturating_sub(filled_before); - if produced > 0 { - self.emitted = self.emitted.saturating_add(produced); - if !self.first_byte_reported { - self.first_byte_reported = true; - let elapsed = self.elapsed(); - rustfs_io_metrics::record_get_object_first_byte_latency( - GET_OBJECT_STAGE_PATH_S3_HANDLER, - elapsed.as_secs_f64(), - ); - if elapsed >= GET_OBJECT_STREAM_WARN_THRESHOLD { - warn!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = elapsed.as_millis(), - state = "first_byte_slow", - "GetObject streaming body first byte was slow" - ); - } - } - if self.emitted >= self.expected { - self.completed = true; - self.finish_ok(); - } - return Poll::Ready(Ok(())); - } - - if self.emitted < self.expected { - // The inner reader signalled a clean EOF before delivering the full - // Content-Length. Returning Ok here would hand the client a truncated body - // under a full Content-Length: the peer treats the short body as complete - // (e.g. `mc mirror` writes a short file and considers it done — the - // "incomplete data mirroring" in issue #2955). Surface an error instead so - // the transfer fails loudly and the client retries rather than persisting - // truncated data. - let error = std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - rustfs_rio::IncompleteBody { - remaining: self.expected.saturating_sub(self.emitted) as i64, - }, - ); - // A premature EOF is also how the legacy duplex read path - // surfaces the object data vanishing mid-stream (typed - // errors do not survive that pump), so arm the resume - // flow before failing loudly when one is attached. - if self.resume.is_some() { - self.begin_resume(error); - continue; - } - error!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - state = "short_eof", - "GetObject streaming body ended before expected length" - ); - self.finish_err(); - return Poll::Ready(Err(error)); - } - - self.completed = true; - self.finish_ok(); - return Poll::Ready(Ok(())); - } - Poll::Ready(Err(err)) => { - // Typed relocation errors (the codec read path delivers them - // in-band) mean rebalance/decommission removed the pinned - // object data mid-stream: reopen and continue instead of - // failing the download. The error is only intercepted before - // the committed body length has been fully delivered. - if self.emitted < self.expected && is_object_relocation_error(&err) && self.resume.is_some() { - self.begin_resume(err); - continue; - } - let failure_reason = Self::classify_read_error(&err); - self.timer = None; - self.finish_err(); - error!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - state = "read_failed", - failure_reason = failure_reason, - error = %err, - "GetObject streaming body read failed" - ); - return Poll::Ready(Err(err)); - } - Poll::Pending => return self.poll_stall_timeout(cx), - } - } - } -} - -impl Drop for GetObjectStreamingReader { - fn drop(&mut self) { - if self.lifecycle.is_finished() { - return; - } - - if self.expected == 0 || self.completed || self.emitted >= self.expected { - self.finish_ok(); - return; - } - - self.finish_err(); - warn!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - state = "dropped_incomplete", - "GetObject streaming body dropped before expected length" - ); - } -} - -/// Reopen budget for a single GetObject body. Three attempts against the -/// jittered 200ms/400ms RetryTimer schedule (~600ms worst case) bound the -/// metadata fan-out a storm of relocated downloads can multiply. -const GET_OBJECT_RESUME_MAX_ATTEMPTS: i64 = 3; - -type GetObjectResumeFuture = Pin> + Send>>; -type GetObjectReopen = Box GetObjectResumeFuture + Send + Sync>; - -enum GetObjectResumePoll { - Resumed { attempts: usize }, - Pending, - Failed { error: std::io::Error, attempts: usize }, -} - -/// Why a single resume attempt did not produce a replacement stream. -#[derive(Debug)] -enum GetObjectResumeFailure { - /// Reopen/admission failure that may clear on the next attempt. - Retryable, - /// The reopened object is not the version this response committed to (or - /// admission is permanently unavailable): continuing would splice two - /// versions into one 200 response, so fail with the original error. - Fatal, -} - -enum GetObjectResumeStage { - Idle, - Backoff, - // The store's boxed read futures are Send but not Sync, while the - // streaming body requires the reader to be Sync, so the in-flight reopen - // future is stored behind a mutex. It is only ever locked under `&mut - // self` in `poll_resume`, so the lock never contends. - Reopening(Mutex>), -} - -/// Mid-stream resume machinery for [`GetObjectStreamingReader`]: when the -/// pinned object data vanishes mid-body (rebalance/decommission copies the -/// version elsewhere, then deletes the source), reopen the object at the -/// emitted offset and continue instead of failing the download. -struct GetObjectResumeControl { - reopen: GetObjectReopen, - timer: RetryTimer, - stage: GetObjectResumeStage, - original_error: Option, - attempts: usize, -} - -impl GetObjectResumeControl { - fn new(reopen: GetObjectReopen, timer: RetryTimer) -> Self { - Self { - reopen, - timer, - stage: GetObjectResumeStage::Idle, - original_error: None, - attempts: 0, - } - } - - fn begin(&mut self, error: std::io::Error) { - self.original_error = Some(error); - self.stage = GetObjectResumeStage::Backoff; - } - - // The trigger error is always recorded by `begin`; the fallback is a - // fail-closed internal error, never a fabricated success. - fn take_trigger_error(&mut self) -> std::io::Error { - self.original_error - .take() - .unwrap_or_else(|| std::io::Error::other("get object resume lost its trigger error")) - } -} - -/// Object-version identity captured when the response committed to a body. A -/// resumed read must serve exactly this version; `data_dir` is deliberately -/// excluded because rebalance regenerates it for the same version. -struct GetObjectResumeIdentity { - version_id: Option, - mod_time: Option, - size: i64, - etag: Option, - // The store rewrites a read's `object_info.size` to the per-read delivered - // length for encrypted and compressed objects (readers.rs Encrypted / - // Compressed transforms), so a reopened subrange reports `size - emitted` - // while a plain read reports the range-invariant `oi.size`. The flag only - // chooses the comparison arithmetic; a transform change that no longer - // matches it fails the identity check, which is the closed direction. - range_dependent_size: bool, -} - -impl GetObjectResumeIdentity { - fn matches(&self, info: &ObjectInfo, emitted: usize) -> bool { - let expected_size = if self.range_dependent_size { - self.size - emitted as i64 - } else { - self.size - }; - self.version_id == info.version_id - && self.mod_time == info.mod_time - && expected_size == info.size - && self.etag == info.etag - } -} - -/// Reopen parameters for a mid-stream resume. Only the SSE-C headers the store -/// read path consumes are retained: the store-level `get_object_reader` spans -/// record their header argument at debug level, so retaining the full request -/// headers would re-log credentials on every attempt. -struct GetObjectResumeContext { - store: Arc, - bucket: String, - key: String, - opts: ObjectOptions, - ssec_headers: HeaderMap, - // Resolved plaintext offsets of the committed response body, captured - // after `HTTPRangeSpec::get_offset_length`: suffix ranges and partNumber - // GETs are already resolved to absolute offsets at that point, so the - // resume offset is `range_start + emitted` regardless of request shape. - range_start: i64, - range_end: i64, - identity: GetObjectResumeIdentity, -} - -impl GetObjectResumeContext { - #[allow(clippy::too_many_arguments)] - fn new( - store: Arc, - bucket: &str, - key: &str, - mut opts: ObjectOptions, - request_headers: &HeaderMap, - info: &ObjectInfo, - range_start: i64, - range_end: i64, - ) -> Self { - if opts.version_id.is_none() - && let Some(version_id) = info.version_id - { - opts.version_id = Some(version_id.to_string()); - } - // Store spans record their header argument at debug level. Retain only - // the SSE-C inputs needed to reopen the reader and keep them redacted. - let ssec_headers = project_ssec_transport_headers(request_headers); - Self { - store, - bucket: bucket.to_string(), - key: key.to_string(), - opts, - ssec_headers, - range_start, - range_end, - identity: GetObjectResumeIdentity { - version_id: info.version_id, - mod_time: info.mod_time, - size: info.size, - etag: info.etag.clone(), - range_dependent_size: info.is_encrypted() || info.is_compressed(), - }, - } - } - - fn resume_range(range_start: i64, range_end: i64, emitted: usize) -> Option { - let start = range_start + emitted as i64; - if start == 0 && range_end < 0 { - // Nothing was emitted from a full-object read: reopen without a - // range so the replacement stream keeps the codec fast path - // instead of the duplex fallback a synthesized range forces. - return None; - } - Some(HTTPRangeSpec { - is_suffix_length: false, - start, - end: range_end, - }) - } - - async fn reopen(&self, emitted: usize) -> Result { - #[cfg(test)] - GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.fetch_add(1, Ordering::Relaxed); - - // A resumed read must hold disk-read admission just like the initial - // read; otherwise recovery reads bypass the concurrency caps exactly - // while rebalance is stressing the pool. - let disk_permit = DefaultObjectUsecase::admit_get_object_disk_read(get_concurrency_manager(), &self.bucket, &self.key) - .await - .map_err(|err| { - if err.code() == &S3ErrorCode::SlowDown { - GetObjectResumeFailure::Retryable - } else { - GetObjectResumeFailure::Fatal - } - })?; - let range = Self::resume_range(self.range_start, self.range_end, emitted); - let reader = self - .store - .get_object_reader(&self.bucket, &self.key, range, self.ssec_headers.clone(), &self.opts) - .await - .map_err(|err| { - debug!( - bucket = %self.bucket, - object = %self.key, - error = %err, - "GetObject mid-stream resume reopen failed" - ); - GetObjectResumeFailure::Retryable - })?; - if !self.identity.matches(&reader.object_info, emitted) { - warn!( - bucket = %self.bucket, - object = %self.key, - "GetObject mid-stream resume resolved a different object version; refusing to splice content" - ); - return Err(GetObjectResumeFailure::Fatal); - } - let stream = wrap_reader(reader.stream); - Ok(match disk_permit { - Some(disk_permit) => wrap_reader(DiskReadPermitReader::new(stream, disk_permit)), - None => stream, - }) - } -} - -#[cfg(test)] -static GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST: AtomicUsize = AtomicUsize::new(0); - -fn get_object_resume_control(ctx: GetObjectResumeContext) -> GetObjectResumeControl { - use rand::RngExt as _; - let ctx = Arc::new(ctx); - let reopen: GetObjectReopen = Box::new(move |emitted| { - let ctx = Arc::clone(&ctx); - Box::pin(async move { ctx.reopen(emitted).await }) - }); - GetObjectResumeControl::new( - reopen, - RetryTimer::new( - GET_OBJECT_RESUME_MAX_ATTEMPTS, - DEFAULT_RETRY_UNIT, - DEFAULT_RETRY_CAP, - MAX_JITTER, - rand::rng().random_range(10..=50), - ), - ) -} - -/// Mid-stream errors that mean the pinned object data is gone (rebalance or -/// decommission removed it after copying the version elsewhere). Only typed -/// `StorageError`s qualify; generic I/O errors and string-matched "not enough -/// disks" failures keep the existing fail-loud behavior. -fn is_object_relocation_error(err: &std::io::Error) -> bool { - let Some(inner) = err.get_ref() else { return false }; - match inner.downcast_ref::() { - Some(StorageError::FileNotFound | StorageError::ObjectNotFound(..) | StorageError::InsufficientReadQuorum(..)) => true, - Some(StorageError::Io(source)) => source.kind() == std::io::ErrorKind::NotFound, - _ => false, - } -} - -/// Resolve the S3 request-body inter-chunk read timeout from the environment. -/// -/// Returns `Duration::ZERO` when disabled (`RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT=0`), -/// in which case [`guard_put_object_body_read_timeout`] passes the body through -/// untouched. -fn put_object_body_read_timeout() -> Duration { - Duration::from_secs(rustfs_utils::get_env_u64( - rustfs_config::ENV_HTTP_REQUEST_BODY_READ_TIMEOUT, - rustfs_config::DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT, - )) -} - -/// A [`ByteStream`] decorator that aborts a request body whose peer stops -/// sending bytes without closing the connection. -/// -/// A well-behaved short body ends with EOF and is rejected promptly by the -/// eager/streaming readers. The failure this guards against is different: a -/// reverse proxy or CDN forwards a *partial* body and then goes silent while -/// holding the connection open, so the inner stream neither yields more bytes -/// nor reports EOF. Without a bound, RustFS would wait forever for bytes that -/// never arrive and the client eventually sees a hang/abort with no server-side -/// explanation (issue #3076). -/// -/// The timer resets on every chunk, so slow-but-progressing uploads are not -/// penalized; it only fires after `timeout` of complete silence. On timeout the -/// stall is logged with the received/expected byte counts and the read fails -/// with an `ErrorKind::TimedOut` error instead of hanging. -/// -/// `remaining_length` and `size_hint` are forwarded from the inner stream so -/// wrapping is transparent to length/content handling downstream. -struct RequestBodyReadTimeout { - inner: DynByteStream, - timeout: Duration, - timer: Option>>, - received: u64, - expected: Option, - bucket: String, - key: String, - request_id: String, - timed_out: bool, -} - -impl Stream for RequestBodyReadTimeout { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - - // Once we have surfaced a stall error, treat the stream as terminated so - // we never poll the abandoned inner stream again. - if this.timed_out { - return Poll::Ready(None); - } - - match Pin::new(&mut this.inner).poll_next(cx) { - Poll::Ready(Some(Ok(chunk))) => { - this.timer = None; - this.received = this.received.saturating_add(chunk.len() as u64); - Poll::Ready(Some(Ok(chunk))) - } - Poll::Ready(other) => { - this.timer = None; - Poll::Ready(other) - } - Poll::Pending => { - if this.timeout.is_zero() { - return Poll::Pending; - } - - if this.timer.is_none() { - this.timer = Some(Box::pin(tokio::time::sleep(this.timeout))); - } - - if let Some(timer) = this.timer.as_mut() - && std::future::Future::poll(timer.as_mut(), cx).is_ready() - { - this.timer = None; - this.timed_out = true; - let expected_display = this.expected.map(|v| v.to_string()).unwrap_or_else(|| "unknown".to_string()); - warn!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_BODY_READ_STALLED, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - request_id = %this.request_id, - bucket = %this.bucket, - key = %this.key, - received_bytes = this.received, - expected_bytes = %expected_display, - timeout_secs = this.timeout.as_secs(), - state = "stall_timeout", - "PutObject request body read stalled; aborting. A proxy/CDN likely forwarded a partial body without closing the connection." - ); - return Poll::Ready(Some(Err(Box::new(std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!( - "request body read stalled: received {} of {} bytes, no data for {}s", - this.received, - expected_display, - this.timeout.as_secs() - ), - )) as StdError))); - } - - Poll::Pending - } - } - } - - fn size_hint(&self) -> (usize, Option) { - self.inner.size_hint() - } -} - -impl ByteStream for RequestBodyReadTimeout { - fn remaining_length(&self) -> RemainingLength { - self.inner.remaining_length() - } -} - -/// Wrap an incoming request body with [`RequestBodyReadTimeout`] unless the -/// feature is disabled (`timeout == 0`), in which case the body is returned -/// untouched. `remaining_length` is preserved via [`StreamingBlob::new`]. -fn guard_put_object_body_read_timeout( - body: StreamingBlob, - bucket: &str, - key: &str, - request_id: &str, - expected: Option, - timeout: Duration, -) -> StreamingBlob { - if timeout.is_zero() { - return body; - } - - StreamingBlob::new(RequestBodyReadTimeout { - inner: body.into(), - timeout, - timer: None, - received: 0, - expected: expected.and_then(|v| u64::try_from(v).ok()), - bucket: bucket.to_string(), - key: key.to_string(), - request_id: request_id.to_string(), - timed_out: false, - }) -} - -impl ExtractArchiveEtagReader { - fn new(inner: R, etag: Arc>>) -> Self { - Self { - inner, - md5: Md5::new(), - finished: false, - etag, - } - } -} - -impl AsyncRead for ExtractArchiveEtagReader { - fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let this = self.project(); - let before = buf.filled().len(); - match this.inner.poll_read(cx, buf) { - Poll::Pending => Poll::Pending, - Poll::Ready(Ok(())) => { - let filled = &buf.filled()[before..]; - if !filled.is_empty() { - this.md5.update(filled); - } else if !*this.finished { - *this.finished = true; - if let Ok(mut etag) = this.etag.lock() { - *etag = Some(hex_simd::encode_to_string(this.md5.clone().finalize(), hex_simd::AsciiCase::Lower)); - } - } - Poll::Ready(Ok(())) - } - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - } - } -} - -struct PooledBufferReader { - buffer: PooledBuffer, - len: usize, - pos: usize, -} - -impl PooledBufferReader { - fn new(buffer: PooledBuffer, len: usize) -> Self { - Self { buffer, len, pos: 0 } - } -} - -impl AsyncRead for PooledBufferReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - if self.pos >= self.len { - return Poll::Ready(Ok(())); - } - - let remaining = self.len - self.pos; - let to_read = remaining.min(buf.remaining()); - buf.put_slice(&self.buffer[self.pos..self.pos + to_read]); - self.pos += to_read; - - Poll::Ready(Ok(())) - } -} - -struct ChunkedBytesReader { - chunks: Vec, - chunk_index: usize, - chunk_offset: usize, -} - -impl ChunkedBytesReader { - fn new(chunks: Vec) -> Self { - Self { - chunks, - chunk_index: 0, - chunk_offset: 0, - } - } -} - -impl AsyncRead for ChunkedBytesReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - while self.chunk_index < self.chunks.len() { - let chunk = &self.chunks[self.chunk_index]; - if self.chunk_offset >= chunk.len() { - self.chunk_index += 1; - self.chunk_offset = 0; - continue; - } - - let remaining = &chunk[self.chunk_offset..]; - let to_read = remaining.len().min(buf.remaining()); - buf.put_slice(&remaining[..to_read]); - self.chunk_offset += to_read; - return Poll::Ready(Ok(())); - } - - Poll::Ready(Ok(())) - } -} - -/// Determine if zero-copy write should be used for this PutObject operation. -/// -/// Zero-copy is beneficial for large objects without encryption or compression. -/// -/// # Arguments -/// -/// * `size` - Object size in bytes -/// * `headers` - HTTP headers (to check for encryption/compression) -/// -/// # Returns -/// -/// `true` if zero-copy should be used, `false` otherwise -fn should_use_zero_copy(size: i64, headers: &HeaderMap) -> bool { - // Only use zero-copy for objects larger than 1MB - const ZERO_COPY_MIN_SIZE: i64 = 1024 * 1024; - - if size <= ZERO_COPY_MIN_SIZE { - return false; - } - - // Don't use zero-copy if encryption is requested - if headers.get(AMZ_SERVER_SIDE_ENCRYPTION).is_some() - || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM).is_some() - || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some() - { - return false; - } - - // Don't use zero-copy if compression is likely (compressible content types) - // The compression check happens later in the flow - if let Some(content_type) = headers.get(CONTENT_TYPE) - && let Ok(ct) = content_type.to_str() - { - // Skip zero-copy for easily compressible content types - // since compression will be applied - let compressible_types = [ - "text/plain", - "text/html", - "text/css", - "text/javascript", - "application/javascript", - "application/json", - "application/xml", - "text/xml", - ]; - for ct_type in compressible_types { - if ct.contains(ct_type) { - return false; - } - } - } - - true -} - -#[cfg(test)] -fn should_use_zero_copy_eager_put_path( - size: i64, - headers: &HeaderMap, - server_side_encryption_requested: bool, - should_compress: bool, - is_extract: bool, -) -> bool { - zero_copy_eager_put_path_status(size, headers, server_side_encryption_requested, should_compress, is_extract) - == PUT_EAGER_STATUS_ELIGIBLE -} - -fn zero_copy_eager_put_path_status( - size: i64, - headers: &HeaderMap, - server_side_encryption_requested: bool, - should_compress: bool, - is_extract: bool, -) -> &'static str { - zero_copy_eager_put_path_status_with_max_size( - size, - headers, - server_side_encryption_requested, - should_compress, - is_extract, - zero_copy_eager_put_max_size_bytes(), - ) -} - -fn zero_copy_eager_put_path_status_with_max_size( - size: i64, - headers: &HeaderMap, - server_side_encryption_requested: bool, - should_compress: bool, - is_extract: bool, - max_size: i64, -) -> &'static str { - if is_extract { - return PUT_EAGER_STATUS_EXTRACT; - } - if should_compress { - return PUT_EAGER_STATUS_COMPRESSED; - } - if server_side_encryption_requested { - return PUT_EAGER_STATUS_ENCRYPTED; - } - - if size <= 0 { - return PUT_EAGER_STATUS_INVALID_SIZE; - } - if size > max_size { - return PUT_EAGER_STATUS_ABOVE_EAGER_MAX; - } - - if !should_use_zero_copy(size, headers) { - return PUT_EAGER_STATUS_ZERO_COPY_INELIGIBLE; - } - - if request_uses_aws_chunked(headers) && decoded_content_length_from_headers(headers).ok().flatten().is_none() { - return PUT_EAGER_STATUS_AWS_CHUNKED_MISSING_DECODED_LENGTH; - } - - PUT_EAGER_STATUS_ELIGIBLE -} - -fn zero_copy_eager_put_max_size_bytes() -> i64 { - let configured = *CACHED_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES.get_or_init(|| { - rustfs_utils::get_env_usize(ENV_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES, DEFAULT_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES) - }); - i64::try_from(configured).unwrap_or(i64::MAX) -} - -fn has_put_sse_request_headers(headers: &HeaderMap) -> bool { - headers.get(AMZ_SERVER_SIDE_ENCRYPTION).is_some() - || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM).is_some() - || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some() -} - -/// Resolve the effective server-side encryption for a write against the bucket's -/// default encryption configuration. -/// -/// A request-level value always wins; the bucket default only fills a gap, and -/// the unknown-algorithm fallback lives once in [`bucket_default_write_sse`]. -/// -/// `has_explicit_ssec` suppresses the default entirely. Only COPY passes `true` -/// today: its destination may carry SSE-C, which must not also be given managed -/// encryption. PUT and extract pass `false`, matching their current behaviour — -/// see backlog#1826 for the divergence that leaves. -/// -/// Callers layering further overrides (PUT's `ciphertext_passthrough`) apply -/// them to the returned pair. -fn resolve_bucket_default_sse( - bucket_sse_config: Option<&ServerSideEncryptionConfiguration>, - requested_sse: Option, - requested_kms_key_id: Option, - has_explicit_ssec: bool, -) -> (Option, Option) { - let bucket_default = || { - if has_explicit_ssec { - return None; - } - bucket_sse_config - .and_then(|config| config.rules.first()) - .and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref()) - }; - - let effective_sse = requested_sse.or_else(|| bucket_default().map(bucket_default_write_sse)); - let effective_kms_key_id = requested_kms_key_id.or_else(|| bucket_default().and_then(|sse| sse.kms_master_key_id.clone())); - (effective_sse, effective_kms_key_id) -} - -fn should_use_small_eager_put_path( - size: i64, - headers: &HeaderMap, - server_side_encryption_requested: bool, - should_compress: bool, - is_extract: bool, -) -> bool { - const SMALL_EAGER_PUT_MAX_SIZE: i64 = 1024 * 1024; - - if is_extract || should_compress || server_side_encryption_requested { - return false; - } - - if size <= 0 || size > SMALL_EAGER_PUT_MAX_SIZE { - return false; - } - - if has_put_sse_request_headers(headers) { - return false; - } - - if request_uses_aws_chunked(headers) && decoded_content_length_from_headers(headers).ok().flatten().is_none() { - return false; - } - - true -} - -/// Objects at or below this size bypass BytesPool and use direct allocation. -/// This avoids Small-tier Mutex contention under high concurrency for tiny objects -/// where the allocation cost is negligible (≤4KiB memcpy). -const POOL_BYPASS_MAX_SIZE: usize = 4 * 1024; - -async fn read_small_put_body_into(body: &mut R, buf: &mut B, size: usize) -> S3Result<()> -where - R: AsyncRead + Unpin, - B: bytes::BufMut, -{ - let mut filled = 0; - - while filled < size { - let mut remaining = (&mut *buf).limit(size - filled); - let read = tokio::io::AsyncReadExt::read_buf(&mut *body, &mut remaining) - .await - .map_err(ApiError::from)?; - if read == 0 { - return Err(s3_error!(IncompleteBody)); - } - filled += read; - } - - let mut extra = [0u8; 1]; - let extra_read = tokio::io::AsyncReadExt::read(&mut *body, &mut extra) - .await - .map_err(ApiError::from)?; - if extra_read != 0 { - return Err(s3_error!(UnexpectedContent)); - } - - Ok(()) -} - -async fn read_small_put_body_exact_pooled(mut body: R, size: usize, pool: &BytesPool) -> S3Result -where - R: AsyncRead + Unpin, -{ - let mut buf = pool.acquire_buffer(size).await; - read_small_put_body_into(&mut body, &mut *buf, size).await?; - Ok(buf) -} - -/// Read small PUT body into a directly-allocated buffer, bypassing BytesPool. -/// Used for objects ≤4KiB where pool contention under high concurrency -/// outweighs the allocation cost. -async fn read_small_put_body_exact_direct(mut body: R, size: usize) -> S3Result>> -where - R: AsyncRead + Unpin, -{ - let mut buf = Vec::with_capacity(size); - read_small_put_body_into(&mut body, &mut buf, size).await?; - Ok(std::io::Cursor::new(buf)) -} - -async fn read_zero_copy_put_body_exact(mut body: S, size: usize) -> S3Result -where - S: futures::Stream> + Unpin, - E: Into, -{ - let mut chunks = Vec::new(); - let mut filled = 0usize; - - while filled < size { - let Some(chunk) = body.next().await else { - return Err(s3_error!(IncompleteBody)); - }; - let chunk = chunk.map_err(|err| ApiError::from(s3s_body_error_to_io(err.into())))?; - if chunk.is_empty() { - continue; - } - if filled.saturating_add(chunk.len()) > size { - return Err(s3_error!(UnexpectedContent)); - } - - rustfs_io_metrics::record_zero_copy_buffer_operation("put_chunk", chunk.len()); - filled += chunk.len(); - chunks.push(chunk); - } - - while let Some(chunk) = body.next().await { - let chunk = chunk.map_err(|err| ApiError::from(s3s_body_error_to_io(err.into())))?; - if !chunk.is_empty() { - return Err(s3_error!(UnexpectedContent)); - } - } - - Ok(ChunkedBytesReader::new(chunks)) -} - -pub(crate) fn object_seek_support_threshold() -> usize { - static OBJECT_SEEK_SUPPORT_THRESHOLD: OnceLock = OnceLock::new(); - *OBJECT_SEEK_SUPPORT_THRESHOLD.get_or_init(|| { - rustfs_utils::get_env_usize( - rustfs_config::ENV_OBJECT_SEEK_SUPPORT_THRESHOLD, - rustfs_config::DEFAULT_OBJECT_SEEK_SUPPORT_THRESHOLD, - ) - }) -} - -fn object_seek_support_concurrency_thresholds() -> (usize, usize) { - static OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS: OnceLock<(usize, usize)> = OnceLock::new(); - *OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS.get_or_init(|| { - let medium = rustfs_utils::get_env_usize( - rustfs_config::ENV_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD, - rustfs_config::DEFAULT_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD, - ) - .max(1); - let high = rustfs_utils::get_env_usize( - rustfs_config::ENV_OBJECT_HIGH_CONCURRENCY_THRESHOLD, - rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD, - ) - .max(medium + 1); - (medium, high) - }) -} - -fn concurrency_aware_seek_support_threshold(configured_threshold: i64, concurrent_requests: usize) -> i64 { - let (medium_threshold, high_threshold) = object_seek_support_concurrency_thresholds(); - let effective_threshold = configured_threshold.min(MAX_GET_OBJECT_MEMORY_BUFFER_BYTES); - - if concurrent_requests >= high_threshold.saturating_mul(2) { - return effective_threshold.min(VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); - } - if concurrent_requests >= high_threshold { - return effective_threshold.min(HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); - } - if concurrent_requests >= medium_threshold { - return effective_threshold.min(MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); - } - - effective_threshold -} - -fn should_buffer_get_object_in_memory( - info: &ObjectInfo, - response_content_length: i64, - part_number: Option, - has_range: bool, - concurrent_requests: usize, -) -> bool { - let configured_threshold = object_seek_support_threshold() as i64; - should_buffer_get_object_in_memory_with_threshold( - info, - response_content_length, - part_number, - has_range, - configured_threshold, - concurrent_requests, - is_get_seek_buffer_enabled(), - ) -} - -fn should_materialize_get_object_body_for_cache( - info: &ObjectInfo, - response_content_length: i64, - part_number: Option, - has_range: bool, - concurrent_requests: usize, -) -> bool { - let configured_threshold = object_seek_support_threshold() as i64; - should_buffer_get_object_in_memory_with_threshold( - info, - response_content_length, - part_number, - has_range, - configured_threshold, - concurrent_requests, - true, - ) -} - -fn should_buffer_get_object_in_memory_with_threshold( - _info: &ObjectInfo, - response_content_length: i64, - part_number: Option, - has_range: bool, - configured_threshold: i64, - concurrent_requests: usize, - seek_buffer_enabled: bool, -) -> bool { - if !seek_buffer_enabled || part_number.is_some() || has_range || response_content_length <= 0 || configured_threshold <= 0 { - return false; - } - if usize::try_from(response_content_length).is_err() { - return false; - } - - let effective_threshold = concurrency_aware_seek_support_threshold(configured_threshold, concurrent_requests); - if configured_threshold > MAX_GET_OBJECT_MEMORY_BUFFER_BYTES - && GET_OBJECT_BUFFER_THRESHOLD_WARNED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - warn!( - configured_threshold_bytes = configured_threshold, - hard_limit_bytes = MAX_GET_OBJECT_MEMORY_BUFFER_BYTES, - "RUSTFS_OBJECT_SEEK_SUPPORT_THRESHOLD exceeds safety cap; using capped in-memory buffer threshold" - ); - } - - if response_content_length > effective_threshold { - return false; - } - - true -} - -#[cfg(test)] -mod deadlock_request_guard_tests { - use super::DeadlockRequestGuard; - use crate::app::storage_api::object_usecase::deadlock_detector::{DeadlockDetector, RequestHangDetectionPolicy}; - use std::cell::Cell; - use std::rc::Rc; - use std::sync::Arc; - - #[test] - fn deadlock_request_guard_unregisters_on_drop() { - let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy { - enabled: true, - ..RequestHangDetectionPolicy::default() - })); - let request_id = "test-request-id".to_string(); - - detector.register_request(&request_id, "test request"); - assert_eq!(detector.tracked_count(), 1); - - { - let _guard = DeadlockRequestGuard::new(Arc::clone(&detector), request_id); - // `_guard` is dropped at the end of this scope, which should unregister the request. - } - - assert_eq!(detector.tracked_count(), 0); - } - - #[test] - fn deadlock_request_guard_skips_disabled_detector() { - let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy { - enabled: false, - ..RequestHangDetectionPolicy::default() - })); - let description_built = Rc::new(Cell::new(false)); - let description_built_for_closure = Rc::clone(&description_built); - - let guard = DeadlockRequestGuard::register_if_enabled(detector, "test-request-id", || { - description_built_for_closure.set(true); - "test request".to_string() - }); - - assert!(guard.is_none()); - assert!(!description_built.get()); - } -} - -async fn maybe_enqueue_transition_immediate(obj_info: &ObjectInfo, src: LcEventSrc) { - enqueue_transition_immediate(obj_info, src).await; -} - -/// Inject additional-checksum response headers (XXHash3/64/128, SHA-512) that s3s -/// cannot carry on its typed `*Output` structs. Centralized so that when s3s gains -/// typed fields for these algorithms, only this one function changes (fill the typed -/// field, drop the header insert) — and there is exactly one place that could ever -/// emit a duplicate header. Header names come from `ChecksumType::key()`, so they are -/// known-valid static strings. -pub(crate) fn inject_additional_checksum_headers(headers: &mut HeaderMap, pairs: &[(&'static str, String)]) { - for (name, value) in pairs { - match HeaderValue::from_str(value) { - Ok(header_value) => { - headers.insert(http::HeaderName::from_static(name), header_value); - } - Err(_) => warn!("Failed to parse {name} checksum header value; skipping"), - } - } -} - -fn inject_accept_ranges_header(headers: &mut HeaderMap) { - headers.insert(http::header::ACCEPT_RANGES, HeaderValue::from_static(ACCEPT_RANGES_BYTES)); -} - -/// Derive the response-header echo pairs for an additional-checksum algorithm -/// (XXHash3/64/128, SHA-512) from the server-computed content checksum, for -/// PutObject to echo back (#1256). Returns empty for the five s3s-typed algorithms -/// (they are echoed via typed fields) and when the value is not yet materialized -/// (e.g. a trailing checksum, whose value lands after the body — covered by e2e). -pub(crate) fn additional_checksum_echo_pairs(want: &Option) -> Vec<(&'static str, String)> { - let mut out = Vec::new(); - if let Some(cs) = want - && !cs.checksum_type.is_s3s_typed() - && !cs.encoded.is_empty() - && let Some(name) = cs.checksum_type.key() - { - out.push((name, cs.encoded.clone())); - } - out -} - -/// Extract trailing-header checksum values, overriding the corresponding input fields. -fn apply_trailing_checksums( - algorithm: Option<&str>, - trailing_headers: &Option, - checksums: &mut PutObjectChecksums, -) { - let Some(alg) = algorithm else { return }; - let Some(checksum_str) = trailing_headers.as_ref().and_then(|trailer| { - let key = match alg { - ChecksumAlgorithm::CRC32 => rustfs_rio::ChecksumType::CRC32.key(), - ChecksumAlgorithm::CRC32C => rustfs_rio::ChecksumType::CRC32C.key(), - ChecksumAlgorithm::SHA1 => rustfs_rio::ChecksumType::SHA1.key(), - ChecksumAlgorithm::SHA256 => rustfs_rio::ChecksumType::SHA256.key(), - ChecksumAlgorithm::CRC64NVME => rustfs_rio::ChecksumType::CRC64_NVME.key(), - _ => return None, - }; - trailer.read(|headers| { - headers - .get(key.unwrap_or_default()) - .and_then(|value| value.to_str().ok().map(|s| s.to_string())) - }) - }) else { - return; - }; - - match alg { - ChecksumAlgorithm::CRC32 => checksums.crc32 = checksum_str, - ChecksumAlgorithm::CRC32C => checksums.crc32c = checksum_str, - ChecksumAlgorithm::SHA1 => checksums.sha1 = checksum_str, - ChecksumAlgorithm::SHA256 => checksums.sha256 = checksum_str, - ChecksumAlgorithm::CRC64NVME => checksums.crc64nvme = checksum_str, - _ => (), - } -} - -/// Checksums resolved from stored (decrypted) metadata for a response. The five -/// legacy algorithms fill named fields; the additional algorithms land in `extra` -/// for raw-header response paths and DTOs that expose their newer typed fields. -#[derive(Default)] -pub(crate) struct ResponseChecksums { - pub(crate) crc32: Option, - pub(crate) crc32c: Option, - pub(crate) sha1: Option, - pub(crate) sha256: Option, - pub(crate) crc64nvme: Option, - pub(crate) checksum_type: Option, - pub(crate) extra: Vec<(&'static str, String)>, -} - -/// Split decrypted checksum pairs into the five legacy fields and the additional -/// algorithm values. Single source of truth for every response -/// path (GetObject / HeadObject / GetObjectAttributes / CompleteMultipartUpload), -/// replacing what used to be five copies of this match loop. -pub(crate) fn classify_response_checksums(pairs: I, is_multipart: bool) -> ResponseChecksums -where - I: IntoIterator, -{ - let mut c = ResponseChecksums::default(); - for (key, checksum) in pairs { - if key == AMZ_CHECKSUM_TYPE { - c.checksum_type = Some(ChecksumType::from(checksum)); - continue; - } - let ct = rustfs_rio::ChecksumType::from_string(key.as_str()); - match ct.base() { - rustfs_rio::ChecksumType::CRC32 => c.crc32 = Some(checksum), - rustfs_rio::ChecksumType::CRC32C => c.crc32c = Some(checksum), - rustfs_rio::ChecksumType::SHA1 => c.sha1 = Some(checksum), - rustfs_rio::ChecksumType::SHA256 => c.sha256 = Some(checksum), - rustfs_rio::ChecksumType::CRC64_NVME => c.crc64nvme = Some(checksum), - _ => { - if let Some(name) = ct.key() { - c.extra.push((name, checksum)); - } - } - } - } - if is_multipart && c.checksum_type.is_none() { - c.checksum_type = Some(ChecksumType::from("COMPOSITE".to_string())); - } - c -} - -#[derive(Default)] -struct PutObjectChecksums { - crc32: Option, - crc32c: Option, - sha1: Option, - sha256: Option, - crc64nvme: Option, -} - -struct PutObjectCommitResult { - obj_info: ObjectInfo, - put_versioned: bool, -} - -struct EagerPutCommitOwner { - task: Option>, - cancellation: tokio_util::sync::CancellationToken, - cancellation_grace: Duration, -} - -impl EagerPutCommitOwner { - fn new( - task: tokio::task::JoinHandle, - cancellation: tokio_util::sync::CancellationToken, - cancellation_grace: Duration, - ) -> Self { - Self { - task: Some(task), - cancellation, - cancellation_grace, - } - } - - async fn join(mut self) -> Result { - let result = self.task.as_mut().expect("eager PUT commit owner task must be present").await; - self.task = None; - result - } -} - -impl Drop for EagerPutCommitOwner { - fn drop(&mut self) { - let Some(mut task) = self.task.take() else { - return; - }; - if tokio::runtime::Handle::try_current().is_err() { - task.abort(); - return; - } - let cancellation = self.cancellation.clone(); - let cancellation_grace = self.cancellation_grace; - spawn_traced(async move { - if tokio::time::timeout(cancellation_grace, &mut task).await.is_err() { - cancellation.cancel(); - metrics::counter!("rustfs_put_commit_owner_deadline_total", "put_path" => "eager").increment(1); - warn!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_COMMIT_OWNER_DEADLINE, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - state = "cancellation_requested", - cancellation_grace_ms = cancellation_grace.as_millis() as u64, - "cancelled eager PutObject commit owner exceeded its grace period and requested storage cleanup" - ); - let _ = task.await; - } - }); - } -} - -fn successful_delete_audit_objects( - delete: &s3s::dto::Delete, - successful_results: impl IntoIterator, -) -> Vec { - delete - .objects - .iter() - .zip(successful_results) - .filter(|(_, successful)| *successful) - .map(|(requested, _)| AuditObjectVersion::new(requested.key.clone(), requested.version_id.clone())) - .collect() -} - -fn normalize_delete_objects_version_id( - version_id: Option, -) -> std::result::Result<(Option, Option), String> { - let version_id = version_id.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); - match version_id { - Some(id) => { - if id.eq_ignore_ascii_case("null") { - Ok((Some("null".to_string()), Some(Uuid::nil()))) - } else { - let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; - Ok((Some(id), Some(uuid))) - } - } - None => Ok((None, None)), - } -} - -#[cfg(test)] -type DeleteSnapshotTestHook = (String, Arc, Arc); -#[cfg(test)] -type PutPostStoreTestHook = (String, Arc, Arc); - -#[cfg(test)] -static DELETE_SNAPSHOT_TEST_HOOK: OnceLock>> = OnceLock::new(); -#[cfg(test)] -static DELETE_SOURCE_TEST_HOOK: OnceLock>> = OnceLock::new(); -#[cfg(test)] -static DELETE_OBJECTS_AUTH_TEST_HOOK: OnceLock>> = OnceLock::new(); -#[cfg(test)] -static PUT_POST_STORE_TEST_HOOK: OnceLock>> = OnceLock::new(); - -#[cfg(test)] -pub(crate) fn install_delete_snapshot_test_hook( - bucket: String, - loaded: Arc, - resume: Arc, -) { - *DELETE_SNAPSHOT_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete snapshot test hook lock should not be poisoned") = Some((bucket, loaded, resume)); -} - -#[cfg(test)] -async fn wait_for_delete_snapshot_test_hook(bucket: &str) { - let hook = { - let mut slot = DELETE_SNAPSHOT_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete snapshot test hook lock should not be poisoned"); - if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { - slot.take() - } else { - None - } - }; - if let Some((_bucket, loaded, resume)) = hook { - loaded.wait().await; - resume.wait().await; - } -} - -#[cfg(test)] -pub(crate) fn install_delete_source_test_hook( - bucket: String, - loaded: Arc, - resume: Arc, -) { - *DELETE_SOURCE_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete source test hook lock should not be poisoned") = Some((bucket, loaded, resume)); -} - -#[cfg(test)] -async fn wait_for_delete_source_test_hook(bucket: &str) { - let hook = { - let mut slot = DELETE_SOURCE_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete source test hook lock should not be poisoned"); - if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { - slot.take() - } else { - None - } - }; - if let Some((_bucket, loaded, resume)) = hook { - loaded.wait().await; - resume.wait().await; - } -} - -#[cfg(test)] -pub(crate) fn install_delete_objects_auth_test_hook( - bucket: String, - loaded: Arc, - resume: Arc, -) { - *DELETE_OBJECTS_AUTH_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete objects auth test hook lock should not be poisoned") = Some((bucket, loaded, resume)); -} - -#[cfg(test)] -async fn wait_for_delete_objects_auth_test_hook(bucket: &str) { - let hook = { - let mut slot = DELETE_OBJECTS_AUTH_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete objects auth test hook lock should not be poisoned"); - if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { - slot.take() - } else { - None - } - }; - if let Some((_bucket, loaded, resume)) = hook { - loaded.wait().await; - resume.wait().await; - } -} - -#[cfg(test)] -fn install_put_post_store_test_hook(bucket: String, entered: Arc, resume: Arc) { - *PUT_POST_STORE_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("PUT post-store test hook lock should not be poisoned") = Some((bucket, entered, resume)); -} - -#[cfg(test)] -async fn wait_for_put_post_store_test_hook(bucket: &str) { - let hook = { - let mut slot = PUT_POST_STORE_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("PUT post-store test hook lock should not be poisoned"); - if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { - slot.take() - } else { - None - } - }; - if let Some((_bucket, entered, resume)) = hook { - entered.wait().await; - resume.wait().await; - } -} - -fn build_put_object_expiration_header(event: &lifecycle::Event) -> Option { - if !event.action.delete() { - return None; - } - - let expire_time = event.due?; - - if event.rule_id.is_empty() || expire_time == OffsetDateTime::UNIX_EPOCH { - return None; - } - - let expiry_date = expire_time.format(&Rfc3339).ok()?; - Some(format!("expiry-date=\"{}\", rule-id=\"{}\"", expiry_date, event.rule_id)) -} - -fn enrich_delete_replication_state_if_needed( - snapshot: &DeleteReplicationConfigSnapshot, - delete_object: &mut StorageDeletedObject, - obj_info: &ObjectInfo, -) { - let Some(replication_state) = delete_object.replication_state.as_ref() else { - return; - }; - if obj_info.replication_status != ReplicationStatusType::Replica - && !replication_state.replicate_decision_str.is_empty() - && (!replication_state.targets.is_empty() || !replication_state.purge_targets.is_empty()) - { - return; - } - - let Some(config) = snapshot.replication_config() else { - return; - }; - let version_id = if delete_object.delete_marker { - None - } else if delete_object.delete_marker_version_id.is_some() { - delete_object.delete_marker_version_id - } else { - delete_object.version_id - }; - if let Some(local_state) = delete_replication_state_from_config( - config, - obj_info, - version_id, - obj_info.replication_status == ReplicationStatusType::Replica, - ) { - set_deleted_object_replication_state(delete_object, &local_state); - } -} - -fn should_schedule_replica_delete_replication( - snapshot: &DeleteReplicationConfigSnapshot, - replication_source: &ObjectInfo, - version_id: Option, -) -> bool { - let Some(config) = snapshot.replication_config() else { - return false; - }; - - delete_replication_state_from_config(config, replication_source, version_id, true).is_some() -} - -fn internal_object_info_lookup_opts(mut opts: ObjectOptions) -> ObjectOptions { - opts.http_preconditions = None; - opts -} - -fn expected_current_version_id(headers: &HeaderMap) -> S3Result> { - headers - .get(RUSTFS_EXPECTED_CURRENT_VERSION_ID) - .map(|value| { - let value = value - .to_str() - .map(str::trim) - .map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header"))?; - if value.eq_ignore_ascii_case("null") { - return Ok(Uuid::nil().to_string()); - } - Uuid::parse_str(value) - .map(|version| version.to_string()) - .map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header")) - }) - .transpose() -} - -fn validate_undo_delete_version(expected: Option<&str>, requested: Option<&str>) -> S3Result<()> { - if expected.is_some() && expected != requested { - return Err(s3_error!(PreconditionFailed)); - } - Ok(()) -} - -fn copy_namespace_lock_error(bucket: &str, object: &str, mode: &'static str, err: rustfs_lock::LockError) -> StorageError { - match err { - rustfs_lock::LockError::QuorumNotReached { required, achieved } => StorageError::NamespaceLockQuorumUnavailable { - mode, - bucket: bucket.to_owned(), - object: object.to_owned(), - required, - achieved, - }, - other => StorageError::Lock(other), - } -} - -async fn acquire_self_copy_namespace_lock(store: &S, bucket: &str, object: &str) -> S3Result -where - S: NamespaceLocking + ?Sized, -{ - let object = encode_dir_object(object); - let lock = store.new_ns_lock(bucket, &object).await.map_err(ApiError::from)?; - lock.get_write_lock(get_lock_acquire_timeout()) - .await - .map_err(|err| ApiError::from(copy_namespace_lock_error(bucket, &object, "write", err)).into()) -} - -pub(crate) async fn acquire_copy_bucket_lifecycle_lock(store: &S, bucket: &str) -> S3Result -where - S: NamespaceLocking + ?Sized, -{ - let lock = store - .new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT) - .await - .map_err(ApiError::from)?; - lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(|err| { - ApiError::from(copy_namespace_lock_error( - bucket, - BUCKET_LIFECYCLE_LOCK_OBJECT, - "bucket_lifecycle_read", - err, - )) - .into() - }) -} - -pub(crate) async fn acquire_copy_bucket_lifecycle_locks( - store: &S, - source_bucket: &str, - destination_bucket: &str, -) -> S3Result<(NamespaceLockGuard, Option)> -where - S: NamespaceLocking + ?Sized, -{ - if source_bucket == destination_bucket { - return Ok((acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?, None)); - } - - if source_bucket < destination_bucket { - let source_guard = acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?; - let destination_guard = acquire_copy_bucket_lifecycle_lock(store, destination_bucket).await?; - Ok((source_guard, Some(destination_guard))) - } else { - let destination_guard = acquire_copy_bucket_lifecycle_lock(store, destination_bucket).await?; - let source_guard = acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?; - Ok((source_guard, Some(destination_guard))) - } -} - -const AMZ_SNOWBALL_EXTRACT_COMPAT: &str = "X-Amz-Snowball-Auto-Extract"; -#[cfg(test)] -const AMZ_SNOWBALL_PREFIX_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Prefix"; -#[cfg(test)] -const AMZ_SNOWBALL_IGNORE_DIRS_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Ignore-Dirs"; -#[cfg(test)] -const AMZ_SNOWBALL_IGNORE_ERRORS_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Ignore-Errors"; -const AMZ_META_PREFIX_LOWER: &str = "x-amz-meta-"; -const SNOWBALL_PREFIX_SUFFIX_LOWER: &str = "snowball-prefix"; -const SNOWBALL_IGNORE_DIRS_SUFFIX_LOWER: &str = "snowball-ignore-dirs"; -const SNOWBALL_IGNORE_ERRORS_SUFFIX_LOWER: &str = "snowball-ignore-errors"; -const SNOWBALL_PREFIX_HEADER_KEYS: &[&str] = &[AMZ_MINIO_SNOWBALL_PREFIX, AMZ_SNOWBALL_PREFIX, AMZ_RUSTFS_SNOWBALL_PREFIX]; -const SNOWBALL_IGNORE_DIRS_HEADER_KEYS: &[&str] = &[ - AMZ_MINIO_SNOWBALL_IGNORE_DIRS, - AMZ_SNOWBALL_IGNORE_DIRS, - AMZ_RUSTFS_SNOWBALL_IGNORE_DIRS, -]; -const SNOWBALL_IGNORE_ERRORS_HEADER_KEYS: &[&str] = &[ - AMZ_MINIO_SNOWBALL_IGNORE_ERRORS, - AMZ_SNOWBALL_IGNORE_ERRORS, - AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, -]; - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct PutObjectExtractOptions { - prefix: Option, - ignore_dirs: bool, - ignore_errors: bool, -} - -fn header_value_is_true(headers: &HeaderMap, key: &str) -> bool { - headers - .get(key) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.trim().eq_ignore_ascii_case("true")) -} - -fn is_put_object_extract_requested(headers: &HeaderMap) -> bool { - header_value_is_true(headers, AMZ_SNOWBALL_EXTRACT) || header_value_is_true(headers, AMZ_SNOWBALL_EXTRACT_COMPAT) -} - -fn trimmed_header_value(headers: &HeaderMap, key: &str) -> Option { - headers - .get(key) - .and_then(|value| value.to_str().ok()) - .map(|value| value.trim().to_string()) -} - -fn is_exact_snowball_meta_key(key: &str, exact_keys: &[&str]) -> bool { - exact_keys.iter().any(|exact_key| key.eq_ignore_ascii_case(exact_key)) -} - -fn snowball_meta_value_by_suffix(headers: &HeaderMap, suffix_lower: &str, exact_keys: &[&str]) -> Option { - for (name, value) in headers { - let key = name.as_str(); - if key.starts_with(AMZ_META_PREFIX_LOWER) - && key.ends_with(suffix_lower) - && !is_exact_snowball_meta_key(key, exact_keys) - && let Ok(parsed) = value.to_str() - { - return Some(parsed.trim().to_string()); - } - } - - None -} - -fn snowball_meta_value(headers: &HeaderMap, exact_keys: &[&str], suffix_lower: &str) -> Option { - for key in exact_keys { - if let Some(value) = trimmed_header_value(headers, key) { - return Some(value); - } - } - - snowball_meta_value_by_suffix(headers, suffix_lower, exact_keys) -} - -fn snowball_meta_flag(headers: &HeaderMap, exact_keys: &[&str], suffix_lower: &str) -> bool { - snowball_meta_value(headers, exact_keys, suffix_lower).is_some_and(|value| value.eq_ignore_ascii_case("true")) -} - -/// Validates that an archive entry path does not escape the target bucket. -/// -/// Delegates to [`rustfs_utils::path::validate_extract_relative_path`] and wraps -/// the result as an S3 error on failure. -pub fn validate_extract_relative_path(path: &str) -> S3Result<()> { - rustfs_utils::path::validate_extract_relative_path(path).map_err(|msg| s3_error!(InvalidArgument, "{msg}")) -} - -fn normalize_snowball_prefix(prefix: &str) -> S3Result> { - let normalized = prefix.trim().trim_matches('/'); - if normalized.is_empty() { - return Ok(None); - } - - validate_extract_relative_path(normalized)?; - - Ok(Some(normalized.to_string())) -} - -/// Normalizes an archive entry key by applying a prefix, trimming slashes, -/// and ensuring directory entries end with `/`. -/// -/// Delegates to [`rustfs_utils::path::normalize_extract_entry_key`] and wraps -/// the result as an S3 error on failure. -pub fn normalize_extract_entry_key(path: &str, prefix: Option<&str>, is_dir: bool) -> S3Result { - rustfs_utils::path::normalize_extract_entry_key(path, prefix, is_dir).map_err(|msg| s3_error!(InvalidArgument, "{msg}")) -} - -fn map_extract_archive_error(err: impl std::fmt::Display) -> S3Error { - s3_error!(InvalidArgument, "Failed to process archive entry: {}", err) -} - -#[derive(Debug, Default)] -struct ExtractEntryPaxAuthorization { - headers: HeaderMap, - object_lock_legal_hold_status: Option, - object_lock_mode: Option, - object_lock_retain_until_date: Option, -} - -async fn apply_extract_entry_pax_extensions( - entry: &mut tokio_tar::Entry>, - bucket: &str, - object_name: &str, - object_lock_config_state: &metadata_sys::ObjectLockConfigState, - metadata: &mut HashMap, - opts: &mut ObjectOptions, -) -> S3Result -where - R: AsyncRead + Send + Unpin + 'static, -{ - let Some(extensions) = entry.pax_extensions().await.map_err(map_extract_archive_error)? else { - return Ok(ExtractEntryPaxAuthorization::default()); - }; - - let mut pax_headers = HeaderMap::new(); - let mut pax_version_id = None; - for ext in extensions { - let ext = ext.map_err(map_extract_archive_error)?; - let key = ext.key().map_err(map_extract_archive_error)?; - let value = ext.value().map_err(map_extract_archive_error)?; - - if let Some(meta_key) = key.strip_prefix("minio.metadata.") { - if !meta_key.is_empty() { - let name = http::HeaderName::from_bytes(meta_key.as_bytes()) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata header"))?; - let header_value = HeaderValue::from_str(value) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata value"))?; - preserve_unclassified_user_metadata(metadata, name.as_str(), value); - pax_headers.insert(name, header_value); - } - continue; - } - - if key == "minio.versionId" && !value.is_empty() { - if Uuid::parse_str(value).is_err() { - return Err(s3_error!(InvalidArgument, "Invalid Snowball PAX version ID")); - } - pax_version_id = Some(value.to_string()); - } - } - - let has_replica_status = pax_headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS); - if let Some(value) = pax_headers.get(AMZ_BUCKET_REPLICATION_STATUS) { - let status = value - .to_str() - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball replication status"))?; - if !status.eq_ignore_ascii_case(ReplicationStatusType::Replica.as_str()) { - return Err(s3_error!(InvalidArgument, "Invalid Snowball replication status")); - } - pax_headers.insert(AMZ_BUCKET_REPLICATION_STATUS, HeaderValue::from_static("REPLICA")); - } - - let authorization_headers = pax_headers.clone(); - - if let Some(value) = pax_headers.remove("x-amz-tagging") { - let value = value - .to_str() - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball object tagging value"))?; - metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), value.to_owned()); - } - - let object_lock_mode = pax_headers - .remove(AMZ_OBJECT_LOCK_MODE_LOWER) - .map(|value| { - value - .to_str() - .map(|value| ObjectLockMode::from(value.to_string())) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock mode")) - }) - .transpose()?; - let object_lock_retain_until_date = pax_headers - .remove(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) - .map(|value| { - let value = value - .to_str() - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date"))?; - OffsetDateTime::parse(value, &Rfc3339) - .map(Timestamp::from) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date")) - }) - .transpose()?; - let object_lock_legal_hold_status = pax_headers - .remove(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) - .map(|value| { - value - .to_str() - .map(|value| ObjectLockLegalHoldStatus::from(value.to_string())) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock legal-hold status")) - }) - .transpose()?; - opts.version_id = pax_version_id; - - extract_metadata_from_mime_with_object_name(&pax_headers, metadata, false, Some(object_name)); - if has_replica_status { - metadata.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)); - metadata.insert( - AMZ_BUCKET_REPLICATION_STATUS.to_string(), - ReplicationStatusType::Replica.as_str().to_string(), - ); - } - if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( - bucket, - object_lock_config_state, - object_lock_legal_hold_status.clone(), - object_lock_mode.clone(), - object_lock_retain_until_date.clone(), - )? { - metadata.extend(object_lock_metadata); - } - - Ok(ExtractEntryPaxAuthorization { - headers: authorization_headers, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - }) -} - -fn insert_expires_metadata(metadata: &mut HashMap, expires: Option<&Timestamp>) -> S3Result<()> { - if let Some(expires) = expires { - let mut formatted = Vec::new(); - expires - .format(TimestampFormat::HttpDate, &mut formatted) - .map_err(|e| ApiError::from(StorageError::other(format!("Invalid expires timestamp: {e}"))))?; - metadata.insert("expires".to_string(), String::from_utf8_lossy(&formatted).into_owned()); - } - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -fn apply_standard_object_metadata( - metadata: &mut HashMap, - cache_control: Option<&str>, - content_disposition: Option<&str>, - content_encoding: Option<&str>, - content_language: Option<&str>, - content_type: Option<&str>, - expires: Option<&Timestamp>, - website_redirect_location: Option<&str>, -) -> S3Result<()> { - if let Some(cache_control) = cache_control { - metadata.insert("cache-control".to_string(), cache_control.to_string()); - } - if let Some(content_disposition) = content_disposition { - metadata.insert("content-disposition".to_string(), content_disposition.to_string()); - } - if let Some(content_encoding) = content_encoding - && let Some(normalized_content_encoding) = normalize_content_encoding_for_storage(content_encoding) - { - metadata.insert("content-encoding".to_string(), normalized_content_encoding); - } - if let Some(content_language) = content_language { - metadata.insert("content-language".to_string(), content_language.to_string()); - } - if let Some(content_type) = content_type { - metadata.insert("content-type".to_string(), content_type.to_string()); - } - insert_expires_metadata(metadata, expires)?; - if let Some(website_redirect_location) = website_redirect_location { - metadata.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location.to_string()); - } - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -fn apply_put_request_metadata( - metadata: &mut HashMap, - headers: &HeaderMap, - object_name: &str, - cache_control: Option, - content_disposition: Option, - content_encoding: Option, - content_language: Option, - content_type: Option, - expires: Option, - website_redirect_location: Option, - tagging: Option, - storage_class: Option, -) -> S3Result<()> { - namespace_reserved_user_metadata(metadata); - apply_standard_object_metadata( - metadata, - cache_control.as_deref(), - content_disposition.as_deref(), - content_encoding.as_deref(), - content_language.as_deref(), - content_type.as_deref(), - expires.as_ref(), - website_redirect_location.as_deref(), - )?; - if let Some(tags) = tagging { - metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), tags); - } - if let Some(storage_class) = storage_class { - metadata.insert(AMZ_STORAGE_CLASS.to_string(), storage_class.as_str().to_string()); - } - - extract_metadata_from_mime_with_object_name(headers, metadata, true, Some(object_name)); - Ok(()) -} - -fn response_storage_class(info: &ObjectInfo, metadata: &HashMap) -> Option { - let stored_class = info - .storage_class - .as_deref() - .or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str)); - let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE - && !info.transitioned_object.tier.is_empty()) - .then_some(info.transitioned_object.tier.as_str()); - let effective_class = storageclass::effective_class(stored_class, transitioned_tier); - - (effective_class != storageclass::STANDARD).then(|| StorageClass::from(effective_class.to_string())) -} - -fn response_storage_class_for_object_attributes( - info: &ObjectInfo, - metadata: &HashMap, - requested: bool, -) -> Option { - if !requested { - return None; - } - - let stored_class = info - .storage_class - .as_deref() - .or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str)); - let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE - && !info.transitioned_object.tier.is_empty()) - .then_some(info.transitioned_object.tier.as_str()); - - Some(StorageClass::from( - storageclass::effective_class(stored_class, transitioned_tier).to_string(), - )) -} - -fn apply_put_request_object_lock_opts( - bucket: &str, - object_lock_config_state: &metadata_sys::ObjectLockConfigState, - object_lock_legal_hold_status: Option, - object_lock_mode: Option, - object_lock_retain_until_date: Option, - opts: &mut ObjectOptions, -) -> S3Result<()> { - if let Some(eval_metadata) = build_put_like_object_lock_metadata( - bucket, - object_lock_config_state, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - )? { - opts.eval_metadata = Some(eval_metadata); - } - - Ok(()) -} - -// Shared across Object Lock validation paths to keep the client-facing -// InvalidRequest message consistent. -pub(crate) const ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED: &str = - "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied"; - -pub(crate) fn build_put_like_object_lock_metadata( - bucket: &str, - object_lock_config_state: &metadata_sys::ObjectLockConfigState, - object_lock_legal_hold_status: Option, - object_lock_mode: Option, - object_lock_retain_until_date: Option, -) -> S3Result>> { - if object_lock_legal_hold_status.is_none() && object_lock_mode.is_none() && object_lock_retain_until_date.is_none() { - return Ok(None); - } - - let retention = match (object_lock_mode, object_lock_retain_until_date) { - (Some(mode), Some(retain_until_date)) => Some(ObjectLockRetention { - mode: Some(ObjectLockRetentionMode::from(mode.as_str().to_string())), - retain_until_date: Some(retain_until_date), - }), - (Some(_), None) | (None, Some(_)) => { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED.to_string(), - )); - } - (None, None) => None, - }; - - validate_bucket_object_lock_enabled_state(bucket, object_lock_config_state)?; - - let mut eval_metadata = parse_object_lock_retention(retention)?; - eval_metadata.extend(parse_object_lock_legal_hold( - object_lock_legal_hold_status.map(|status| ObjectLockLegalHold { status: Some(status) }), - )?); - - if eval_metadata.is_empty() { - return Ok(None); - } - - Ok(Some(eval_metadata)) -} - -fn put_like_write_creates_new_version(opts: &ObjectOptions) -> bool { - opts.version_id.is_none() && opts.versioned && !opts.version_suspended -} - -pub(crate) fn validate_existing_object_lock_for_write( - object_lock_config_state: &metadata_sys::ObjectLockConfigState, - existing_obj_info: &ObjectInfo, - opts: &ObjectOptions, -) -> S3Result<()> { - if put_like_write_creates_new_version(opts) { - return Ok(()); - } - // An authorized replication write may replace the locked version only - // when the set layer's commit-lock LWW will judge every locking category, - // judged against the bucket's authoritative lock state (default retention - // included) exactly like the set-layer gate, which re-checks the same - // rule under the lock. A non-authoritative state or malformed lock - // metadata fails closed here. - if opts.replication_request { - let may_pass = replication_write_may_pass_worm_gate(object_lock_config_state, existing_obj_info, opts).map_err(|_| { - S3Error::with_message(S3ErrorCode::AccessDenied, "Object Lock state could not be verified.".to_string()) - })?; - return if may_pass { - Ok(()) - } else { - Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Object is locked and the replication write carries no source lock decision for it.".to_string(), - )) - }; - } - - let legal_hold = get_object_legalhold_meta(&existing_obj_info.user_defined); - if legal_hold.is_on() { - return Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Object has a legal hold and cannot be overwritten. Remove the legal hold first.".to_string(), - )); - } - - let retention = get_object_retention_meta(&existing_obj_info.user_defined); - if let Some(mode) = retention.mode - && mode == RetentionMode::Compliance - && is_retention_active(mode, retention.retain_until_date) - { - return Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Object is under COMPLIANCE retention and cannot be overwritten.".to_string(), - )); - } - - Ok(()) -} - -fn delete_creates_delete_marker(opts: &ObjectOptions) -> bool { - opts.version_id.is_none() && opts.versioned && !opts.version_suspended -} - -fn delete_removes_current_object(opts: &ObjectOptions) -> bool { - delete_request_targets_current( - opts.version_id - .as_deref() - .and_then(|version_id| Uuid::parse_str(version_id).ok()), - ) -} - -fn delete_request_targets_current(version_id: Option) -> bool { - version_id.is_none() || version_id.is_some_and(|version_id| version_id.is_nil()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DeleteMemoryUpdate { - DeleteMarker, - Object { size: u64, removed_current_object: bool }, -} - -fn delete_memory_update( - creates_delete_marker: bool, - committed_delete_marker: bool, - requested_current: bool, - accounting_size: Option, - removed_current_object: bool, -) -> Option { - if creates_delete_marker || (committed_delete_marker && requested_current) { - return Some(DeleteMemoryUpdate::DeleteMarker); - } - - (!committed_delete_marker) - .then_some(accounting_size) - .flatten() - .map(|size| DeleteMemoryUpdate::Object { - size, - removed_current_object, - }) -} - -async fn apply_delete_memory_update(bucket: &str, update: Option) { - match update { - Some(DeleteMemoryUpdate::DeleteMarker) => record_bucket_delete_marker_memory(bucket).await, - Some(DeleteMemoryUpdate::Object { - size, - removed_current_object, - }) => record_bucket_object_delete_memory(bucket, size, removed_current_object).await, - None => {} - } -} - -/// `DeleteObjects` is idempotent. A raw filesystem `NotFound` can cross the -/// distributed delete path instead of its usual typed missing-object error. -fn is_delete_objects_not_found(error: &EcstoreError) -> bool { - is_err_object_not_found(error) - || is_err_version_not_found(error) - || matches!(error, StorageError::Io(source) if source.kind() == std::io::ErrorKind::NotFound) -} - -/// Bounded concurrency for the per-object pre-delete stat fanout in -/// `execute_delete_objects` (backlog#929 / HP-8). Keeps the metadata reads for -/// a 1000-key batch from serializing while capping the disk fanout pressure. -const DELETE_OBJECTS_PRE_STAT_CONCURRENCY: usize = 16; - -/// backlog#929 (HP-8): whether the pre-delete `get_object_info` for one entry -/// of a DeleteObjects batch can be skipped without changing behavior. -/// -/// The stat result feeds four consumers, and each must be provably idle: -/// - the app-layer object-lock admission check never runs for deletes that -/// create a delete marker, and non-lock buckets cannot hold retention or -/// legal-hold metadata (`bucket_lock_enabled == false`); -/// - replication reads its authoritative source metadata later while the -/// SetDisks write lock is held, so it does not consume this advisory stat; -/// - usage accounting for delete-marker creation goes through -/// `record_bucket_delete_marker_memory` and never reads the object size -/// (`accounting_creates_delete_marker` is computed from the same versioning -/// snapshot the accounting branch uses); -/// - transitioned-object (ILM tier) cleanup journaling is a no-op for -/// delete-marker creation because no version is removed, so `ObjSweeper` -/// produces no journal entry regardless of the stat result. -/// -/// Object-lock enabled buckets always keep the stat, so their delete path is -/// byte-for-byte the pre-#929 one (see PR #4297). -fn can_skip_delete_objects_pre_stat( - bucket_lock_enabled: bool, - opts: &ObjectOptions, - accounting_creates_delete_marker: bool, -) -> bool { - !bucket_lock_enabled && delete_creates_delete_marker(opts) && accounting_creates_delete_marker -} - -fn complete_delete_noop( - helper: OperationHelper, - bucket: String, - key: String, - version_id: Option, -) -> (S3Result>, OperationHelper) { - let helper = helper - .event_name(EventName::ObjectRemovedNoOP) - .object(ObjectInfo { - name: key, - bucket, - ..Default::default() - }) - .version_id(version_id.unwrap_or_default()); - let result = Ok(S3Response::with_status(DeleteObjectOutput::default(), StatusCode::NO_CONTENT)); - let helper = helper.complete(&result); - (result, helper) -} - -fn delete_response_version_id(version_id: Option, synthetic_version_id: bool) -> Option { - if synthetic_version_id { - None - } else if version_id == Some(Uuid::nil()) { - Some(NULL_VERSION_ID.to_string()) - } else { - version_id.map(|version_id| version_id.to_string()) - } -} - -fn reduce_delete_objects_result<'a>( - object: &ObjectToDelete, - deleted: &'a StorageDeletedObject, - error: Option<&EcstoreError>, - synthetic_version_id: bool, -) -> Result<&'a StorageDeletedObject, s3s::dto::Error> { - match error { - None => Ok(deleted), - Some(error) if is_delete_objects_not_found(error) => Ok(deleted), - Some(error) => { - let api_error = ApiError::from(error.clone()); - Err(s3s::dto::Error { - code: Some(api_error.code.as_str().to_string()), - key: Some(object.object_name.clone()), - message: Some(api_error.message), - version_id: delete_response_version_id(object.version_id, synthetic_version_id), - }) - } - } -} - -fn resolve_put_object_extract_options(headers: &HeaderMap) -> S3Result { - let prefix = snowball_meta_value(headers, SNOWBALL_PREFIX_HEADER_KEYS, SNOWBALL_PREFIX_SUFFIX_LOWER) - .map(|value| normalize_snowball_prefix(&value)) - .transpose()? - .flatten(); - let ignore_dirs = snowball_meta_flag(headers, SNOWBALL_IGNORE_DIRS_HEADER_KEYS, SNOWBALL_IGNORE_DIRS_SUFFIX_LOWER); - let ignore_errors = snowball_meta_flag(headers, SNOWBALL_IGNORE_ERRORS_HEADER_KEYS, SNOWBALL_IGNORE_ERRORS_SUFFIX_LOWER); - - Ok(PutObjectExtractOptions { - prefix, - ignore_dirs, - ignore_errors, - }) -} - -fn put_object_extract_limits() -> ArchiveLimits { - ArchiveLimits::default() -} - -fn validate_put_object_extract_entry_count(count: usize, limits: ArchiveLimits) -> S3Result<()> { - if count > limits.max_entries { - return Err(s3_error!( - InvalidArgument, - "Archive entry count exceeds limit: count={}, limit={}", - count, - limits.max_entries - )); - } - Ok(()) -} - -fn validate_put_object_extract_entry_size(path: &str, size: u64, limits: ArchiveLimits) -> S3Result<()> { - if size > limits.max_entry_size { - return Err(s3_error!( - InvalidArgument, - "Archive entry size exceeds limit for {}: size={}, limit={}", - path, - size, - limits.max_entry_size - )); - } - Ok(()) -} - -fn validate_put_object_extract_total_size(total_size: u64, limits: ArchiveLimits) -> S3Result<()> { - if total_size > limits.max_total_unpacked_size { - return Err(s3_error!( - InvalidArgument, - "Archive total unpacked size exceeds limit: size={}, limit={}", - total_size, - limits.max_total_unpacked_size - )); - } - Ok(()) -} - -fn validate_put_object_extract_entry_path(path: &str, limits: ArchiveLimits) -> S3Result<()> { - if path.len() > limits.max_path_length { - return Err(s3_error!( - InvalidArgument, - "Archive entry path exceeds limit for {}: length={}, limit={}", - path, - path.len(), - limits.max_path_length - )); - } - Ok(()) -} - -fn is_sse_kms_requested(input: &PutObjectInput, headers: &HeaderMap) -> bool { - input - .server_side_encryption - .as_ref() - .is_some_and(|sse| sse.as_str().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) - || input.ssekms_key_id.is_some() - || headers - .get(AMZ_SERVER_SIDE_ENCRYPTION) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.trim().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) - || headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID) -} - -fn is_post_object_sse_kms_requested(input: &PutObjectInput, headers: &HeaderMap) -> bool { - is_sse_kms_requested(input, headers) -} - -async fn resolve_put_object_expiration(bucket: &str, obj_info: &ObjectInfo) -> Option { - let Ok((lifecycle_config, _)) = metadata_sys::get_lifecycle_config(bucket).await else { - debug!(bucket, state = "config_missing", "PUT object expiration config missing"); - return None; - }; - - let obj_opts = lifecycle::object_opts_from_object_info(obj_info); - let event = predict_lifecycle_expiration(&lifecycle_config, &obj_opts).await; - debug!( - bucket, - action = ?event.action, - rule_id = %event.rule_id, - due = ?event.due, - "PUT object expiration resolved" - ); - build_put_object_expiration_header(&event) -} - -/// Cadence for the "I/O queue congestion detected" WARN. Under sustained -/// overload (client concurrency at or above the disk-read permit pool) every -/// GET observes >=80% utilization, so an unthrottled WARN floods the log -/// from the already saturated hot path; congestion metrics stay per-request. -const IO_QUEUE_CONGESTION_WARN_INTERVAL_MS: u64 = 5_000; - -/// At-most-one-WARN-per-interval limiter for the I/O queue congestion log. -/// Callers supply monotonic milliseconds so tests can drive the clock. -struct IoQueueCongestionWarnThrottle { - /// Timestamp of the last emitted WARN; `u64::MAX` until the first one. - last_warn_ms: AtomicU64, - /// Congested requests left unlogged since the last emitted WARN. - suppressed: AtomicU64, -} - -impl IoQueueCongestionWarnThrottle { - const fn new() -> Self { - Self { - last_warn_ms: AtomicU64::new(u64::MAX), - suppressed: AtomicU64::new(0), - } - } - - /// Claim the right to emit one WARN. Returns the number of events - /// suppressed since the previous emission, or `None` while the interval - /// window is still closed (the event is counted, not logged). - fn claim(&self, now_ms: u64) -> Option { - let last = self.last_warn_ms.load(Ordering::Relaxed); - let window_open = last == u64::MAX || now_ms.saturating_sub(last) >= IO_QUEUE_CONGESTION_WARN_INTERVAL_MS; - if window_open - && self - .last_warn_ms - .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - Some(self.suppressed.swap(0, Ordering::Relaxed)) - } else { - self.suppressed.fetch_add(1, Ordering::Relaxed); - None - } - } - - /// Monotonic milliseconds since the first call, for production callers. - fn now_ms() -> u64 { - static ANCHOR: OnceLock = OnceLock::new(); - ANCHOR.get_or_init(std::time::Instant::now).elapsed().as_millis() as u64 - } -} - -static IO_QUEUE_CONGESTION_WARN_THROTTLE: IoQueueCongestionWarnThrottle = IoQueueCongestionWarnThrottle::new(); - -#[derive(Clone, Default)] -pub struct DefaultObjectUsecase { - context: Option>, - #[cfg(test)] - get_object_timeout_policy: Option, -} - -async fn track_object_read_setup(health: Option<&ObjectTrafficHealth>, future: F) -> F::Output -where - F: std::future::Future, -{ - let _progress = health.and_then(ObjectTrafficHealth::track_read_storage); - future.await -} - -impl DefaultObjectUsecase { - fn should_use_large_put_concurrency_tuning(size: i64) -> bool { - size >= DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES - } - - #[cfg(test)] - pub fn without_context() -> Self { - Self { - context: None, - get_object_timeout_policy: None, - } - } - - pub fn from_global() -> Self { - Self { - context: current_app_context(), - #[cfg(test)] - get_object_timeout_policy: None, - } - } - - /// Build the use-case bound to an explicit application context - /// (backlog#1052 S6): the per-server request path passes its own context - /// so the use-case resolves that server's store; `None` falls back to the - /// ambient default. - pub fn with_context(context: Option>) -> Self { - Self { - context, - #[cfg(test)] - get_object_timeout_policy: None, - } - } - - #[cfg(test)] - fn with_context_and_get_object_timeout_policy( - context: Option>, - get_object_timeout_policy: GetObjectTimeoutPolicy, - ) -> Self { - Self { - context, - get_object_timeout_policy: Some(get_object_timeout_policy), - } - } - - fn bucket_metadata_sys(&self) -> Option>> { - self.context.as_ref().and_then(|context| context.bucket_metadata().handle()) - } - - fn object_store(&self) -> Option> { - current_object_store_handle_for_context(self.context.as_deref()) - } - - fn object_data_cache(&self) -> Arc { - current_object_data_cache_for_context(self.context.as_deref()) - } - - fn object_traffic_health(&self) -> Option> { - self.context - .as_ref() - .map(|context| context.object_traffic_health()) - .or_else(|| current_app_context().map(|context| context.object_traffic_health())) - } - - fn base_buffer_size(&self) -> usize { - self.context - .clone() - .or_else(current_app_context) - .map(|context| context.buffer_config().get().base_config.default_unknown) - .unwrap_or_else(|| RustFSBufferConfig::default().base_config.default_unknown) - } - - async fn check_bucket_quota(&self, bucket: &str, op: QuotaOperation, size: u64) -> S3Result> { - let Some(metadata_sys) = self.bucket_metadata_sys() else { - return Ok(None); - }; - let quota_checker = QuotaChecker::new(metadata_sys); - map_quota_check_outcome(bucket, quota_checker.check_quota(bucket, op, size).await).map(Some) - } - - fn build_memory_bytes_blob( - bytes: Bytes, - response_content_length: i64, - source: &'static str, - lifecycle: GetObjectBodyLifecycle, - ) -> StreamingBlob { - let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled(); - let memory_blob_start = get_stage_metrics_enabled.then(std::time::Instant::now); - let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now); - let bytes_len = bytes.len(); - let guard = rustfs_io_metrics::track_get_object_buffered_bytes(bytes_len); - let remaining = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - let blob = if is_get_small_body_once_enabled() && bytes_len == remaining { - let owner = MemoryOnceBodyOwner::new(bytes, guard, lifecycle); - StreamingBlob::from_bytes(Bytes::from_owner(owner)) - } else { - StreamingBlob::new(MemoryTrackedBytesStream::new(bytes, remaining, source, guard, lifecycle)) - }; - if let Some(handoff_start) = handoff_start { - rustfs_io_metrics::record_get_object_response_handoff( - "single_chunk", - source, - bytes_len, - response_content_length, - handoff_start.elapsed().as_secs_f64(), - ); - } - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_MEMORY_BLOB, memory_blob_start); - blob - } - - fn build_memory_blob( - buf: Vec, - response_content_length: i64, - source: &'static str, - lifecycle: GetObjectBodyLifecycle, - ) -> StreamingBlob { - Self::build_memory_bytes_blob(Bytes::from(buf), response_content_length, source, lifecycle) - } - - fn select_stream_buffer_strategy( - response_content_length: i64, - optimal_buffer_size: usize, - enable_readahead: bool, - has_range: bool, - ) -> (usize, GetObjectStreamStrategy) { - if enable_readahead && !has_range && response_content_length >= LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES { - let expanded_buffer_size = optimal_buffer_size - .saturating_mul(LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER) - .min(LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES) - .max(optimal_buffer_size); - return (expanded_buffer_size, GetObjectStreamStrategy::LargeSequentialReadahead); - } - - (optimal_buffer_size, GetObjectStreamStrategy::Standard) - } - - #[allow(clippy::too_many_arguments)] - fn build_reader_blob( - reader: R, - response_content_length: i64, - request_id: &str, - content_range: Option<&str>, - stream_buffer_size: usize, - stream_strategy: GetObjectStreamStrategy, - bucket: &str, - key: &str, - lifecycle: GetObjectBodyLifecycle, - resume: Option>, - ) -> StreamingBlob - where - R: AsyncRead + Send + Sync + Unpin + 'static, - { - let streaming_blob_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - let tuned_stream_buffer_size = - tune_reader_stream_buffer_size(stream_buffer_size, response_content_length, stream_strategy); - let (stream_buffer_size, buffer_source) = - resolve_reader_stream_buffer_size(tuned_stream_buffer_size, get_reader_stream_buffer_size_override()); - let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled(); - if get_stage_metrics_enabled { - rustfs_io_metrics::record_get_object_stream_strategy( - stream_strategy.as_str(), - stream_buffer_size, - response_content_length, - ); - } - let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now); - let reader = GetObjectStreamingReader::new( - reader, - bucket, - key, - request_id, - content_range.map(|content_range| content_range.to_string()), - expected, - get_object_disk_read_timeout(), - lifecycle, - resume, - ); - let stream = GetObjectReaderStream::new(reader, stream_buffer_size, expected, stream_strategy.as_str(), buffer_source) - .with_diagnostics(bucket, key, request_id); - let blob = StreamingBlob::new(stream); - if let Some(handoff_start) = handoff_start { - rustfs_io_metrics::record_get_object_response_handoff( - stream_strategy.as_str(), - buffer_source, - stream_buffer_size, - response_content_length, - handoff_start.elapsed().as_secs_f64(), - ); - } - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAMING_BLOB, streaming_blob_start); - blob - } - - fn init_get_object_bootstrap(&self, bucket: &str, key: &str, request_id: &str) -> S3Result { - #[cfg(test)] - let timeout_config = self - .get_object_timeout_policy - .clone() - .unwrap_or_else(GetObjectTimeoutPolicy::cached_from_env); - #[cfg(not(test))] - let timeout_config = GetObjectTimeoutPolicy::cached_from_env(); - let wrapper = RequestTimeoutWrapper::with_request_id(timeout_config.clone(), request_id.to_string()); - let request_start = std::time::Instant::now(); - let request_guard = ConcurrencyManager::track_request(); - let concurrent_requests = GetObjectGuard::concurrent_requests(); - - let deadlock_detector = deadlock_detector::get_deadlock_detector(); - let deadlock_request_guard = DeadlockRequestGuard::register_if_enabled(deadlock_detector, wrapper.request_id(), || { - format!("GetObject {bucket}/{key}") - }); - - Self::ensure_get_object_not_timed_out(&wrapper, &timeout_config, bucket, key, GetObjectTimeoutStage::BeforeProcessing)?; - - debug!( - "GetObject request started with {} concurrent requests, timeout={:?}", - concurrent_requests, timeout_config.get_object_timeout - ); - - Ok(GetObjectBootstrap { - timeout_config, - wrapper, - request_start, - request_guard, - _deadlock_request_guard: deadlock_request_guard, - concurrent_requests, - }) - } - - fn validate_get_object_part_number(part_number: Option, info: &ObjectInfo) -> S3Result<()> { - if let Some(part_number) = part_number - && part_number > 1 - && !info.parts.iter().any(|part| part.number == part_number) - { - return Err(s3_error!(InvalidPart)); - } - Ok(()) - } - - fn validate_get_object_before_cold_fill(headers: &HeaderMap, part_number: Option, info: &ObjectInfo) -> S3Result<()> { - check_preconditions(headers, info)?; - Self::validate_get_object_part_number(part_number, info) - } - - /// How long a GET waits for a disk read permit before degrading to a - /// permit-less read. Cached: consulted per GET. Zero disables the bound. - fn disk_permit_wait_timeout() -> Duration { - static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); - *CACHED.get_or_init(|| { - Duration::from_secs(rustfs_utils::get_env_u64( - rustfs_config::ENV_OBJECT_DISK_PERMIT_WAIT_TIMEOUT, - rustfs_config::DEFAULT_OBJECT_DISK_PERMIT_WAIT_TIMEOUT, - )) - }) - } - - async fn acquire_get_object_io_planning( - manager: &ConcurrencyManager, - request_timeout: Option>, - bucket: &str, - key: &str, - ) -> S3Result { - let permit_wait_start = std::time::Instant::now(); - let disk_permit = Self::admit_get_object_disk_read(manager, bucket, key).await?; - let permit_wait_duration = permit_wait_start.elapsed(); - - if let Some(timeout) = request_timeout { - Self::ensure_get_object_not_timed_out( - timeout.wrapper, - timeout.policy, - bucket, - key, - GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration }, - )?; - } - - let queue_status = manager.io_queue_status(); - let queue_snapshot = GetObjectQueueSnapshot::from_available_permits( - queue_status.total_permits, - queue_status.total_permits.saturating_sub(queue_status.permits_in_use), - ); - let queue_utilization = queue_snapshot.utilization_percent(); - - if queue_snapshot.is_congested(80.0) { - // Metrics count every congested request; only the WARN is rate - // limited, because under saturation every GET crosses the - // threshold and per-request WARNs flood the log. - rustfs_io_metrics::record_io_queue_congestion(); - - if let Some(suppressed_warns) = IO_QUEUE_CONGESTION_WARN_THROTTLE.claim(IoQueueCongestionWarnThrottle::now_ms()) { - warn!( - bucket = %bucket, - key = %key, - queue_utilization = format!("{:.1}%", queue_utilization), - permits_in_use = queue_status.permits_in_use, - total_permits = queue_status.total_permits, - suppressed_warns, - "I/O queue congestion detected" - ); - } - } - - if let Some(timeout) = request_timeout { - Self::ensure_get_object_not_timed_out( - timeout.wrapper, - timeout.policy, - bucket, - key, - GetObjectTimeoutStage::BeforeRead, - )?; - } - - Ok(GetObjectIoPlanning { - disk_permit, - permit_wait_duration, - queue_status, - queue_utilization, - }) - } - - // Shared by the initial read path and the mid-stream resume reopen, which - // must hold the same admission token before touching disks. The permit - // wait inside is bounded by the primary-pool timeout. - async fn admit_get_object_disk_read( - manager: &ConcurrencyManager, - bucket: &str, - key: &str, - ) -> S3Result> { - let permit_wait_start = std::time::Instant::now(); - let permit_wait_timeout = Self::disk_permit_wait_timeout(); - // Permits are held for the whole body transfer, so slow clients can pin - // all of them while disks are idle. Bound the wait on the primary pool - // and, on timeout, admit from a bounded degraded overflow lane. Total - // concurrent disk-active GETs are hard-capped at - // `primary_cap + degraded_cap`; once that cap is reached we reject with - // `SlowDown` instead of reading without any admission token. Never - // proceed permit-less. - let disk_permit = match manager - .admit_disk_read(permit_wait_timeout) - .await - .map_err(|_| s3_error!(InternalError, "disk read semaphore closed"))? - { - DiskReadAdmission::Primary(permit) => Some(permit), - // Throttling disabled by config (primary cap 0): proceed without an - // admission token. Not a saturation bypass. - DiskReadAdmission::Unbounded => None, - DiskReadAdmission::Degraded(permit) => { - metrics::counter!("rustfs.get_object.disk_permit.degraded.total").increment(1); - warn!( - bucket = %bucket, - key = %key, - wait_ms = permit_wait_start.elapsed().as_millis() as u64, - "GetObject admitted into bounded degraded disk-read lane after primary pool saturation" - ); - Some(permit) - } - DiskReadAdmission::Rejected => { - metrics::counter!("rustfs.get_object.disk_permit.hard_reject.total").increment(1); - warn!( - bucket = %bucket, - key = %key, - wait_ms = permit_wait_start.elapsed().as_millis() as u64, - "GetObject rejected: disk-read hard concurrency cap reached" - ); - return Err(s3_error!( - SlowDown, - "disk read concurrency limit reached, please reduce your request rate" - )); - } - }; - Ok(disk_permit.map(GetObjectDiskPermit::new)) - } - - async fn acquire_cold_fill_io_planning( - manager: &'static ConcurrencyManager, - bucket: &str, - key: &str, - ) -> Result { - match Self::acquire_get_object_io_planning(manager, None, bucket, key).await { - Ok(io) => Ok(io), - Err(err) if err.code() == &S3ErrorCode::SlowDown => Err(ColdFillError::Storage(StorageError::SlowDown)), - Err(_) => Err(ColdFillError::DiskAdmissionClosed), - } - } - - fn get_object_io_planning_without_disk(manager: &ConcurrencyManager) -> GetObjectIoPlanning { - let queue_status = manager.io_queue_status(); - let queue_snapshot = GetObjectQueueSnapshot::from_available_permits( - queue_status.total_permits, - queue_status.total_permits.saturating_sub(queue_status.permits_in_use), - ); - GetObjectIoPlanning { - disk_permit: None, - permit_wait_duration: Duration::ZERO, - queue_utilization: queue_snapshot.utilization_percent(), - queue_status, - } - } - - /// Cheap request-shape validations, run before the bucket-existence store - /// lookup so invalid requests keep their InvalidArgument precedence. - fn validate_get_object_request(req: &S3Request) -> S3Result { - // Clone only the fields this path needs instead of the whole input. - let bucket = req.input.bucket.clone(); - let key = req.input.key.clone(); - let version_id = req.input.version_id.clone(); - let part_number = req.input.part_number; - let range = req.input.range; - - validate_object_key(&key, "GET")?; - - let part_number = parse_part_number_i32_to_usize(part_number, "GET")?; - - let rs = range.map(range_to_http_range_spec).transpose()?; - - if rs.is_some() && part_number.is_some() { - return Err(s3_error!(InvalidArgument, "range and part_number invalid")); - } - - Ok(GetObjectValidatedRequest { - bucket, - key, - version_id, - part_number, - rs, - }) - } - - async fn prepare_get_object_request_context( - validated: GetObjectValidatedRequest, - headers: &HeaderMap, - ) -> S3Result { - let GetObjectValidatedRequest { - bucket, - key, - version_id, - part_number, - rs, - } = validated; - - let opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), part_number, headers) - .await - .map_err(ApiError::from)?; - - Ok(GetObjectRequestContext { - version_id_for_event: version_id.unwrap_or_default(), - bucket, - key, - part_number, - rs, - opts, - }) - } - #[allow(clippy::too_many_arguments)] - async fn prepare_get_object_read_execution( - &self, - req: &S3Request, - manager: &'static ConcurrencyManager, - store: Arc, - wrapper: &RequestTimeoutWrapper, - timeout_config: &GetObjectTimeoutPolicy, - bucket: &str, - key: &str, - rs: Option, - opts: &ObjectOptions, - part_number: Option, - object_traffic_health: Option>, - ) -> S3Result { - let read_start = std::time::Instant::now(); - let read_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then_some(read_start); - let store_headers = project_ssec_transport_headers(&req.headers); - let cache_adapter = self.object_data_cache(); - if cache_adapter.is_disabled() || !cache_adapter.materialize_fill_enabled() { - let io_planning = Self::acquire_get_object_io_planning( - manager, - Some(GetObjectRequestTimeout { - wrapper, - policy: timeout_config, - }), - bucket, - key, - ) - .await?; - let reader = track_object_read_setup( - object_traffic_health.as_deref(), - store.get_object_reader(bucket, key, rs.clone(), store_headers, opts), - ) - .await - .map_err(map_get_object_reader_error)?; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; - return Ok(GetObjectPreparedRead { io_planning, read_setup }); - } - - // Preserve the legacy metadata-fanout bound without making followers - // hold a body-transfer permit while they wait on the cold-fill session. - let mut metadata_admission = Some( - Self::acquire_get_object_io_planning( - manager, - Some(GetObjectRequestTimeout { - wrapper, - policy: timeout_config, - }), - bucket, - key, - ) - .await?, - ); - let mut prepared = Some( - track_object_read_setup( - object_traffic_health.as_deref(), - store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts), - ) - .await - .map_err(map_get_object_reader_error)?, - ); - let mut cache_fill_allowed = true; - let mut legacy_hook_missed = false; - 'snapshot: { - let info = prepared - .as_ref() - .ok_or_else(|| s3_error!(InternalError, "prepared metadata snapshot is unavailable"))? - .object_info(); - // Preconditions, cache planning, and the authoritative hook lookup all - // run against one namespace-locked metadata snapshot. Cacheable misses - // release both the lock and short admission before joining cold fill. - let Some(response_content_length) = get_object_body_cache_plaintext_len(&rs, opts, info) else { - break 'snapshot; - }; - let cache_plan = build_get_object_body_cache_plan( - &cache_adapter, - GetObjectBodyCacheRequest { - bucket, - key, - info, - response_content_length, - has_range: rs.is_some(), - part_number, - encryption_applied: info.is_encrypted(), - }, - ); - - // The legacy hook is evaluated once, before cold-fill coordination. - // In-session producer retries never re-enter this snapshot block. - let legacy_probe = lookup_preplanned_get_object_body_cache_hook( - Arc::clone(&cache_adapter), - cache_plan.clone(), - bucket, - key, - &rs, - opts, - info, - ) - .await; - if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Ineligible) { - break 'snapshot; - } - Self::validate_get_object_before_cold_fill(&req.headers, part_number, info)?; - if let GetObjectBodyCacheHookLookup::Hit(body) = legacy_probe { - drop(metadata_admission.take()); - let info = prepared - .take() - .ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))? - .into_object_info(); - let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; - return Ok(GetObjectPreparedRead { - io_planning: Self::get_object_io_planning_without_disk(manager), - read_setup, - }); - } - if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Miss) { - legacy_hook_missed = true; - } - if !legacy_hook_missed - && let GetObjectBodyCacheLookup::Hit(body) = lookup_get_object_body_cache_hit(&cache_adapter, &cache_plan).await - { - drop(metadata_admission.take()); - let info = prepared - .take() - .ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))? - .into_object_info(); - let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; - return Ok(GetObjectPreparedRead { - io_planning: Self::get_object_io_planning_without_disk(manager), - read_setup, - }); - } - - let GetObjectBodyCachePlan::Cacheable(engine_plan) = &cache_plan else { - break 'snapshot; - }; - let Some(cache_key) = cache_plan.key().cloned() else { - break 'snapshot; - }; - let expected = usize::try_from(response_content_length) - .map_err(|_| s3_error!(InternalError, "cold-fill body length is not representable"))?; - let response_size = u64::try_from(response_content_length) - .map_err(|_| s3_error!(InternalError, "cold-fill body length is negative"))?; - let waiter_deadline = cold_fill_deadline(wrapper, timeout_config, response_size); - let proposed_producer_deadline = cold_fill_producer_deadline(timeout_config, response_size); - let coordinator = cache_adapter.cold_fill_coordinator(); - let info = prepared - .take() - .ok_or_else(|| s3_error!(InternalError, "prepared cold-fill reader is unavailable"))? - .into_object_info(); - drop(metadata_admission.take()); - let outcome = coordinate_cold_fill(&coordinator, cache_key, waiter_deadline, Some(proposed_producer_deadline), { - let adapter = &cache_adapter; - let headers = &store_headers; - let store = &store; - let range = &rs; - let object_traffic_health = &object_traffic_health; - move |producer| { - let adapter = Arc::clone(adapter); - let engine_plan = engine_plan.clone(); - let h = headers.clone(); - let store = Arc::clone(store); - let range = range.clone(); - let bucket = bucket.to_owned(); - let key = key.to_owned(); - let opts = opts.clone(); - let object_traffic_health = object_traffic_health.as_ref().map(Arc::clone); - async move { - let producer_deadline = producer.deadline(); - let cancellation = producer.cancellation_token(); - let second_chance = match await_cold_fill_startup( - lookup_cold_fill_second_chance(&adapter, &engine_plan), - &cancellation, - producer_deadline, - ) - .await - { - Ok(body) => body, - Err(ColdFillStartupWaitError::Cancelled) => { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - Err(ColdFillStartupWaitError::DeadlineExceeded) => { - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - return; - } - }; - if let Some(body) = second_chance { - producer.finish_shared(Ok(body)); - return; - } - - let acquire = Self::acquire_cold_fill_io_planning(manager, &bucket, &key); - let producer_io = match await_cold_fill_startup(acquire, &cancellation, producer_deadline).await { - Ok(result) => result, - Err(ColdFillStartupWaitError::Cancelled) => { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - Err(ColdFillStartupWaitError::DeadlineExceeded) => { - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - return; - } - }; - let producer_io = match producer_io { - Ok(io) => io, - Err(err) => { - producer.finish_shared(Err(err)); - return; - } - }; - - let prepare = track_object_read_setup( - object_traffic_health.as_deref(), - store.prepare_get_object_reader(&bucket, &key, range.clone(), HeaderMap::new(), &opts), - ); - let prepared = match match await_cold_fill_startup(prepare, &cancellation, producer_deadline).await { - Ok(result) => result, - Err(ColdFillStartupWaitError::Cancelled) => { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - Err(ColdFillStartupWaitError::DeadlineExceeded) => { - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - return; - } - } { - Ok(prepared) => prepared, - Err(err) => { - producer.relinquish_or_finish(ColdFillError::Storage(err)); - return; - } - }; - let current_info = prepared.object_info(); - let current_length = match current_info.get_actual_size() { - Ok(length) => length, - Err(err) => { - let _ = err; - producer.finish_shared(Err(ColdFillError::Storage(StorageError::FileCorrupt))); - return; - } - }; - let current_plan = build_get_object_body_cache_plan_for_revalidation( - &adapter, - GetObjectBodyCacheRequest { - bucket: &bucket, - key: &key, - info: current_info, - response_content_length: current_length, - has_range: range.is_some(), - part_number, - encryption_applied: current_info.is_encrypted(), - }, - ); - let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, ¤t_plan, &engine_plan) - else { - return; - }; - - let reservation = adapter.reserve_body(&engine_plan); - #[cfg(test)] - let reader_open_plan = engine_plan.clone(); - start_cold_fill_producer( - producer, - reservation, - || async move { Ok(producer_io) }, - || { - #[cfg(test)] - record_cold_fill_reader_open_for_test(&reader_open_plan); - let open_reader = prepared.with_headers(h).into_reader(); - async move { track_object_read_setup(object_traffic_health.as_deref(), open_reader).await } - }, - ColdFillProducerExecution { - expected, - deadline: producer_deadline, - adapter, - engine_plan, - }, - ) - .await; - } - } - }) - .await; - - match outcome { - ColdFillCoordinateOutcome::Ready(result) => { - let body = match result { - Ok(body) => body, - Err(ColdFillError::Storage(err)) => return Err(map_get_object_reader_error(err).into()), - Err(ColdFillError::DiskAdmissionClosed) => { - return Err(s3_error!(InternalError, "disk read semaphore closed")); - } - }; - let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true) - .await?; - return Ok(GetObjectPreparedRead { - io_planning: Self::get_object_io_planning_without_disk(manager), - read_setup, - }); - } - ColdFillCoordinateOutcome::Bypass => { - cache_fill_allowed = false; - break 'snapshot; - } - ColdFillCoordinateOutcome::Rejected => return Err(ApiError::from(StorageError::SlowDown).into()), - } - } - - let (io_planning, reader) = if let Some(prepared) = prepared.take() { - let io_planning = metadata_admission - .take() - .ok_or_else(|| s3_error!(InternalError, "prepared metadata admission is unavailable"))?; - let reader = - track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader()) - .await - .map_err(map_get_object_reader_error)?; - (io_planning, reader) - } else { - let io_planning = Self::acquire_get_object_io_planning( - manager, - Some(GetObjectRequestTimeout { - wrapper, - policy: timeout_config, - }), - bucket, - key, - ) - .await?; - let reader = if legacy_hook_missed { - let prepared = track_object_read_setup( - object_traffic_health.as_deref(), - store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts), - ) - .await - .map_err(map_get_object_reader_error)?; - track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader()) - .await - .map_err(map_get_object_reader_error)? - } else { - track_object_read_setup( - object_traffic_health.as_deref(), - store.get_object_reader(bucket, key, rs.clone(), store_headers, opts), - ) - .await - .map_err(map_get_object_reader_error)? - }; - (io_planning, reader) - }; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, cache_fill_allowed) - .await?; - if let Some(read_stage_start) = read_stage_start { - rustfs_io_metrics::record_get_object_stage_duration( - "s3_handler", - "store_reader_setup", - read_stage_start.elapsed().as_secs_f64(), - ); - } - Ok(GetObjectPreparedRead { io_planning, read_setup }) - } - - #[allow(clippy::too_many_arguments)] - async fn finish_get_object_read( - req: &S3Request, - manager: &ConcurrencyManager, - bucket: &str, - key: &str, - mut rs: Option, - part_number: Option, - read_start: std::time::Instant, - reader: GetObjectReader, - cache_fill_allowed: bool, - ) -> S3Result { - // ODC-16: capture whether the ecstore cache hook already probed this - // read, so the app layer does not repeat the lookup it ran after fresh - // metadata resolution. - let cache_hook_served = reader.is_cache_hook_served(); - let cache_hook_probed = reader.cache_hook_probed(); - let info = reader.object_info; - let stream = reader.stream; - let buffered_body = reader.buffered_body; - - let read_duration = read_start.elapsed(); - - // Conditional metrics recording to reduce overhead - if rustfs_io_metrics::get_stage_metrics_enabled() { - use rustfs_io_metrics::record_zero_copy_read; - record_zero_copy_read(info.size as usize, read_duration.as_secs_f64() * 1000.0); - manager.record_disk_operation(info.size as u64, read_duration, true).await; - } - - check_preconditions(&req.headers, &info)?; - Self::validate_get_object_part_number(part_number, &info)?; - - debug!(object_size = info.size, part_count = info.parts.len(), "GET object metadata snapshot"); - for part in info.parts.iter() { - debug!( - part_number = part.number, - part_size = part.size, - part_actual_size = part.actual_size, - "GET object part details" - ); - } - - let content_type = if let Some(content_type) = &info.content_type { - match ContentType::from_str(content_type) { - Ok(res) => Some(res), - Err(err) => { - error!(content_type, error = ?err, "GET object content-type parse failed"); - None - } - } - } else { - None - }; - let last_modified = info.mod_time.map(Timestamp::from); - - if let Some(part_number) = part_number - && rs.is_none() - { - rs = HTTPRangeSpec::from_part_sizes( - info.size, - part_number, - info.parts.iter().map(|part| { - if part.actual_size > 0 { - part.actual_size - } else { - i64::try_from(part.size).unwrap_or(i64::MAX) - } - }), - ); - } - - validate_sse_headers_for_read(&info.user_defined, &req.headers)?; - - let mut content_length = info.get_actual_size().map_err(ApiError::from)?; - let (resume_range_start, resume_range_end, content_range) = if let Some(rs) = &rs { - let total_size = content_length; - let (start, length) = rs.get_offset_length(total_size).map_err(ApiError::from)?; - content_length = length; - let start = start as i64; - // Inclusive end of the committed body; may precede `start` when a - // zero-length range was requested, in which case the body completes - // immediately and the resume range is never consulted. - ( - start, - start + length - 1, - Some(format!("bytes {}-{}/{}", start, start + length - 1, total_size)), - ) - } else { - (0, -1, None) - }; - - debug!( - "GET object metadata check: parts={}, provided_sse_key={:?}", - info.parts.len(), - req.input.sse_customer_key.is_some() - ); - - let read_principal = SseKmsPrincipal::from_request(req); - let decryption_request = DecryptionRequest { - bucket, - key, - metadata: &info.user_defined, - sse_customer_key: req.input.sse_customer_key.as_ref(), - sse_customer_key_md5: req.input.sse_customer_key_md5.as_ref(), - principal: read_principal.as_ref(), - }; - - let response_content_length = content_length; - - let ( - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - encryption_applied, - final_stream, - buffered_body, - ) = match classify_sse_read_response(decryption_request).await? { - // The stream is already decrypted by the object layer's encryption - // resolver; only the response headers, authorization and audit - // summary are derived here, without a second KMS unwrap. - Some(headers) => ( - Some(headers.server_side_encryption), - headers.sse_customer_algorithm, - headers.sse_customer_key_md5, - headers.ssekms_key_id, - true, - wrap_reader(stream), - None, - ), - None => (None, None, None, None, false, wrap_reader(stream), buffered_body), - }; - - Ok(GetObjectReadSetup { - info, - final_stream, - buffered_body, - cache_hook_served, - cache_hook_probed, - cache_fill_allowed, - rs, - content_type, - last_modified, - response_content_length, - content_range, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - encryption_applied, - resume_range_start, - resume_range_end, - }) - } - #[allow(clippy::too_many_arguments)] - fn finalize_get_object_strategy( - &self, - manager: &ConcurrencyManager, - bucket: &str, - key: &str, - info: &ObjectInfo, - rs: Option<&HTTPRangeSpec>, - response_content_length: i64, - permit_wait_duration: Duration, - queue_utilization: f64, - queue_status: &concurrency::IoQueueStatus, - concurrent_requests: usize, - ) -> GetObjectStrategyContext { - let base_buffer_size = if response_content_length > 0 { - get_buffer_size_opt_in(response_content_length) - } else { - self.base_buffer_size() - }; - - let is_sequential_hint = if rs.is_none() { - true - } else if let Some(range_spec) = rs { - range_spec.start == 0 && !range_spec.is_suffix_length - } else { - false - }; - - // Conditional metrics recording to reduce overhead - if rustfs_io_metrics::get_stage_metrics_enabled() { - if let Some(range_spec) = rs - && range_spec.start >= 0 - { - manager.record_access(range_spec.start as u64, response_content_length as u64); - } - - if response_content_length > 0 { - manager.record_transfer(response_content_length as u64, permit_wait_duration); - } - } - - let io_strategy = - manager.calculate_io_strategy_with_context(info.size, base_buffer_size, permit_wait_duration, is_sequential_hint); - - debug!( - wait_ms = permit_wait_duration.as_millis() as u64, - load_level = ?io_strategy.load_level, - buffer_size = io_strategy.buffer_size, - buffer_multiplier = io_strategy.buffer_multiplier, - readahead = io_strategy.enable_readahead, - storage_media = ?io_strategy.storage_media, - access_pattern = ?io_strategy.access_pattern, - bandwidth_tier = ?io_strategy.bandwidth_tier, - concurrent_requests = io_strategy.concurrent_requests, - file_size = info.size, - is_sequential = is_sequential_hint, - "Enhanced multi-factor I/O strategy calculated" - ); - - let io_priority = manager.get_io_priority(response_content_length); - - if manager.is_priority_scheduling_enabled() { - debug!( - bucket = %bucket, - key = %key, - priority = %io_priority, - request_size = response_content_length, - "I/O priority assigned (based on actual request size)" - ); - } - - rustfs_io_metrics::record_get_object_io_state( - permit_wait_duration.as_secs_f64(), - queue_utilization, - queue_status.permits_in_use, - queue_status.total_permits.saturating_sub(queue_status.permits_in_use), - io_strategy.load_level.as_str(), - io_strategy.buffer_multiplier, - ); - rustfs_io_metrics::record_io_priority_assignment(io_priority.as_str()); - - debug!( - actual_request_size = response_content_length, - priority = %io_priority.as_str(), - "I/O priority finalized with actual request size" - ); - - let optimal_buffer_size = if io_strategy.buffer_size > 0 { - io_strategy.buffer_size - } else { - get_concurrency_aware_buffer_size(response_content_length, base_buffer_size) - }; - - debug!( - "GetObject buffer sizing: file_size={}, base={}, optimal={}, concurrent_requests={}, io_strategy={:?}", - response_content_length, base_buffer_size, optimal_buffer_size, concurrent_requests, io_strategy.load_level - ); - let enable_readahead = io_strategy.enable_readahead; - - GetObjectStrategyContext { - io_strategy, - optimal_buffer_size, - enable_readahead, - } - } - - fn build_get_object_checksums( - info: &ObjectInfo, - headers: &HeaderMap, - part_number: Option, - rs: Option<&HTTPRangeSpec>, - ) -> S3Result { - if let Some(checksum_mode) = headers.get(AMZ_CHECKSUM_MODE) - && checksum_mode.to_str().unwrap_or_default() == "ENABLED" - && rs.is_none() - { - let (decrypted_checksums, is_multipart) = info.decrypt_checksums(part_number.unwrap_or(0), headers).map_err(|e| { - error!(error = %e, "GetObject checksum decryption failed"); - ApiError::from(e) - })?; - - return Ok(classify_response_checksums(decrypted_checksums, is_multipart)); - } - - Ok(ResponseChecksums::default()) - } - #[allow(clippy::too_many_arguments)] - async fn build_get_object_body( - final_stream: R, - info: &ObjectInfo, - response_content_length: i64, - request_id: &str, - content_range: Option<&str>, - optimal_buffer_size: usize, - enable_readahead: bool, - concurrent_requests: usize, - part_number: Option, - has_range: bool, - encryption_applied: bool, - buffered_body: Option, - bucket: &str, - key: &str, - mut lifecycle: GetObjectBodyLifecycle, - resume: F, - ) -> S3Result - where - R: AsyncRead + Send + Sync + Unpin + 'static, - F: FnOnce(&ObjectInfo) -> Option>, - { - if encryption_applied { - let should_buffer_encrypted_object = - should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests); - - if should_buffer_encrypted_object { - // Strict materialization (#1324): a decrypted body that is shorter - // or longer than the declared content length must hard-fail before - // headers, not warn-and-serve a truncated/over-long body. - let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ).await { - Ok(buf) => { - return Ok(Self::build_memory_blob( - buf, - response_content_length, - GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER, - lifecycle, - )); - } - Err(e) => { - lifecycle.finish_err(); - error!(error = %e, "GetObject decrypted object strict materialization failed"); - return Err(e.into_s3_error(response_content_length)); - } - } - } - - debug!(buffer_size = optimal_buffer_size, "Encrypted object uses streaming decrypt path"); - let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let (stream_buffer_size, stream_strategy) = - Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range); - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start); - return Ok(Self::build_reader_blob( - final_stream, - response_content_length, - request_id, - content_range, - stream_buffer_size, - stream_strategy, - bucket, - key, - lifecycle, - resume(info), - )); - } - - if let Some(buffered_body) = buffered_body { - // Strict materialization (#1324): the buffered body is the exact - // response payload; a length disagreement means an upstream/cache bug - // and must hard-fail before headers rather than serve a body that does - // not match its committed Content-Length. - let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - if buffered_body.len() != expected { - lifecycle.finish_err(); - error!( - expected = response_content_length, - actual = buffered_body.len(), - "Buffered GetObject body length mismatch" - ); - return Err(ApiError::from(StorageError::other(format!( - "Buffered GetObject body length mismatch: expected {response_content_length}, got {}", - buffered_body.len() - ))) - .into()); - } - - return Ok(Self::build_memory_bytes_blob( - buffered_body, - response_content_length, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - lifecycle, - )); - } - - let should_provide_seek_support = - should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests); - - if should_provide_seek_support { - // Strict materialization (#1324): the previous implementation only - // logged a warning on a length mismatch, and — most dangerously — on a read - // error it fell through to streaming the *same* reader after - // `read_to_end` had already drained K bytes, shipping a body missing - // its prefix (prefix-misaligned data). Both are now hard errors: an - // exact-length read is required, and any read error returns without - // reusing the partially consumed reader. - let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await { - Ok(buf) => { - return Ok(Self::build_memory_blob( - buf, - response_content_length, - GET_MEMORY_BODY_SOURCE_SEEK_BUFFER, - lifecycle, - )); - } - Err(e) => { - lifecycle.finish_err(); - error!( - error = %e, - "GetObject seek-support strict materialization failed; refusing to reuse the partially consumed reader" - ); - return Err(e.into_s3_error(response_content_length)); - } - } - } - - let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let (stream_buffer_size, stream_strategy) = - Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range); - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start); - Ok(Self::build_reader_blob( - final_stream, - response_content_length, - request_id, - content_range, - stream_buffer_size, - stream_strategy, - bucket, - key, - lifecycle, - resume(info), - )) - } - - #[allow(clippy::too_many_arguments)] - async fn build_get_object_body_with_cache( - cache_adapter: &ObjectDataCacheAdapter, - final_stream: R, - info: &ObjectInfo, - response_content_length: i64, - request_id: &str, - content_range: Option<&str>, - optimal_buffer_size: usize, - enable_readahead: bool, - concurrent_requests: usize, - part_number: Option, - has_range: bool, - encryption_applied: bool, - mut buffered_body: Option, - cache_hook_served: bool, - cache_hook_probed: bool, - cache_fill_allowed: bool, - bucket: &str, - key: &str, - mut lifecycle: GetObjectBodyLifecycle, - resume: F, - ) -> S3Result - where - R: AsyncRead + Send + Sync + Unpin + 'static, - F: FnOnce(&ObjectInfo) -> Option>, - { - // ODC-16 (backlog#1121): when the ecstore hook or shared cold fill - // already supplied this body, the request-level plan was built before - // the authoritative lookup. Serve it without planning a second time. - if cache_hook_served && let Some(bytes) = buffered_body.take() { - return Ok(Self::build_memory_bytes_blob( - bytes, - response_content_length, - GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE, - lifecycle, - )); - } - - if !cache_fill_allowed { - return Self::build_get_object_body( - final_stream, - info, - response_content_length, - request_id, - content_range, - optimal_buffer_size, - enable_readahead, - concurrent_requests, - part_number, - has_range, - encryption_applied, - buffered_body, - bucket, - key, - lifecycle, - resume, - ) - .await; - } - - let cache_request = GetObjectBodyCacheRequest { - bucket, - key, - info, - response_content_length, - has_range, - part_number, - encryption_applied, - }; - let cache_plan = build_get_object_body_cache_plan(cache_adapter, cache_request); - - // ODC-16: only look up when the hook did not probe this read. When it did - // probe (a served body handled above, or a miss), its result is - // authoritative because it ran after fresh metadata resolution, so the - // app layer skips its own lookup and only uses the plan to fill. - if !cache_hook_probed { - match lookup_get_object_body_cache_hit(cache_adapter, &cache_plan).await { - GetObjectBodyCacheLookup::Hit(bytes) => { - return Ok(Self::build_memory_bytes_blob( - bytes, - response_content_length, - GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE, - lifecycle, - )); - } - GetObjectBodyCacheLookup::Disabled | GetObjectBodyCacheLookup::Skip | GetObjectBodyCacheLookup::Miss => {} - } - } - - if let Some(buffered_body) = buffered_body { - // ODC-15: the body is already fully in hand, so keep the fill off the - // response's critical path. For a cacheable plan, run the fill in a - // detached task (Bytes is a cheap clone) and return immediately. For - // a non-cacheable plan the fill is a pure metric-only skip with no - // I/O, so record it inline to preserve observability. - if cache_fill_allowed && matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_)) { - let cache_adapter = cache_adapter.clone(); - let cache_plan = cache_plan.clone(); - let fill_bytes = buffered_body.clone(); - tokio::spawn(async move { - let _ = fill_get_object_body_cache_from_buffered_body(&cache_adapter, &cache_plan, &fill_bytes).await; - }); - } else if cache_fill_allowed { - let _ = fill_get_object_body_cache_from_buffered_body(cache_adapter, &cache_plan, &buffered_body).await; - } - - return Ok(Self::build_memory_bytes_blob( - buffered_body, - response_content_length, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - lifecycle, - )); - } - - let should_materialize_for_cache = cache_adapter.materialize_fill_enabled() - && cache_fill_allowed - && matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_)) - && should_materialize_get_object_body_for_cache( - info, - response_content_length, - part_number, - has_range, - concurrent_requests, - ); - - if should_materialize_for_cache { - let Ok(materialized_capacity) = usize::try_from(response_content_length) else { - warn!( - expected = response_content_length, - "GetObject materialize-fill skipped because content length is not representable" - ); - return Self::build_get_object_body( - final_stream, - info, - response_content_length, - request_id, - content_range, - optimal_buffer_size, - enable_readahead, - concurrent_requests, - part_number, - has_range, - encryption_applied, - None, - bucket, - key, - lifecycle, - resume, - ) - .await; - }; - // ODC-07 / #1324: share the strict exact-length materialization gate - // with the encrypted and seek memory branches. The helper bounds the - // read to `capacity + 1` (so an over-long stream is detected without - // buffering it unbounded), rejects short and over-long reads, and on a - // partial-read error refuses to reuse the consumed reader. - match strict_materialize_object_body( - final_stream, - materialized_capacity, - GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ, - ) - .await - { - Ok(buf) => { - let bytes = Bytes::from(buf); - // ODC-15: fill off the response's critical path (see the - // buffered-body branch above). - let cache_adapter = cache_adapter.clone(); - let cache_plan = cache_plan.clone(); - let fill_bytes = bytes.clone(); - tokio::spawn(async move { - let _ = fill_get_object_body_cache_from_materialized_body(&cache_adapter, &cache_plan, &fill_bytes).await; - }); - - return Ok(Self::build_memory_bytes_blob( - bytes, - response_content_length, - GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED, - lifecycle, - )); - } - Err(e) => { - lifecycle.finish_err(); - error!(error = %e, "GetObject materialize-fill strict materialization failed"); - // A short/over-long body would ship a truncated or over-long - // response; a partial-read error leaves the stream consumed so - // falling back to streaming would send a prefix-misaligned - // body. Both fail the request. - return Err(e.into_s3_error(response_content_length)); - } - } - } - - Self::build_get_object_body( - final_stream, - info, - response_content_length, - request_id, - content_range, - optimal_buffer_size, - enable_readahead, - concurrent_requests, - part_number, - has_range, - encryption_applied, - None, - bucket, - key, - lifecycle, - resume, - ) - .await - } - - fn put_object_execution_context(req: &S3Request) -> (EventName, QuotaOperation, &'static str) { - if req.extensions.get::().is_some() { - (put_event_name_for_post_object(true), QuotaOperation::PostObject, "POST") - } else { - (put_event_name_for_post_object(false), QuotaOperation::PutObject, "PUT") - } - } - - #[instrument(name = "execute_put_object", level = "info", skip(self, _fs, req))] - pub async fn execute_put_object(&self, _fs: &FS, req: S3Request) -> S3Result> { - self.execute_put_object_boxed(_fs, req).await - } - - fn execute_put_object_boxed<'a>( - &'a self, - _fs: &'a FS, - req: S3Request, - ) -> impl std::future::Future>> + Send + 'a { - Box::pin(self.execute_put_object_inner(_fs, req)) - } - - #[hotpath::measure( - label = "rustfs::app::object_usecase::DefaultObjectUsecase::execute_put_object", - impl_type = "DefaultObjectUsecase" - )] - async fn execute_put_object_inner(&self, _fs: &FS, req: S3Request) -> S3Result> { - let start_time = std::time::Instant::now(); - let mut req = req; - - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let (event_name, quota_operation, request_method_name) = Self::put_object_execution_context(&req); - if req.extensions.get::().is_some() && is_post_object_sse_kms_requested(&req.input, &req.headers) - { - return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for POST object uploads")); - } - if let Some(ref storage_class) = req.input.storage_class - && !is_valid_storage_class(storage_class.as_str()) - { - return Err(s3_error!(InvalidStorageClass)); - } - // An authorized inbound replication PUT must store the replica verbatim. - // A snowball-extracted member object keeps `x-amz-meta-snowball-auto-extract` - // in its user metadata, and the replication client replays stored metadata - // as headers — re-dispatching that PUT into the extract path would try to - // untar the member's own bytes (failing replication for any non-archive - // member) instead of writing the replica. - let inbound_replication_put = replication_request_authorized(&req) - && get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true"); - if is_put_object_extract_requested(&req.headers) && !inbound_replication_put { - return Box::pin(self.execute_put_object_extract(req)).await; - } - // SSE-C ciphertext passthrough (authorized replication only): the body - // is already ciphertext and must be stored verbatim — no compression, - // no bucket-default encryption. - let ciphertext_passthrough = - inbound_replication_put && rustfs_utils::http::ssec_transport_to_stored_metadata(&req.headers).is_some(); - - let input = std::mem::take(&mut req.input); - - let PutObjectInput { - body, - bucket, - cache_control, - key, - content_length, - content_disposition, - content_encoding, - content_language, - content_type, - expires, - tagging, - metadata, - version_id, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key, - sse_customer_key_md5, - ssekms_key_id, - content_md5, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - storage_class, - website_redirect_location, - .. - } = input; - - // Merge SSE-C params from headers (fallback when S3 layer does not populate input) - let (h_algo, h_key, h_md5) = extract_ssec_params_from_headers(&req.headers)?; - let sse_customer_algorithm = sse_customer_algorithm.or(h_algo); - let sse_customer_key = sse_customer_key.or(h_key); - let sse_customer_key_md5 = sse_customer_key_md5.or(h_md5); - - // Merge server_side_encryption from headers (fallback when S3 layer does not populate input) - let server_side_encryption = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?); - - // Validate object key - validate_object_key(&key, request_method_name)?; - validate_table_catalog_object_mutation(&bucket, &key).await?; - - // Validate archive content encoding (reject when strict mode is enabled) - validate_archive_content_encoding( - &key, - req.headers.get("content-type").and_then(|value| value.to_str().ok()), - req.headers.get("content-encoding").and_then(|value| value.to_str().ok()), - )?; - - let Some(body) = body else { return Err(s3_error!(IncompleteBody)) }; - - // Guard against a proxy/CDN that forwards a partial body then goes silent - // without closing the connection: bound the inter-chunk wait so the read - // fails (with a diagnostic log) instead of hanging forever (issue #3076). - let body = { - let request_id = req - .extensions - .get::() - .map(|ctx| ctx.request_id.clone()) - .unwrap_or_default(); - guard_put_object_body_read_timeout(body, &bucket, &key, &request_id, content_length, put_object_body_read_timeout()) - }; - - // Resolve the authoritative decoded/plain object length (rejecting negative/unknown) before anything else consumes it. - let mut size = resolve_put_object_authoritative_size(&req.headers, content_length)?; - - // The app check preserves the existing S3 error contract; the storage - // commit path reserves the exact net logical growth under its locks. - let quota_check = self - .check_bucket_quota( - &bucket, - quota_operation, - u64::try_from(size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - ) - .await?; - let quota_enabled = quota_check.as_ref().is_some_and(|result| result.quota_limit.is_some()); - if quota_enabled && ciphertext_passthrough { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - "SSE-C ciphertext replication is unavailable for quota-enabled buckets".to_string(), - )); - } - - let put_stage_metrics_enabled = rustfs_io_metrics::put_stage_metrics_enabled(); - let ingress_stage_start = put_stage_metrics_enabled.then(Instant::now); - let should_compress = - is_disk_compressible(&req.headers, &key) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64 && !ciphertext_passthrough; - let server_side_encryption_requested = - server_side_encryption.is_some() || sse_customer_algorithm.is_some() || ssekms_key_id.is_some(); - - // Resolve the store through the request-bound server context - // (backlog#1052 S6), not the process-global handle, so an embedded - // second server never writes into the first server's store. - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - let bucket_validate_stage_start = put_stage_metrics_enabled.then(Instant::now); - validate_bucket_exists(&store, &bucket).await?; - rustfs_io_metrics::record_put_object_stage_duration_from("app_bucket_validate", bucket_validate_stage_start); - - let put_admission = match get_concurrency_manager() - .admit_put_object() - .await - .map_err(|_| s3_error!(InternalError, "foreground write admission closed"))? - { - PutObjectAdmission::Disabled => None, - PutObjectAdmission::Admitted(permit) => { - counter!("rustfs.put_object.foreground_admission.total", "result" => "admitted").increment(1); - Some(permit) - } - PutObjectAdmission::Rejected => { - counter!("rustfs.put_object.foreground_admission.total", "result" => "rejected").increment(1); - return Err(s3_error!( - SlowDown, - "foreground write concurrency limit reached, please reduce your request rate" - )); - } - }; - - let mut put_request_guard = PutObjectGuard::new(); - let concurrent_put_requests = PutObjectGuard::concurrent_requests(); - - // Apply adaptive buffer sizing based on file size for optimal streaming performance. - // Uses workload profile configuration (enabled by default) to select appropriate buffer size. - // Buffer sizes range from 32KB to 4MB depending on file size and configured workload profile. - // Concurrency-aware adjustment reduces buffer size under high PUT concurrency to lower memory pressure. - let base_buffer_size = get_buffer_size_opt_in(size); - let use_large_put_concurrency_tuning = Self::should_use_large_put_concurrency_tuning(size); - let buffer_size = if use_large_put_concurrency_tuning { - get_put_concurrency_aware_buffer_size(size, base_buffer_size) - } else { - base_buffer_size - }; - - // Detect zero-copy opportunity before encryption/compression decisions - // Zero-copy is beneficial for large unencrypted, uncompressed objects - let enable_zero_copy = should_use_zero_copy(size, &req.headers); - - if enable_zero_copy { - // Record zero-copy write attempt - counter!("rustfs_zero_copy_write_attempts_total").increment(1); - histogram!("rustfs_zero_copy_write_size_bytes").record(size as f64); - debug!("Zero-copy write enabled for {} byte object (bucket={}, key={})", size, bucket, key); - } - - let use_empty_or_small_eager_put_path = size == 0 - || should_use_small_eager_put_path(size, &req.headers, server_side_encryption_requested, should_compress, false); - let zero_copy_eager_put_path_status = - zero_copy_eager_put_path_status(size, &req.headers, server_side_encryption_requested, should_compress, false); - let use_zero_copy_eager_put_path = zero_copy_eager_put_path_status == PUT_EAGER_STATUS_ELIGIBLE; - if use_zero_copy_eager_put_path { - counter!(buffered_write::ATTEMPTS_TOTAL).increment(1); - histogram!(buffered_write::ATTEMPT_SIZE_BYTES).record(size as f64); - } - let put_path = if should_compress { - "stream_compressed" - } else if use_zero_copy_eager_put_path { - "zero_copy_eager" - } else if use_empty_or_small_eager_put_path { - "small_eager" - } else { - "streaming" - }; - rustfs_io_metrics::record_put_object_diagnostics( - put_path, - zero_copy_eager_put_path_status, - size, - buffer_size, - use_large_put_concurrency_tuning, - ); - - let sse_config_stage_start = put_stage_metrics_enabled.then(Instant::now); - let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); - rustfs_io_metrics::record_put_object_stage_duration_from("app_sse_config_lookup", sse_config_stage_start); - debug!( - target: "rustfs::app::object_usecase", - component = "app", - subsystem = "object", - event = "bucket_sse_config_lookup", - bucket = %bucket, - found = bucket_sse_config.is_some(), - "Bucket SSE configuration lookup completed" - ); - - let original_sse = server_side_encryption.clone(); - let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( - bucket_sse_config.as_ref().map(|(config, _timestamp)| config), - server_side_encryption, - ssekms_key_id, - false, - ); - debug!( - target: "rustfs::app::object_usecase", - component = "app", - subsystem = "object", - event = "effective_sse_resolved", - bucket = %bucket, - requested = ?original_sse, - effective = ?effective_sse, - "Resolved effective SSE configuration" - ); - - if ciphertext_passthrough { - // The replica keeps the source's SSE-C metadata; the bucket - // default must not claim managed encryption on it. - effective_sse = None; - effective_kms_key_id = None; - } - - // Validate SSE-C headers early: reject partial/invalid combinations per S3 spec - validate_sse_headers_for_write( - effective_sse.as_ref(), - effective_kms_key_id.as_ref(), - extract_ssekms_context_from_headers(&req.headers)?.as_ref(), - sse_customer_algorithm.as_ref(), - sse_customer_key.as_ref(), - sse_customer_key_md5.as_ref(), - true, // PutObject requires all three: algorithm, key, key_md5 - )?; - - let mut metadata = metadata.unwrap_or_default(); - let has_explicit_object_lock_retention = object_lock_mode.is_some() - || object_lock_retain_until_date.is_some() - || has_replication_retention_update(&req.headers, inbound_replication_put); - let object_lock_config_stage_start = put_stage_metrics_enabled.then(Instant::now); - let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?; - rustfs_io_metrics::record_put_object_stage_duration_from("app_object_lock_config_lookup", object_lock_config_stage_start); - apply_put_request_metadata( - &mut metadata, - &req.headers, - &key, - cache_control, - content_disposition, - content_encoding, - content_language, - content_type, - expires, - website_redirect_location, - tagging, - storage_class.clone(), - )?; - apply_bucket_default_lock_retention( - &bucket, - &object_lock_config_state, - &mut metadata, - has_explicit_object_lock_retention, - )?; - - let put_opts_stage_start = put_stage_metrics_enabled.then(Instant::now); - let mut opts: ObjectOptions = put_opts_with_replication_authorization( - &bucket, - &key, - version_id.clone(), - &req.headers, - metadata.clone(), - replication_request_authorized(&req), - ) - .await - .map_err(ApiError::from)?; - if let Some(quota_check) = quota_check.as_ref() { - apply_quota_admission(&mut opts, quota_check)?; - } - rustfs_io_metrics::record_put_object_stage_duration_from("app_put_opts_build", put_opts_stage_start); - apply_bucket_generation_guard(&req, &bucket, &mut opts)?; - apply_put_request_object_lock_opts( - &bucket, - &object_lock_config_state, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - &mut opts, - )?; - let eager_put_commit_cancellation = - (use_zero_copy_eager_put_path || use_empty_or_small_eager_put_path).then(tokio_util::sync::CancellationToken::new); - opts.put_object_cancellation = eager_put_commit_cancellation.clone(); - - // rustfs/backlog#1009: the pre-PUT lookup has exactly two consumers — - // the existing-object WORM validation and usage accounting's - // previous_current_size. When the bucket has no object locking (WORM is - // a provable no-op; the gate fails closed on metadata errors) and the - // PUT targets the latest version (no explicit version_id from internal - // replication), the lookup is skipped and accounting is backfilled from - // the dst xl.meta that rename_data already reads, saving a full-disk - // metadata fanout per PUT. - let prelookup_required = version_id.is_some() || object_lock_checks_required_for_state(&object_lock_config_state); - // Outer None = prelookup skipped (accounting comes from the commit - // backfill); Some(inner) = the previous current size as observed by the - // lookup, with the pre-#1009 semantics kept bit-for-bit. - let prelookup_stage_start = (prelookup_required && put_stage_metrics_enabled).then(Instant::now); - let prelookup_previous_current_size: Option> = if prelookup_required { - let current_opts: ObjectOptions = internal_object_info_lookup_opts( - get_opts(&bucket, &key, version_id.clone(), None, &req.headers) - .await - .map_err(ApiError::from)?, - ); - let previous_current_info = { - crate::hp_guard!("S3::put_object_prelookup"); - store.get_object_info(&bucket, &key, ¤t_opts).await - }; - Some(match previous_current_info { - Ok(existing_obj_info) => { - validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &opts)?; - Some(if quota_enabled { - quota_object_size(&existing_obj_info).map_err(ApiError::from)? - } else { - existing_obj_info.size.max(0) as u64 - }) - } - Err(err) => { - if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { - return Err(ApiError::from(err).into()); - } - None - } - }) - } else { - None - }; - rustfs_io_metrics::record_put_object_stage_duration_from("app_prelookup", prelookup_stage_start); - - let actual_size = size; - if !ciphertext_passthrough && let Some(quota_check) = quota_check.as_ref() { - ensure_object_size_within_quota( - quota_check, - u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - )?; - } - - let mut md5hex = if let Some(base64_md5) = content_md5 { - let md5 = base64_simd::STANDARD - .decode_to_vec(base64_md5.as_bytes()) - .map_err(|e| ApiError::from(StorageError::other(format!("Invalid content MD5: {e}"))))?; - Some(hex_simd::encode_to_string(&md5, hex_simd::AsciiCase::Lower)) - } else { - None - }; - - let mut sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); - - let mut write_plan = WritePlan::new(); - // Additional-checksum (XXHash3/64/128, SHA-512) values to echo on the PutObject - // response (#1256); captured at want_checksum set points before opts is moved. - let mut put_extra_checksum_headers: Vec<(&'static str, String)> = Vec::new(); - let mut reader = if should_compress { - let body = tokio::io::BufReader::with_capacity( - buffer_size, - StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), - ); - let algorithm = CompressionAlgorithm::default(); - insert_str(&mut metadata, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); - insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, size.to_string()); - - let mut hrd = - HashReader::from_stream(body, size, size, md5hex.take(), sha256hex.take(), false).map_err(ApiError::from)?; - - if let Err(err) = hrd.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { - return Err(ApiError::from(err).into()); - } - - opts.want_checksum = hrd.checksum(); - put_extra_checksum_headers = additional_checksum_echo_pairs(&opts.want_checksum); - insert_str(&mut opts.user_defined, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); - insert_str(&mut opts.user_defined, SUFFIX_ACTUAL_SIZE, size.to_string()); - - size = HashReader::SIZE_PRESERVE_LAYER; - write_plan = write_plan.with_compression(algorithm); - hrd - } else { - if use_zero_copy_eager_put_path { - let zero_copy_start = std::time::Instant::now(); - let eager_body = read_zero_copy_put_body_exact(body, actual_size as usize).await?; - rustfs_io_metrics::record_zero_copy_write(actual_size as usize, zero_copy_start.elapsed().as_secs_f64() * 1000.0); - HashReader::from_stream(eager_body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? - } else if use_empty_or_small_eager_put_path { - if (actual_size as usize) <= POOL_BYPASS_MAX_SIZE { - // Bypass BytesPool for very small objects to avoid Small-tier - // Mutex contention under high concurrency. Direct allocation - // for ≤4KiB is negligible cost. - let eager_body = read_small_put_body_exact_direct( - StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), - actual_size as usize, - ) - .await?; - HashReader::from_stream(eager_body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? - } else { - let pool = get_concurrency_manager().bytes_pool(); - let eager_body = read_small_put_body_exact_pooled( - StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), - actual_size as usize, - pool.as_ref(), - ) - .await?; - let eager_reader = PooledBufferReader::new(eager_body, actual_size as usize); - HashReader::from_stream(eager_reader, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? - } - } else { - let body = tokio::io::BufReader::with_capacity( - buffer_size, - StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), - ); - HashReader::from_stream(body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? - } - }; - - if size >= 0 { - if let Err(err) = reader.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { - return Err(ApiError::from(err).into()); - } - - opts.want_checksum = reader.checksum(); - put_extra_checksum_headers = additional_checksum_echo_pairs(&opts.want_checksum); - } - rustfs_io_metrics::record_put_object_path(put_path); - rustfs_io_metrics::record_put_object_stage_duration_from("ingress_prepare", ingress_stage_start); - - let mut helper = OperationHelper::new(&req, event_name, S3Operation::PutObject); - let ssekms_context = extract_ssekms_context_from_headers(&req.headers)?; - - // Apply encryption using unified SSE API. - let encryption_stage_start = put_stage_metrics_enabled.then(Instant::now); - let write_principal = SseKmsPrincipal::from_request(&req); - let encryption_request = EncryptionRequest { - bucket: &bucket, - key: &key, - server_side_encryption: effective_sse.clone(), - ssekms_key_id: effective_kms_key_id.clone(), - ssekms_context, - sse_customer_algorithm: sse_customer_algorithm.clone(), - sse_customer_key, - sse_customer_key_md5: sse_customer_key_md5.clone(), - content_size: actual_size, - principal: write_principal.as_ref(), - }; - - // SSE-C ciphertext passthrough must skip sse_encryption entirely: an - // explicit guard is required because prepare_sse_configuration inside - // it falls back to the bucket default encryption config and would - // double-encrypt the already-encrypted body. - let encryption_material = if opts.preserve_ciphertext { - None - } else { - match sse_encryption(encryption_request).await { - Ok(material) => material, - Err(err) => { - let result = Err(err.into()); - let _ = helper.complete(&result); - return result; - } - } - }; - - if let Some(material) = encryption_material { - effective_sse = Some(material.server_side_encryption.clone()); - effective_kms_key_id = material.kms_key_id.clone(); - - write_plan = write_plan.with_encryption(material.write_encryption(None)); - - let encryption_metadata = encryption_material_to_metadata(&material)?; - metadata.extend(encryption_metadata.clone()); - opts.user_defined.extend(encryption_metadata); - } - - reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?; - rustfs_io_metrics::record_put_object_stage_duration_from("app_encryption_prepare", encryption_stage_start); - - let reader = PutObjReader::new(reader); - - let mt2 = metadata.clone(); - opts.user_defined.extend(metadata); - let request_context = req.extensions.get::().cloned(); - let request_id = request_context - .as_ref() - .map(|ctx| ctx.request_id.clone()) - .unwrap_or_else(|| request_context::RequestContext::fallback().request_id); - - // Compute the replication decision exactly once per PUT. The same - // immutable `dsc` drives both the pending metadata written below and the - // post-commit schedule (see the reuse site further down), so a - // replication-config hot update can no longer split the two phases - // (https://github.com/rustfs/backlog/issues/1320). - let replication_decision_stage_start = put_stage_metrics_enabled.then(Instant::now); - let dsc = - must_replicate_object(&bucket, &key, &mt2, "".to_string(), opts.delete_marker_replication_status(), opts.clone()) - .await; - rustfs_io_metrics::record_put_object_stage_duration_from("app_replication_decision", replication_decision_stage_start); - - if dsc.replicate_any() { - insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); - insert_str( - &mut opts.user_defined, - SUFFIX_REPLICATION_STATUS, - dsc.pending_status().unwrap_or_default(), - ); - } - - let cache_adapter = self.object_data_cache(); - let cache_invalidate_before_stage_start = put_stage_metrics_enabled.then(Instant::now); - let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; - rustfs_io_metrics::record_put_object_stage_duration_from( - "app_cache_invalidate_before", - cache_invalidate_before_stage_start, - ); - - let store_put_watchdog = tokio_util::sync::CancellationToken::new(); - spawn_traced({ - let store_put_watchdog = store_put_watchdog.clone(); - let request_id = request_id.clone(); - let bucket = bucket.clone(); - let key = key.clone(); - let put_path = put_path.to_string(); - async move { - tokio::select! { - _ = store_put_watchdog.cancelled() => {} - _ = tokio::time::sleep(PUT_OBJECT_STORE_WARN_THRESHOLD) => { - warn!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_STORE_INFLIGHT_SLOW, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - request_id = %request_id, - bucket = %bucket, - key = %key, - put_path = %put_path, - object_size = actual_size, - threshold_ms = PUT_OBJECT_STORE_WARN_THRESHOLD.as_millis() as u64, - state = "store_put_pending", - "PutObject store write remains in flight" - ); - } - } - } - }); - - let object_traffic_health = if use_zero_copy_eager_put_path || use_empty_or_small_eager_put_path { - self.object_traffic_health() - } else { - None - }; - let put_commit = spawn_traced_join({ - let store = Arc::clone(&store); - let bucket = bucket.clone(); - let key = key.clone(); - let opts = opts.clone(); - let cache_adapter = cache_adapter.clone(); - let request_id = request_id.clone(); - let put_path = put_path.to_string(); - let put_admission = put_admission; - async move { - let _put_admission = put_admission; - let object_traffic_progress = object_traffic_health - .as_deref() - .and_then(ObjectTrafficHealth::track_write_storage); - let mut reader = reader; - let store_put_stage_start = put_stage_metrics_enabled.then(Instant::now); - let (obj_info, backfilled_old_current_size) = match store - .put_object_with_old_current_size(&bucket, &key, &mut reader, &opts) - .await - .map_err(ApiError::from) - { - Ok(obj_info) => { - store_put_watchdog.cancel(); - debug!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_STORE_RETURNED, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - request_id = %request_id, - bucket = %bucket, - key = %key, - put_path = %put_path, - object_size = actual_size, - duration_ms = start_time.elapsed().as_millis() as u64, - result = "success", - "PutObject store write returned" - ); - obj_info - } - Err(err) => { - store_put_watchdog.cancel(); - rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start); - warn!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_STORE_RETURNED, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - request_id = %request_id, - bucket = %bucket, - key = %key, - put_path = %put_path, - object_size = actual_size, - duration_ms = start_time.elapsed().as_millis() as u64, - result = "error", - error = %err, - "PutObject store write returned" - ); - return Err(err.into()); - } - }; - rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start); - drop(_put_admission); - drop(object_traffic_progress); - #[cfg(test)] - wait_for_put_post_store_test_hook(&bucket).await; - - let post_store_stage_start = put_stage_metrics_enabled.then(Instant::now); - maybe_enqueue_transition_immediate(&obj_info, LcEventSrc::S3PutObject).await; - let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &key).await; - - let put_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await; - // Fast in-memory update for immediate quota and admin usage consistency. - // The previous current size comes from the prelookup when it ran, - // otherwise from the rename_data backfill (rustfs/backlog#1009); the - // backfill reproduces the lookup's observation bit for bit (latest - // version's ObjectInfo.size — 0 for a delete-marker latest — or - // not-found → None). - let committed_size = quota_accounting_object_size(&obj_info, quota_enabled)?; - match prelookup_previous_current_size.or_else(|| previous_current_size_from_backfill(backfilled_old_current_size)) - { - Some(previous_current_size) => { - if put_versioned { - record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; - } else { - record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; - } - } - None => { - // Neither source could determine the previous state (peers - // predating the backfill field during a rolling upgrade, or - // sub-quorum metadata divergence). Record the components that - // are correct regardless; the next authoritative scanner - // refresh replaces the in-memory numbers. - debug!( - target: "rustfs::app::object_usecase", - bucket = %bucket, - key = %key, - put_versioned, - "put_object old-size backfill unknown; recording degraded usage delta" - ); - record_bucket_object_write_unknown_previous_memory(&bucket, committed_size, put_versioned).await; - } - } - - if dsc.replicate_any() { - schedule_object_replication(obj_info.clone(), store, dsc).await; - } - - rustfs_scanner::record_dirty_usage_bucket(&bucket); - rustfs_io_metrics::record_put_object_stage_duration_from("app_post_store_bookkeeping", post_store_stage_start); - - let capacity_update_stage_start = put_stage_metrics_enabled.then(Instant::now); - let manager = get_capacity_manager(); - manager.record_write_operation().await; - rustfs_io_metrics::record_put_object_stage_duration_from("app_capacity_update", capacity_update_stage_start); - - Ok::<_, S3Error>(PutObjectCommitResult { obj_info, put_versioned }) - } - }); - let put_commit_result = if let Some(cancellation) = eager_put_commit_cancellation { - EagerPutCommitOwner::new(put_commit, cancellation, EAGER_PUT_COMMIT_CANCELLATION_GRACE) - .join() - .await - } else { - put_commit.await - }; - let PutObjectCommitResult { obj_info, put_versioned } = match put_commit_result { - Ok(Ok(result)) => result, - Ok(Err(err)) => { - let result: S3Result> = Err(err); - put_request_guard.finish_err(); - let _ = helper.complete(&result); - return result; - } - Err(err) => { - let result: S3Result> = Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("put object commit owner task failed: {err}"), - )); - put_request_guard.finish_err(); - let _ = helper.complete(&result); - return result; - } - }; - - let raw_version = obj_info.version_id.map(|v| v.to_string()); - - helper = helper.object(obj_info.clone()); - if let Some(version_id) = &raw_version { - helper = helper.version_id(version_id.clone()); - } - - let put_version = if put_versioned { raw_version } else { None }; - - let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag)); - - let expiration = resolve_put_object_expiration(&bucket, &obj_info).await; - - let mut checksums = PutObjectChecksums { - crc32: input.checksum_crc32, - crc32c: input.checksum_crc32c, - sha1: input.checksum_sha1, - sha256: input.checksum_sha256, - crc64nvme: input.checksum_crc64nvme, - }; - apply_trailing_checksums( - input.checksum_algorithm.as_ref().map(|a| a.as_str()), - &req.trailing_headers, - &mut checksums, - ); - - let output = PutObjectOutput { - e_tag, - server_side_encryption: effective_sse, - sse_customer_algorithm: sse_customer_algorithm.clone(), - sse_customer_key_md5: sse_customer_key_md5.clone(), - ssekms_key_id: effective_kms_key_id, - expiration, - checksum_crc32: checksums.crc32, - checksum_crc32c: checksums.crc32c, - checksum_sha1: checksums.sha1, - checksum_sha256: checksums.sha256, - checksum_crc64nvme: checksums.crc64nvme, - version_id: put_version, - ..Default::default() - }; - - // For browser-based POST uploads (multipart/form-data), response status/body handling - // is decided by s3s PostObject serializer (success_action_status / redirect semantics). - - let mut response = S3Response::new(output); - // Echo XXHash3/64/128 / SHA-512 checksums that s3s PutObjectOutput has no typed - // field for (#1256). - inject_additional_checksum_headers(&mut response.headers, &put_extra_checksum_headers); - let result = Ok(response); - let _ = helper.complete(&result); - - // Record PutObject metrics via zero-copy-metrics - { - let duration_ms = start_time.elapsed().as_millis() as f64; - rustfs_io_metrics::record_put_object( - duration_ms, - size, - enable_zero_copy, // Track if zero-copy was enabled - ); - } - - debug!( - target: "rustfs::app::object_usecase", - component = "app", - subsystem = "object", - bucket = %bucket, - key = %key, - concurrent_put_requests, - buffer_size, - "PutObject request completed" - ); - - put_request_guard.finish_ok(); - - result - } - - fn finalize_get_object_completion( - wrapper: &RequestTimeoutWrapper, - timeout_config: &GetObjectTimeoutPolicy, - total_duration: Duration, - response_content_length: i64, - optimal_buffer_size: usize, - ) { - rustfs_io_metrics::record_get_object_completion( - total_duration.as_secs_f64(), - response_content_length, - optimal_buffer_size, - ); - - rustfs_io_metrics::record_get_object(total_duration.as_millis() as f64, response_content_length); - - if wrapper.is_timeout() { - warn!( - "GetObject request exceeded timeout: duration={:?} timeout={:?}", - wrapper.elapsed(), - timeout_config.get_object_timeout - ); - rustfs_io_metrics::record_get_object_timeout(None, Some(wrapper.elapsed().as_secs_f64())); - } - - debug!( - "GetObject completed: size={} duration={:?} buffer={}", - response_content_length, total_duration, optimal_buffer_size - ); - } - - fn ensure_get_object_not_timed_out( - wrapper: &RequestTimeoutWrapper, - timeout_config: &GetObjectTimeoutPolicy, - bucket: &str, - key: &str, - stage: GetObjectTimeoutStage, - ) -> S3Result<()> { - if !wrapper.is_timeout() { - return Ok(()); - } - - let timeout_secs = timeout_config.get_object_timeout.as_secs(); - let elapsed_ms = wrapper.elapsed().as_millis(); - - match stage { - GetObjectTimeoutStage::BeforeProcessing => { - warn!( - bucket = %bucket, - key = %key, - timeout_secs, - elapsed_ms, - "GetObject request timed out before processing" - ); - Err(s3_error!(InternalError, "Request timeout before processing")) - } - GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration } => { - warn!( - bucket = %bucket, - key = %key, - wait_ms = permit_wait_duration.as_millis(), - timeout_secs, - elapsed_ms, - "GetObject request timed out while waiting for disk permit" - ); - rustfs_io_metrics::record_get_object_timeout(Some("disk_permit"), Some(wrapper.elapsed().as_secs_f64())); - Err(s3_error!(InternalError, "Request timeout while waiting for disk permit")) - } - GetObjectTimeoutStage::BeforeRead => { - warn!( - bucket = %bucket, - key = %key, - timeout_secs, - elapsed_ms, - "GetObject request timed out before reading object" - ); - rustfs_io_metrics::record_get_object_timeout(Some("before_read"), Some(wrapper.elapsed().as_secs_f64())); - Err(s3_error!(InternalError, "Request timeout before reading object")) - } - } - } - - #[allow(clippy::too_many_arguments)] - async fn finalize_get_object_response( - helper: OperationHelper, - bucket: &str, - method: &hyper::Method, - headers: &HeaderMap, - event_info: Option, - version_id_for_event: String, - output: GetObjectOutput, - extra_checksum_headers: Vec<(&'static str, String)>, - ) -> S3Result> { - let helper = match event_info { - Some(event_info) => helper.object(event_info), - None => helper, - }; - let helper = helper.version_id(version_id_for_event); - let mut response = wrap_response_with_cors(bucket, method, headers, output).await; - inject_accept_ranges_header(&mut response.headers); - // Emit XXHash3/64/128 and SHA-512 checksums that s3s GetObjectOutput cannot - // carry (#1257). This is the download-side integrity path AWS SDKs verify. - inject_additional_checksum_headers(&mut response.headers, &extra_checksum_headers); - let result = Ok(response); - let _ = helper.complete(&result); - result - } - #[allow(clippy::too_many_arguments)] - async fn build_get_object_output_context( - &self, - req: &S3Request, - manager: &ConcurrencyManager, - bucket: &str, - key: &str, - info: ObjectInfo, - event_info: Option, - final_stream: DynReader, - buffered_body: Option, - cache_hook_served: bool, - cache_hook_probed: bool, - cache_fill_allowed: bool, - rs: Option, - content_type: Option, - last_modified: Option, - response_content_length: i64, - content_range: Option, - request_id: &str, - server_side_encryption: Option, - sse_customer_algorithm: Option, - sse_customer_key_md5: Option, - ssekms_key_id: Option, - encryption_applied: bool, - permit_wait_duration: Duration, - queue_utilization: f64, - queue_status: &concurrency::IoQueueStatus, - concurrent_requests: usize, - part_number: Option, - versioned: bool, - lifecycle: GetObjectBodyLifecycle, - resume: F, - ) -> S3Result - where - F: FnOnce(&ObjectInfo) -> Option>, - { - let strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let strategy = self.finalize_get_object_strategy( - manager, - bucket, - key, - &info, - rs.as_ref(), - response_content_length, - permit_wait_duration, - queue_utilization, - queue_status, - concurrent_requests, - ); - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_OUTPUT_STRATEGY, strategy_start); - let GetObjectStrategyContext { - io_strategy: _, - optimal_buffer_size, - enable_readahead, - } = strategy; - let cache_adapter = self.object_data_cache(); - - let body_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let body = Self::build_get_object_body_with_cache( - &cache_adapter, - final_stream, - &info, - response_content_length, - request_id, - content_range.as_deref(), - optimal_buffer_size, - enable_readahead, - concurrent_requests, - part_number, - rs.is_some(), - encryption_applied, - buffered_body, - cache_hook_served, - cache_hook_probed, - cache_fill_allowed, - bucket, - key, - lifecycle, - resume, - ) - .await?; - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_BUILD, body_build_start); - - let checksum_headers_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let checksums = Self::build_get_object_checksums(&info, &req.headers, part_number, rs.as_ref())?; - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_CHECKSUM_HEADERS, checksum_headers_start); - - let output_version_id = if versioned { - info.version_id.map(|vid| { - if vid == Uuid::nil() { - "null".to_string() - } else { - vid.to_string() - } - }) - } else { - None - }; - - // x-amz-restore: extract from object metadata - let restore = info.user_defined.get(X_AMZ_RESTORE.as_str()).and_then(|v| { - let rs = parse_restore_obj_status(v).ok()?; - Some(rs.to_string2()) - }); - - // x-amz-expiration: predict from lifecycle configuration - let lifecycle_expiration_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let expiration = resolve_put_object_expiration(bucket, &info).await; - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION, lifecycle_expiration_start); - let storage_class = response_storage_class(&info, &info.user_defined); - let cache_control = info.user_defined.get("cache-control").cloned(); - let content_disposition = info.user_defined.get("content-disposition").cloned(); - - let metadata_filter_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let metadata = filter_object_metadata(&info.user_defined); - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_METADATA_FILTER, metadata_filter_start); - - let output = GetObjectOutput { - body: Some(body), - content_length: Some(response_content_length), - last_modified, - content_type, - content_encoding: info.content_encoding.clone(), - cache_control, - content_disposition, - content_range, - e_tag: info.etag.map(|etag| to_s3s_etag(&etag)), - metadata, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - checksum_crc32: checksums.crc32, - checksum_crc32c: checksums.crc32c, - checksum_sha1: checksums.sha1, - checksum_sha256: checksums.sha256, - checksum_crc64nvme: checksums.crc64nvme, - checksum_type: checksums.checksum_type, - version_id: output_version_id, - restore, - expiration, - storage_class, - ..Default::default() - }; - - Ok(GetObjectOutputContext { - output, - event_info, - response_content_length, - optimal_buffer_size, - extra_checksum_headers: checksums.extra, - }) - } - - /// Headers a proxied read forwards verbatim to the replication target: - /// only the client's SSE-C key family, so the target performs the real - /// SSE-C decryption (never the replication-check exemption). HTTP - /// conditional headers (If-Match & co.) are deliberately NOT forwarded — - /// MinIO does not forward them either, and a remote 304/412 would leak a - /// conditional evaluation against a replica the local site never saw. - /// Range and part-number travel as typed SDK parameters instead. - fn proxy_read_passthrough_headers(headers: &HeaderMap) -> HeaderMap { - const FORWARDED: &[&str] = &[ - "x-amz-server-side-encryption-customer-algorithm", - "x-amz-server-side-encryption-customer-key", - "x-amz-server-side-encryption-customer-key-md5", - ]; - let mut forwarded = HeaderMap::new(); - for name in FORWARDED { - if let Ok(header_name) = http::HeaderName::from_str(name) - && let Some(value) = headers.get(&header_name) - { - forwarded.insert(header_name, value.clone()); - } - } - forwarded - } - - /// True when a proxied SDK call failed because the target does not have - /// the object either (service-level not-found or a raw 404, which also - /// covers NoSuchVersion): the caller tries the next target silently. - fn proxy_sdk_error_is_not_found(err: &aws_sdk_s3::error::SdkError) -> bool { - err.raw_response().is_some_and(|resp| resp.status().as_u16() == 404) - } - - /// Serve a GET whose local read failed with not-found by proxying to the - /// bucket's replication targets (MinIO `proxyGetToReplicationTarget`, - /// backlog#1675 P1-5). Returns None when no target can serve the object; - /// the caller then returns the original local error. - async fn proxy_get_object_to_replication_targets( - req: &S3Request, - bucket: &str, - key: &str, - opts: &ObjectOptions, - ) -> Option { - let targets = get_read_proxy_targets(bucket, key, opts).await; - if targets.is_empty() { - return None; - } - let extra_headers = Self::proxy_read_passthrough_headers(&req.headers); - let range = req - .headers - .get(http::header::RANGE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - let part_number = req.input.part_number; - - for target in targets { - match target - .get_object( - &target.bucket, - key, - opts.version_id.clone(), - range.clone(), - part_number, - extra_headers.clone(), - ) - .await - { - Ok(remote) => { - // MinIO-aligned accounting: one total per proxy attempt - // (targets were available), one failed when no target - // served it — never per target. - record_replication_proxy(bucket, "GetObject", false).await; - return Some(Self::proxy_sdk_get_output_to_s3s(remote)); - } - Err(err) if Self::proxy_sdk_error_is_not_found(&err) => { - debug!(bucket, key, arn = %target.arn, "read proxy: target does not have the object"); - } - Err(err) => { - warn!(bucket, key, arn = %target.arn, error = %err, "read proxy: GET against replication target failed"); - } - } - } - record_replication_proxy(bucket, "GetObject", true).await; - None - } - - /// Serve a HEAD whose local lookup failed with not-found by proxying to - /// the bucket's replication targets (MinIO `proxyHeadToRepTarget`). - async fn proxy_head_object_to_replication_targets( - req: &S3Request, - bucket: &str, - key: &str, - opts: &ObjectOptions, - ) -> Option { - let targets = get_read_proxy_targets(bucket, key, opts).await; - if targets.is_empty() { - return None; - } - let extra_headers = Self::proxy_read_passthrough_headers(&req.headers); - let range = req - .headers - .get(http::header::RANGE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - let part_number = req.input.part_number; - - for target in targets { - match target - .head_object_for_proxy( - &target.bucket, - key, - opts.version_id.clone(), - range.clone(), - part_number, - extra_headers.clone(), - ) - .await - { - Ok(remote) => { - // MinIO-aligned accounting: one total per proxy attempt, - // one failed when no target served it. - record_replication_proxy(bucket, "HeadObject", false).await; - return Some(Self::proxy_sdk_head_output_to_s3s(remote)); - } - Err(err) if Self::proxy_sdk_error_is_not_found(&err) => { - debug!(bucket, key, arn = %target.arn, "read proxy: target does not have the object"); - } - Err(err) => { - warn!(bucket, key, arn = %target.arn, error = %err, "read proxy: HEAD against replication target failed"); - } - } - } - record_replication_proxy(bucket, "HeadObject", true).await; - None - } - - /// Translate a proxied SDK GET response into the s3s output, forwarding - /// the body as a stream (no buffering, no local persistence). - fn proxy_sdk_get_output_to_s3s(remote: aws_sdk_s3::operation::get_object::GetObjectOutput) -> GetObjectOutput { - let body = remote.body; - let body_stream = tokio_util::io::ReaderStream::with_capacity(body.into_async_read(), 64 * 1024); - GetObjectOutput { - body: Some(StreamingBlob::wrap(body_stream)), - content_length: remote.content_length, - content_range: remote.content_range, - content_type: remote.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()), - content_encoding: remote.content_encoding, - content_disposition: remote.content_disposition, - content_language: remote.content_language, - cache_control: remote.cache_control, - e_tag: remote.e_tag.as_deref().and_then(|v| ETag::from_str(v).ok()), - last_modified: remote - .last_modified - .and_then(|dt| OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanos()).ok()) - .map(Timestamp::from), - metadata: remote.metadata, - version_id: remote.version_id, - server_side_encryption: remote - .server_side_encryption - .map(|sse| ServerSideEncryption::from(sse.as_str().to_string())), - sse_customer_algorithm: remote.sse_customer_algorithm, - sse_customer_key_md5: remote.sse_customer_key_md5, - ssekms_key_id: remote.ssekms_key_id, - parts_count: remote.parts_count, - tag_count: remote.tag_count, - storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())), - expiration: remote.expiration, - restore: remote.restore, - checksum_crc32: remote.checksum_crc32, - checksum_crc32c: remote.checksum_crc32_c, - checksum_crc64nvme: remote.checksum_crc64_nvme, - checksum_sha1: remote.checksum_sha1, - checksum_sha256: remote.checksum_sha256, - checksum_type: remote.checksum_type.map(|ct| ChecksumType::from(ct.as_str().to_string())), - ..Default::default() - } - } - - /// Translate a proxied SDK HEAD response into the s3s output. - /// - /// Known gaps: the SDK's HeadObjectOutput does not model 206/Content-Range - /// for a ranged HEAD (the SDK exposes no content_range member on HEAD), - /// and s3s' typed HeadObjectOutput has no tag_count field (the local path - /// injects x-amz-tagging-count as a raw header) — both are dropped for - /// proxied HEADs. - fn proxy_sdk_head_output_to_s3s(remote: aws_sdk_s3::operation::head_object::HeadObjectOutput) -> HeadObjectOutput { - HeadObjectOutput { - content_length: remote.content_length, - content_type: remote.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()), - content_encoding: remote.content_encoding, - content_disposition: remote.content_disposition, - content_language: remote.content_language, - cache_control: remote.cache_control, - accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()), - e_tag: remote.e_tag.as_deref().and_then(|v| ETag::from_str(v).ok()), - last_modified: remote - .last_modified - .and_then(|dt| OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanos()).ok()) - .map(Timestamp::from), - metadata: remote.metadata, - version_id: remote.version_id, - server_side_encryption: remote - .server_side_encryption - .map(|sse| ServerSideEncryption::from(sse.as_str().to_string())), - sse_customer_algorithm: remote.sse_customer_algorithm, - sse_customer_key_md5: remote.sse_customer_key_md5, - ssekms_key_id: remote.ssekms_key_id, - parts_count: remote.parts_count, - storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())), - expiration: remote.expiration, - restore: remote.restore, - checksum_crc32: remote.checksum_crc32, - checksum_crc32c: remote.checksum_crc32_c, - checksum_crc64nvme: remote.checksum_crc64_nvme, - checksum_sha1: remote.checksum_sha1, - checksum_sha256: remote.checksum_sha256, - checksum_type: remote.checksum_type.map(|ct| ChecksumType::from(ct.as_str().to_string())), - ..Default::default() - } - } - - #[instrument(name = "execute_get_object", level = "trace", skip(self, req))] - pub async fn execute_get_object(&self, req: S3Request) -> S3Result> { - self.execute_get_object_boxed(req).await - } - - fn execute_get_object_boxed( - &self, - req: S3Request, - ) -> impl std::future::Future>> + Send + '_ { - Box::pin(self.execute_get_object_inner(req)) - } - - #[hotpath::measure( - label = "rustfs::app::object_usecase::DefaultObjectUsecase::execute_get_object", - impl_type = "DefaultObjectUsecase" - )] - async fn execute_get_object_inner(&self, req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let inbound_request_context = req.extensions.get::(); - let request_id = inbound_request_context - .map(|ctx| ctx.request_id.clone()) - .unwrap_or_else(|| request_context::RequestContext::fallback().request_id); - if rustfs_io_metrics::get_stage_metrics_enabled() - && let Some(context) = inbound_request_context - { - rustfs_io_metrics::record_get_object_stage_duration( - GET_OBJECT_STAGE_PATH_S3_HANDLER, - GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT, - context.start_time.elapsed().as_secs_f64(), - ); - } - let bootstrap = self.init_get_object_bootstrap(&req.input.bucket, &req.input.key, &request_id)?; - let timeout_config = bootstrap.timeout_config; - let wrapper = bootstrap.wrapper; - let request_start = bootstrap.request_start; - let concurrent_requests = bootstrap.concurrent_requests; - let mut lifecycle = GetObjectBodyLifecycle::tracked(bootstrap.request_guard); - - let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event(); - // mc get 3 - - // Cheap request-shape validations run first so invalid requests keep - // their InvalidArgument precedence over bucket existence. - let validated = match Self::validate_get_object_request(&req) { - Ok(validated) => validated, - Err(err) => { - lifecycle.finish_err(); - return Err(err); - } - }; - - // SF05: Store lookup next (5s-TTL bucket-validation cache). Bucket - // existence is established before any bucket-metadata work, so requests - // naming nonexistent buckets fail before the versioning lookup in - // get_opts. The store comes from the request-bound server context - // (backlog#1052 S6), not the process-global handle. - let object_traffic_health = self.object_traffic_health(); - let object_metadata_progress = object_traffic_health - .as_deref() - .and_then(ObjectTrafficHealth::track_read_metadata); - let store_lookup_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let Some(store) = self.object_store() else { - lifecycle.finish_err(); - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - if let Err(err) = validate_bucket_exists(&store, &req.input.bucket).await { - lifecycle.finish_err(); - return Err(err); - } - if let Some(store_lookup_start) = store_lookup_start { - rustfs_io_metrics::record_get_object_stage_duration( - "s3_handler", - "store_lookup", - store_lookup_start.elapsed().as_secs_f64(), - ); - } - - let request_context_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let request_context = match Self::prepare_get_object_request_context(validated, &req.headers).await { - Ok(request_context) => request_context, - Err(err) => { - lifecycle.finish_err(); - return Err(err); - } - }; - if let Some(request_context_start) = request_context_start { - rustfs_io_metrics::record_get_object_stage_duration( - "s3_handler", - "request_context", - request_context_start.elapsed().as_secs_f64(), - ); - } - let GetObjectRequestContext { - bucket, - key, - version_id_for_event, - part_number, - rs, - opts, - } = request_context; - drop(object_metadata_progress); - - let manager = get_concurrency_manager(); - - let prepared_read = match self - .prepare_get_object_read_execution( - &req, - manager, - store.clone(), - &wrapper, - &timeout_config, - &bucket, - &key, - rs, - &opts, - part_number, - object_traffic_health, - ) - .await - { - Ok(prepared_read) => prepared_read, - Err(err) => { - // Active-active replication lag window: an object missing - // locally (and only missing — other errors keep their - // semantics) may still be served by proxying the GET to a - // replication target (backlog#1675 P1-5). - if matches!(*err.code(), S3ErrorCode::NoSuchKey | S3ErrorCode::NoSuchVersion) - && let Some(output) = Self::proxy_get_object_to_replication_targets(&req, &bucket, &key, &opts).await - { - lifecycle.finish_ok(); - let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; - inject_accept_ranges_header(&mut response.headers); - let result = Ok(response); - let _ = helper.version_id(version_id_for_event).complete(&result); - return result; - } - lifecycle.finish_err(); - return Err(err); - } - }; - let GetObjectPreparedRead { io_planning, read_setup } = prepared_read; - let GetObjectIoPlanning { - disk_permit, - permit_wait_duration, - queue_status, - queue_utilization, - } = io_planning; - - let GetObjectReadSetup { - info, - final_stream, - buffered_body, - cache_hook_served, - cache_hook_probed, - cache_fill_allowed, - rs, - content_type, - last_modified, - response_content_length, - content_range, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - encryption_applied, - resume_range_start, - resume_range_end, - } = read_setup; - let final_stream = if let Some(disk_permit) = disk_permit { - wrap_reader(DiskReadPermitReader::new(final_stream, disk_permit)) - } else { - final_stream - }; - - // Clone ObjectInfo for event notification only when an event will - // actually be built — the clone is expensive for multipart objects. - let event_info = helper.wants_object_info().then(|| info.clone()); - - let output_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let output_context = self - .build_get_object_output_context( - &req, - manager, - &bucket, - &key, - info, - event_info, - final_stream, - buffered_body, - cache_hook_served, - cache_hook_probed, - cache_fill_allowed, - rs, - content_type, - last_modified, - response_content_length, - content_range, - &request_id, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - encryption_applied, - permit_wait_duration, - queue_utilization, - &queue_status, - concurrent_requests, - part_number, - opts.versioned, - lifecycle, - |info| { - Some(get_object_resume_control(GetObjectResumeContext::new( - store, - &bucket, - &key, - opts, - &req.headers, - info, - resume_range_start, - resume_range_end, - ))) - }, - ) - .await; - let output_context = match output_context { - Ok(output_context) => output_context, - Err(err) => return Err(err), - }; - if let Some(output_build_start) = output_build_start { - rustfs_io_metrics::record_get_object_stage_duration( - "s3_handler", - "output_build", - output_build_start.elapsed().as_secs_f64(), - ); - } - let GetObjectOutputContext { - output, - event_info, - response_content_length, - optimal_buffer_size, - extra_checksum_headers, - } = output_context; - - let total_duration = request_start.elapsed(); - Self::finalize_get_object_completion( - &wrapper, - &timeout_config, - total_duration, - response_content_length, - optimal_buffer_size, - ); - - Self::finalize_get_object_response( - helper, - &bucket, - &req.method, - &req.headers, - event_info, - version_id_for_event, - output, - extra_checksum_headers, - ) - .await - } - - pub async fn execute_get_object_attributes( - &self, - req: S3Request, - ) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = - OperationHelper::new(&req, EventName::ObjectAccessedAttributes, S3Operation::GetObjectAttributes).suppress_event(); - let GetObjectAttributesInput { - bucket, - key, - max_parts, - object_attributes, - part_number_marker, - version_id, - sse_customer_key, - sse_customer_key_md5, - .. - } = req.input; - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - - let mut opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), None, &req.headers) - .await - .map_err(ApiError::from)?; - opts.include_part_checksums = object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS); - - let info = match store.get_object_info(&bucket, &key, &opts).await { - Ok(info) => info, - Err(err) => { - if is_err_object_not_found(&err) || is_err_version_not_found(&err) { - if is_dir_object(&key) { - let has_children = match probe_prefix_has_children(store, &bucket, &key, false).await { - Ok(has_children) => has_children, - Err(e) => { - error!( - "Failed to probe children for object attributes (bucket: {}, key: {}): {}", - bucket, key, e - ); - false - } - }; - let msg = head_prefix_not_found_message(&bucket, &key, has_children); - return Err(S3Error::with_message(S3ErrorCode::NoSuchKey, msg)); - } - return Err(S3Error::new(S3ErrorCode::NoSuchKey)); - } - return Err(ApiError::from(err).into()); - } - }; - - if info.delete_marker { - if opts.version_id.is_none() { - return Err(S3Error::new(S3ErrorCode::NoSuchKey)); - } - return Err(S3Error::new(S3ErrorCode::MethodNotAllowed)); - } - - validate_ssec_for_read(&info.user_defined, sse_customer_key.as_ref(), sse_customer_key_md5.as_ref())?; - - let metadata_map = info.user_defined.clone(); - debug!( - "GetObjectAttributes raw object_attributes={:?}", - object_attributes.iter().map(|value| value.as_str()).collect::>() - ); - - let requested = |name: &'static str| -> bool { object_attributes_requested(&object_attributes, name) }; - let storage_class = - response_storage_class_for_object_attributes(&info, &metadata_map, requested(ObjectAttributes::STORAGE_CLASS)); - - let e_tag = if requested(ObjectAttributes::ETAG) { - info.etag.as_ref().map(|etag| to_s3s_etag(etag)) - } else { - None - }; - - let object_size = if requested(ObjectAttributes::OBJECT_SIZE) { - Some(info.get_actual_size().map_err(ApiError::from)?) - } else { - None - }; - - let checksum = if requested(ObjectAttributes::CHECKSUM) { - let (checksums, is_multipart) = info.decrypt_checksums(0, &req.headers).map_err(ApiError::from)?; - // GetObjectAttributes returns checksums in the XML body, and s3s's Checksum - // type has no field for the additional algorithms, so `extra` cannot be - // surfaced here (unlike the header-based GET/HEAD paths) — an s3s limitation - // tracked for when it gains typed fields. - let ResponseChecksums { - crc32: checksum_crc32, - crc32c: checksum_crc32c, - sha1: checksum_sha1, - sha256: checksum_sha256, - crc64nvme: checksum_crc64nvme, - checksum_type, - .. - } = classify_response_checksums(checksums, is_multipart); - - Some(Checksum { - checksum_crc32, - checksum_crc32c, - checksum_sha1, - checksum_sha256, - checksum_crc64nvme, - checksum_type, - ..Default::default() - }) - } else { - None - }; - let object_parts = if requested(ObjectAttributes::OBJECT_PARTS) && info.is_multipart() { - let params = parse_list_parts_params(part_number_marker, max_parts)?; - let mut parts = Vec::new(); - let mut marker = params.part_number_marker; - let max_parts = params.max_parts; - let mut start_at = 0usize; - - if let Some(marker_value) = marker { - if let Some(index) = info.parts.iter().position(|part| part.number == marker_value) { - start_at = index + 1; - } else { - marker = None; - } - } - - let max_parts: i32 = max_parts.try_into().map_err(|_| { - S3Error::with_message(S3ErrorCode::InvalidArgument, "max-parts value is out of range".to_string()) - })?; - let end = (start_at + params.max_parts).min(info.parts.len()); - let is_truncated = end < info.parts.len(); - - for part in &info.parts[start_at..end] { - let (checksums, is_multipart) = info.decrypt_checksums(part.number, &req.headers).map_err(ApiError::from)?; - // Additional algorithms cannot be surfaced in the ObjectPart XML body - // (s3s has no field); same limitation as the object-level attributes above. - let ResponseChecksums { - crc32: checksum_crc32, - crc32c: checksum_crc32c, - sha1: checksum_sha1, - sha256: checksum_sha256, - crc64nvme: checksum_crc64nvme, - .. - } = classify_response_checksums(checksums, is_multipart); - - let part_size = if part.actual_size > 0 { - part.actual_size - } else { - part.size.try_into().map_err(|_| { - S3Error::with_message(S3ErrorCode::InvalidArgument, "Part size value is out of range".to_string()) - })? - }; - - parts.push(ObjectPart { - checksum_crc32, - checksum_crc32c, - checksum_sha1, - checksum_sha256, - checksum_crc64nvme, - part_number: i32::try_from(part.number).ok(), - size: Some(part_size), - ..Default::default() - }); - } - - let part_number_marker = marker.and_then(|v| i32::try_from(v).ok()); - let next_part_number_marker = parts.last().and_then(|part| part.part_number); - - Some(GetObjectAttributesParts { - is_truncated: Some(is_truncated), - max_parts: Some(max_parts), - next_part_number_marker, - part_number_marker, - parts: Some(parts), - total_parts_count: Some(i32::try_from(info.parts.len()).map_err(|_| { - S3Error::with_message(S3ErrorCode::InvalidArgument, "Part count is out of range".to_string()) - })?), - }) - } else { - None - }; - - let version_id = if BucketVersioningSys::prefix_enabled(&bucket, &key).await { - info.version_id.map(|vid| { - if vid == Uuid::nil() { - "null".to_string() - } else { - vid.to_string() - } - }) - } else { - None - }; - - let output = GetObjectAttributesOutput { - checksum, - delete_marker: if info.delete_marker { Some(true) } else { None }, - e_tag, - last_modified: info.mod_time.map(Timestamp::from), - object_parts, - object_size, - storage_class, - version_id: version_id.clone(), - ..Default::default() - }; - - helper = helper.object(info).version_id(version_id.unwrap_or_default()); - - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - result - } - - pub fn execute_copy_object( - &self, - req: S3Request, - ) -> impl std::future::Future>> + Send + '_ { - Box::pin(self.execute_copy_object_inner(req)) - } - - #[instrument(name = "execute_copy_object", level = "debug", skip(self, req))] - async fn execute_copy_object_inner(&self, req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = OperationHelper::new(&req, EventName::ObjectCreatedCopy, S3Operation::CopyObject); - let CopyObjectInput { - copy_source, - bucket, - key, - version_id: dest_version_id, - server_side_encryption: requested_sse, - ssekms_key_id: requested_kms_key_id, - sse_customer_algorithm, - sse_customer_key, - sse_customer_key_md5, - copy_source_sse_customer_algorithm, - copy_source_sse_customer_key, - copy_source_sse_customer_key_md5, - metadata_directive, - metadata, - tagging, - tagging_directive, - copy_source_if_match, - copy_source_if_none_match, - cache_control, - content_disposition, - content_encoding, - content_language, - content_type, - expires, - website_redirect_location, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - storage_class, - checksum_algorithm, - .. - } = req.input.clone(); - let requested_checksum_type = checksum_algorithm - .as_ref() - .map(|algorithm| rustfs_rio::ChecksumType::from_string(algorithm.as_str())); - if requested_checksum_type.is_some_and(|checksum_type| !checksum_type.is_set()) { - return Err(s3_error!(InvalidArgument, "Unsupported checksum algorithm")); - } - let (src_bucket, src_key, version_id) = match copy_source { - CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)), - CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)), - CopySource::Bucket { - ref bucket, - ref key, - version_id, - } => (bucket.to_string(), key.to_string(), version_id.map(|v| v.to_string())), - }; - - // Normalize the copy-source version id like GET/HEAD do: trim, treat "null" as the - // nil UUID, and reject malformed ids up front (issue #4238). - let version_id = match version_id { - Some(v) => { - let trimmed = v.trim(); - if trimmed.eq_ignore_ascii_case("null") { - Some(Uuid::nil().to_string()) - } else if Uuid::parse_str(trimmed).is_ok() { - Some(trimmed.to_string()) - } else { - return Err(s3_error!(InvalidArgument, "Invalid version id specified in copy source")); - } - } - None => None, - }; - - if let Some(ref sc) = storage_class - && !is_valid_storage_class(sc.as_str()) - { - return Err(s3_error!(InvalidStorageClass)); - } - let ssekms_context = extract_ssekms_context_from_headers(&req.headers)?; - validate_sse_headers_for_write( - requested_sse.as_ref(), - requested_kms_key_id.as_ref(), - ssekms_context.as_ref(), - sse_customer_algorithm.as_ref(), - sse_customer_key.as_ref(), - sse_customer_key_md5.as_ref(), - true, - )?; - let has_explicit_ssec = sse_customer_algorithm.is_some() || sse_customer_key.is_some() || sse_customer_key_md5.is_some(); - - // Validate both source and destination keys - validate_object_key(&src_key, "COPY (source)")?; - validate_object_key(&key, "COPY (dest)")?; - validate_table_catalog_object_mutation(&bucket, &key).await?; - let replaces_metadata = match metadata_directive.as_ref().map(|directive| directive.as_str()) { - None | Some(MetadataDirective::COPY) => false, - Some(MetadataDirective::REPLACE) => true, - Some(_) => { - return Err(S3Error::with_message( - S3ErrorCode::InvalidArgument, - "The MetadataDirective header is invalid".to_string(), - )); - } - }; - let replacement_metadata = if replaces_metadata { - validate_archive_content_encoding(&key, content_type.as_deref(), content_encoding.as_deref())?; - let mut replacement_metadata = metadata.unwrap_or_default(); - namespace_reserved_user_metadata(&mut replacement_metadata); - apply_standard_object_metadata( - &mut replacement_metadata, - cache_control.as_deref(), - content_disposition.as_deref(), - content_encoding.as_deref(), - content_language.as_deref(), - content_type.as_deref(), - expires.as_ref(), - website_redirect_location.as_deref(), - )?; - Some(replacement_metadata) - } else { - None - }; - - // AWS S3 allows self-copy when metadata directive is REPLACE (used to update metadata in-place), - // when an explicit storage class change is requested, or when restoring a specific historical - // version onto the current key (source carries a versionId). Reject only a true no-op self-copy - // where none of these apply (issue #4238). - let replacement_tags = super::storage_api::object_usecase::s3_api::tagging::resolve_copy_object_tags( - tagging.as_deref(), - tagging_directive.as_ref(), - )?; - - if !replaces_metadata - && tagging_directive.as_ref().map(TaggingDirective::as_str) != Some(TaggingDirective::REPLACE) - && storage_class.is_none() - && version_id.is_none() - && src_bucket == bucket - && src_key == key - { - error!(bucket, key, "Rejected self-copy operation"); - return Err(s3_error!( - InvalidRequest, - "Cannot copy an object to itself. Source and destination must be different." - )); - } - - // warn!("copy_object {}/{}, to {}/{}", &src_bucket, &src_key, &bucket, &key); - - let mut src_opts = copy_src_opts(&src_bucket, &src_key, &req.headers).map_err(ApiError::from)?; - - src_opts.version_id = version_id.clone(); - - let mut src_get_opts = ObjectOptions { - version_id: src_opts.version_id.clone(), - versioned: src_opts.versioned, - version_suspended: src_opts.version_suspended, - ..Default::default() - }; - apply_copy_source_bucket_generation_guard(&req, &src_bucket, &mut src_get_opts)?; - - let mut dst_opts = copy_dst_opts_with_replication_authorization( - &bucket, - &key, - dest_version_id.clone(), - &req.headers, - HashMap::new(), - replication_request_authorized(&req), - ) - .await - .map_err(ApiError::from)?; - apply_bucket_generation_guard(&req, &bucket, &mut dst_opts)?; - - let cp_src_dst_same = path_join_buf(&[&src_bucket, &src_key]) == path_join_buf(&[&bucket, &key]); - let expected_current_version_id = expected_current_version_id(&req.headers)?; - if expected_current_version_id.is_some() - && (!cp_src_dst_same || version_id.is_none() || dest_version_id.is_some() || !dst_opts.versioned) - { - return Err(s3_error!( - InvalidRequest, - "Expected current version precondition requires a versioned same-object historical copy that creates a new version" - )); - } - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - let (source_bucket_lifecycle_guard, destination_bucket_lifecycle_guard_storage) = - acquire_copy_bucket_lifecycle_locks(store.as_ref(), &src_bucket, &bucket).await?; - let current_source_incarnation_id = store - .bucket_incarnation_id_from_disk(&src_bucket) - .await - .map_err(ApiError::from)?; - if src_get_opts - .expected_bucket_incarnation_id - .is_some_and(|expected| expected != current_source_incarnation_id) - { - return Err(ApiError::from(StorageError::BucketNotFound(src_bucket.clone())).into()); - } - let current_destination_incarnation_id = if src_bucket == bucket { - current_source_incarnation_id - } else { - store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)? - }; - if dst_opts - .expected_bucket_incarnation_id - .is_some_and(|expected| expected != current_destination_incarnation_id) - { - return Err(ApiError::from(StorageError::BucketNotFound(bucket.clone())).into()); - } - let destination_bucket_lifecycle_guard = destination_bucket_lifecycle_guard_storage - .as_ref() - .unwrap_or(&source_bucket_lifecycle_guard); - if source_bucket_lifecycle_guard.is_lock_lost() || destination_bucket_lifecycle_guard.is_lock_lost() { - return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable { - mode: "copy_bucket_generation", - bucket: bucket.clone(), - object: key.clone(), - required: 1, - achieved: 0, - }) - .into()); - } - src_get_opts.expected_bucket_incarnation_id = Some(current_source_incarnation_id); - dst_opts.expected_bucket_incarnation_id = Some(current_destination_incarnation_id); - if src_bucket != bucket { - dst_opts.add_bucket_lifecycle_lock_guard(&source_bucket_lifecycle_guard); - } - dst_opts.add_bucket_lifecycle_lock_guard(destination_bucket_lifecycle_guard); - - // Bucket metadata uses the bucket name as its namespace-lock key. Load - // every copy-time bucket snapshot before a same-object key can collide - // with that key (for example, copying `bucket/bucket` onto itself). - let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); - let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?; - if cp_src_dst_same && key == bucket && expected_current_version_id.is_none() { - dst_opts.object_lock_config_snapshot = - Some(store.object_lock_config_snapshot(&bucket).await.map_err(ApiError::from)?); - } - let mut current_opts: ObjectOptions = internal_object_info_lookup_opts( - get_opts(&bucket, &key, dest_version_id.clone(), None, &req.headers) - .await - .map_err(ApiError::from)?, - ); - - let _self_copy_lock_guard = if cp_src_dst_same && expected_current_version_id.is_none() { - let guard = acquire_self_copy_namespace_lock(store.as_ref(), &bucket, &key).await?; - src_opts.no_lock = true; - src_get_opts.no_lock = true; - dst_opts.no_lock = true; - Some(guard) - } else { - None - }; - if let Some(guard) = _self_copy_lock_guard.as_ref() { - dst_opts.add_namespace_lock_guard(guard); - } - dst_opts.expected_current_version_id = expected_current_version_id.clone(); - - if _self_copy_lock_guard.is_some() { - current_opts.no_lock = true; - } - let previous_current_sizes = match store.get_object_info(&bucket, &key, ¤t_opts).await { - Ok(existing_obj_info) => { - validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &dst_opts)?; - if let Some(expected) = expected_current_version_id.as_deref() - && existing_obj_info.version_id.unwrap_or_default().to_string() != expected - { - return Err(s3_error!(PreconditionFailed)); - } - Some((existing_obj_info.size.max(0) as u64, quota_object_size(&existing_obj_info))) - } - Err(err) => { - if expected_current_version_id.is_some() { - return Err(s3_error!(PreconditionFailed)); - } - if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { - return Err(ApiError::from(err).into()); - } - None - } - }; - - let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( - bucket_sse_config.as_ref().map(|(config, _)| config), - requested_sse, - requested_kms_key_id, - has_explicit_ssec, - ); - - let h = build_ssec_read_headers( - copy_source_sse_customer_algorithm.as_ref(), - copy_source_sse_customer_key.as_ref(), - copy_source_sse_customer_key_md5.as_ref(), - ); - - let copy_principal = SseKmsPrincipal::from_request(&req); - - if source_bucket_lifecycle_guard.is_lock_lost() { - return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable { - mode: "copy_source_bucket_generation", - bucket: src_bucket.clone(), - object: src_key.clone(), - required: 1, - achieved: 0, - }) - .into()); - } - - let (gr, source_cancellation) = store - .get_object_reader_for_copy(&src_bucket, &src_key, None, h, &src_get_opts) - .await - .map_err(map_get_object_reader_error)?; - - // The commit owner is intentionally detached so SetDisk can finish - // its rename/cleanup and post-commit publication if the HTTP caller - // goes away. Keep a request-owned guard for the source producer: - // cancellation drops the source read promptly, while the detached - // commit task retains the guards it needs to complete safely. - let _source_cancellation_guard = source_cancellation.clone().drop_guard(); - - let mut src_info = gr.object_info.clone(); - - // A copy reads the source plaintext, so it needs the source key's decrypt permission - // as well as the destination key's generate permission below. The source read resolves - // its material inside the object layer, which has no request identity, so the check - // happens here. - authorize_sse_kms_object_read(copy_principal.as_ref(), &src_info.user_defined).await?; - - // Capture the version actually read from the source before src_info is mutated/consumed - // below. This is the exact source version copied (issue #4976): the response must echo it - // via x-amz-copy-source-version-id, distinct from the destination version_id. - let src_resolved_version_id = src_info.version_id; - - // Source object's existing checksum, if any. When the copy does not request a new - // algorithm, AWS preserves the source object's checksum on the destination (#4996); the - // copy does not transform the plaintext, so we carry the stored value over unchanged - // rather than re-hashing every byte. - let src_checksum = src_info.checksum.as_ref().and_then(|bytes| { - let (pairs, _) = rustfs_rio::read_checksums(bytes.as_ref(), 0); - pairs - .into_iter() - .find_map(|(k, v)| rustfs_rio::Checksum::new_from_string(&k, &v)) - }); - - // Validate copy source conditions - if let Some(if_match) = copy_source_if_match { - if let Some(ref etag) = src_info.etag { - if let Some(strong_etag) = if_match.into_etag() { - if ETag::Strong(etag.clone()) != strong_etag { - return Err(s3_error!(PreconditionFailed)); - } - } else { - // Weak ETag or Any (*) in If-Match should fail per RFC 9110 - return Err(s3_error!(PreconditionFailed)); - } - } else { - return Err(s3_error!(PreconditionFailed)); - } - } - - if let Some(if_none_match) = copy_source_if_none_match - && let Some(ref etag) = src_info.etag - && let Some(strong_etag) = if_none_match.into_etag() - && ETag::Strong(etag.clone()) == strong_etag - { - return Err(s3_error!(PreconditionFailed)); - } - - // A same-name copy is normally serviced as a metadata-only update: the store layer - // rewrites xl.meta in place and leaves the data blocks alone. That shortcut is only sound - // when the destination's physical bytes are identical to the source's, and encryption - // breaks exactly that. The destination metadata is rebuilt from scratch below — - // `strip_managed_encryption_metadata` drops the source DEK and `sse_encryption` mints a - // fresh one — so reusing the stored ciphertext would leave a new DEK sitting beside bytes - // it cannot decrypt, permanently destroying the object (GET fails with an AEAD tag - // mismatch). The mirror case is worse because it is silent: an encrypted source copied - // without any destination SSE keeps its ciphertext while losing the key metadata, so GET - // hands back raw ciphertext as if it were plaintext. So whenever either side is - // encrypted, leave metadata_only = false and let the store layer do a full read/write - // rewrite through put_object, the same resolution the versioned historical-restore path - // uses (issue #4238, crates/ecstore/src/store/object.rs). - // - // This mirrors MinIO's `isSourceEncrypted || isTargetEncrypted -> metadataOnly = false` - // in CopyObjectHandler, with one deliberate difference: MinIO decides "target encrypted" - // from request headers alone, while `effective_sse` here also resolves the bucket default - // encryption rule. `sse_encryption` mints a DEK from that resolved value, so a - // header-only check would miss a self-copy under a bucket default rule. The source half - // deliberately reuses `ObjectInfo::is_encrypted` rather than naming individual headers, - // so a future encryption flavour is covered here the moment it is recognised there. - // - // The zero-copy shortcut is only recoverable for encrypted objects by *preserving* the - // DEK that sealed the bytes and re-wrapping it under a new master key (MinIO's - // `rotateKey` + `keyRotation` flag, which is why it may keep metadataOnly = true). RustFS - // has no such rewrap primitive today; adding one is backlog#1637, and it would enter here - // as an explicit exception rather than by relaxing this guard. - let copy_changes_encryption = src_info.is_encrypted() || effective_sse.is_some() || has_explicit_ssec; - if cp_src_dst_same && src_info.transitioned_object.tier.is_empty() && !copy_changes_encryption { - src_info.metadata_only = true; - } - - // Extract user_defined from Arc for mutation; it will be re-wrapped after all edits. - let mut user_defined = (*src_info.user_defined).clone(); - let effective_tags = replacement_tags.unwrap_or_else(|| (*src_info.user_tags).clone()); - if !replaces_metadata { - let source_expires = src_info.expires.map(Timestamp::from); - insert_expires_metadata(&mut user_defined, source_expires.as_ref())?; - } - - strip_managed_encryption_metadata(&mut user_defined); - - let destination_storage_class = storage_class - .as_ref() - .map(StorageClass::as_str) - .unwrap_or(storageclass::STANDARD); - src_info.storage_class = Some(destination_storage_class.to_string()); - - let actual_size = src_info.get_actual_size().map_err(ApiError::from)?; - - let length = actual_size; - - let mut compress_metadata = HashMap::new(); - - let should_compress = is_disk_compressible(&req.headers, &key) && actual_size > MIN_DISK_COMPRESSIBLE_SIZE as i64; - - if should_compress { - insert_str( - &mut compress_metadata, - SUFFIX_COMPRESSION, - compression_metadata_value(CompressionAlgorithm::default()), - ); - insert_str(&mut compress_metadata, SUFFIX_ACTUAL_SIZE, actual_size.to_string()); - } else { - remove_str(&mut user_defined, SUFFIX_COMPRESSION); - remove_str(&mut user_defined, SUFFIX_ACTUAL_SIZE); - remove_str(&mut user_defined, SUFFIX_COMPRESSION_SIZE); - } - - // Handle MetadataDirective REPLACE: replace user metadata while preserving system metadata. - // System metadata (compression, encryption) is added after this block to ensure - // it's not cleared by the REPLACE operation. - if let Some(replacement_metadata) = replacement_metadata { - user_defined = replacement_metadata; - src_info.content_type = content_type.clone(); - src_info.content_encoding = content_encoding.as_deref().and_then(normalize_content_encoding_for_storage); - src_info.expires = expires.map(OffsetDateTime::from); - } else if metadata_directive.is_some() || website_redirect_location.is_some() { - user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_WEBSITE_REDIRECT_LOCATION)); - if let Some(website_redirect_location) = website_redirect_location { - user_defined.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location); - } - } - - user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_STORAGE_CLASS)); - if destination_storage_class != storageclass::STANDARD { - user_defined.insert(AMZ_STORAGE_CLASS.to_string(), destination_storage_class.to_string()); - } - - user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_OBJECT_TAGGING)); - if !effective_tags.is_empty() { - user_defined.insert(AMZ_OBJECT_TAGGING.to_string(), effective_tags.clone()); - } - src_info.user_tags = Arc::new(effective_tags); - - let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); - remove_object_lock_metadata_for_copy(&mut user_defined); - if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( - &bucket, - &object_lock_config_state, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - )? { - user_defined.extend(object_lock_metadata); - } - apply_bucket_default_lock_retention( - &bucket, - &object_lock_config_state, - &mut user_defined, - has_explicit_object_lock_retention, - )?; - - let mut write_plan = WritePlan::new(); - let mut reader = if should_compress { - let algorithm = CompressionAlgorithm::default(); - let hrd = HashReader::from_stream(gr.stream, length, actual_size, None, None, false).map_err(ApiError::from)?; - write_plan = write_plan.with_compression(algorithm); - hrd - } else { - HashReader::from_stream(gr.stream, length, actual_size, None, None, false).map_err(ApiError::from)? - }; - - // Give the destination object a checksum so CopyObject returns it and a later checksum-mode - // HEAD/GET matches (#4996). When the caller requests an algorithm, compute it fresh over the - // copied plaintext (the hasher sits on the innermost reader so it digests plaintext). When - // none is requested, carry the source object's stored checksum over unchanged — the copy - // does not alter the plaintext, so re-hashing would be wasted work and would flatten a - // multipart composite value. - match requested_checksum_type { - Some(checksum_type) => { - reader.add_calculated_checksum(checksum_type).map_err(ApiError::from)?; - } - None => { - if let Some(cs) = src_checksum { - reader.add_non_trailing_checksum(Some(cs), true).map_err(ApiError::from)?; - } - } - } - - let encryption_request = EncryptionRequest { - bucket: &bucket, - key: &key, - server_side_encryption: effective_sse.clone(), - ssekms_key_id: effective_kms_key_id.clone(), - ssekms_context, - sse_customer_algorithm: sse_customer_algorithm.clone(), - sse_customer_key, - sse_customer_key_md5: sse_customer_key_md5.clone(), - content_size: actual_size, - principal: copy_principal.as_ref(), - }; - - if let Some(material) = sse_encryption(encryption_request).await? { - effective_sse = Some(material.server_side_encryption.clone()); - effective_kms_key_id = material.kms_key_id.clone(); - - write_plan = write_plan.with_encryption(material.write_encryption(None)); - - user_defined.extend(encryption_material_to_metadata(&material)?); - } - - reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?; - - src_info.put_object_reader = Some(PutObjReader::new(reader)); - - // check quota - - for (k, v) in compress_metadata { - user_defined.insert(k, v); - } - - // The source object's replication bookkeeping (internal status/timestamp, - // replica state, and the surfaced x-amz-replication-status) describes the - // SOURCE's replication history; carried onto the destination it fakes a - // COMPLETED/REPLICA state for an object that never replicated (MinIO - // filterReplicationStatusMetadata parity). Inbound replica writes are - // exempt: the authorized replication request owns these keys (see - // copy_dst_opts_with_replication_authorization above). - if !dst_opts.replication_request { - user_defined.retain(|k, _| !k.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)); - remove_str(&mut user_defined, SUFFIX_REPLICATION_STATUS); - remove_str(&mut user_defined, SUFFIX_REPLICATION_TIMESTAMP); - remove_str(&mut user_defined, SUFFIX_REPLICA_STATUS); - remove_str(&mut user_defined, SUFFIX_REPLICA_TIMESTAMP); - } - - // Compute the replication decision exactly once per copy. The same - // immutable `dsc` drives both the pending metadata written below and the - // post-commit schedule (see the reuse site after copy_object), so a - // replication-config hot update cannot split the two phases — same - // contract as the PUT path (https://github.com/rustfs/backlog/issues/1320). - // `must_replicate_object` itself declines inbound replica writes - // (replication_request / REPLICA status), so replicas are never - // re-scheduled outbound. - let dsc = must_replicate_object( - &bucket, - &key, - &user_defined, - "".to_string(), - dst_opts.delete_marker_replication_status(), - dst_opts.clone(), - ) - .await; - if dsc.replicate_any() { - insert_str(&mut user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); - insert_str(&mut user_defined, SUFFIX_REPLICATION_STATUS, dsc.pending_status().unwrap_or_default()); - } - - src_info.user_defined = Arc::new(user_defined); - - let quota_check = self - .check_bucket_quota( - &bucket, - QuotaOperation::CopyObject, - u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - ) - .await?; - let quota_enabled = quota_check.as_ref().is_some_and(|result| result.quota_limit.is_some()); - if let Some(quota_check) = quota_check.as_ref() { - apply_quota_admission(&mut dst_opts, quota_check)?; - } - let previous_current_size = match previous_current_sizes { - Some((_, Ok(logical_size))) if quota_enabled => Some(logical_size), - Some((_, Err(err))) if quota_enabled => return Err(ApiError::from(err).into()), - Some((physical_size, _)) => Some(physical_size), - None => None, - }; - if let Some(quota_check) = quota_check.as_ref() { - ensure_object_size_within_quota( - quota_check, - u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - )?; - } - let has_bucket_metadata = self.bucket_metadata_sys().is_some(); - let cache_adapter = self.object_data_cache(); - let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; - - let copy_commit = spawn_traced_join({ - let store = Arc::clone(&store); - let src_bucket = src_bucket.clone(); - let src_key = src_key.clone(); - let bucket = bucket.clone(); - let key = key.clone(); - let src_opts = src_opts.clone(); - let dst_opts = dst_opts.clone(); - async move { - let _source_bucket_lifecycle_guard = source_bucket_lifecycle_guard; - let _destination_bucket_lifecycle_guard_storage = destination_bucket_lifecycle_guard_storage; - let _self_copy_lock_guard = _self_copy_lock_guard; - - let oi = store - .copy_object(&src_bucket, &src_key, &bucket, &key, &mut src_info, &src_opts, &dst_opts) - .await - .map_err(ApiError::from)?; - - // Reuse the single pre-commit replication decision (see `dsc` above) so - // the persisted pending marker and the schedule always agree, mirroring - // the PUT path. - if dsc.replicate_any() { - schedule_object_replication(oi.clone(), Arc::clone(&store), dsc).await; - } - - maybe_enqueue_transition_immediate(&oi, LcEventSrc::S3CopyObject).await; - let _ = invalidate_object_data_cache_after_copy_success(&cache_adapter, &bucket, &key).await; - - let dest_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await; - if has_bucket_metadata { - let committed_size = quota_accounting_object_size(&oi, quota_enabled)?; - if dest_versioned { - record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; - } else { - record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; - } - } - - rustfs_scanner::record_dirty_usage_bucket(&bucket); - Ok::<_, S3Error>((oi, dest_versioned)) - } - }); - let (oi, dest_versioned) = copy_commit.await.map_err(|err| { - S3Error::with_message(S3ErrorCode::InternalError, format!("copy object commit owner task failed: {err}")) - })??; - - let raw_dest_version = oi.version_id.map(|v| v.to_string()); - let dest_version = if dest_versioned { raw_dest_version } else { None }; - - // Echo the source version that was copied via x-amz-copy-source-version-id (issue #4976). - // AWS/MinIO return this whenever the source bucket carries versioning (enabled or - // suspended); render the null version as "null" like GET/HEAD do. This is the exact source - // version, kept distinct from the destination version_id above. - let src_versioned = BucketVersioningSys::prefix_enabled(&src_bucket, &src_key).await - || BucketVersioningSys::prefix_suspended(&src_bucket, &src_key).await; - let copy_source_version_id = if src_versioned { - src_resolved_version_id.map(|vid| { - if vid == Uuid::nil() { - "null".to_string() - } else { - vid.to_string() - } - }) - } else { - None - }; - - // Report the destination object's checksum in the response, decoded the same way GetObject - // / HeadObject do so the value is identical to a later checksum-mode HEAD/GET (#4996). - let response_checksums = oi - .decrypt_checksums(0, &req.headers) - .map(|(pairs, is_multipart)| classify_response_checksums(pairs, is_multipart)) - .unwrap_or_default(); - - // warn!("copy_object oi {:?}", &oi); - let object_info = oi.clone(); - let mut checksum_md5 = None; - let mut checksum_sha512 = None; - let mut checksum_xxhash3 = None; - let mut checksum_xxhash64 = None; - let mut checksum_xxhash128 = None; - for (name, value) in response_checksums.extra { - match name { - "x-amz-checksum-md5" => checksum_md5 = Some(value), - "x-amz-checksum-sha512" => checksum_sha512 = Some(value), - "x-amz-checksum-xxhash3" => checksum_xxhash3 = Some(value), - "x-amz-checksum-xxhash64" => checksum_xxhash64 = Some(value), - "x-amz-checksum-xxhash128" => checksum_xxhash128 = Some(value), - _ => {} - } - } - let copy_object_result = CopyObjectResult { - e_tag: oi.etag.as_ref().map(|etag| to_s3s_etag(etag)), - last_modified: oi.mod_time.map(Timestamp::from), - checksum_crc32: response_checksums.crc32, - checksum_crc32c: response_checksums.crc32c, - checksum_sha1: response_checksums.sha1, - checksum_sha256: response_checksums.sha256, - checksum_crc64nvme: response_checksums.crc64nvme, - checksum_md5, - checksum_sha512, - checksum_xxhash3, - checksum_xxhash64, - checksum_xxhash128, - checksum_type: response_checksums.checksum_type, - }; - - let output = CopyObjectOutput { - copy_object_result: Some(copy_object_result), - copy_source_version_id, - server_side_encryption: effective_sse, - ssekms_key_id: effective_kms_key_id, - sse_customer_algorithm, - sse_customer_key_md5, - version_id: dest_version, - ..Default::default() - }; - - let version_id = req.input.version_id.clone().unwrap_or_default(); - helper = helper.object(object_info).version_id(version_id); - - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - result - } - - #[instrument(level = "debug", skip(self, req))] - pub async fn execute_delete_objects( - &self, - mut req: S3Request, - ) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObjects).suppress_event(); - let request_context = helper.request_context_or_from_request(&req); - let (bucket, delete) = { - let bucket = req.input.bucket.clone(); - let delete = req.input.delete.clone(); - (bucket, delete) - }; - - if delete.objects.is_empty() || delete.objects.len() > 1000 { - return Err(S3Error::with_message( - S3ErrorCode::InvalidArgument, - "No objects to delete or too many objects to delete".to_string(), - )); - } - - let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false); - if !recursive_force_delete_is_authorized(&req.headers, is_owner, false) { - return Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Recursive force-delete is restricted to administrative requests", - )); - } - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - // Capture the bucket generation before per-object authorization, but - // do not expose a bucket-state error unless at least one object is authorized. - let bucket_generation = load_bucket_generation_from_store(store.as_ref(), &req, &bucket).await; - - let bypass_governance = has_bypass_governance_header(&req.headers); - - #[derive(Default, Clone)] - struct DeleteResult { - delete_object: Option, - error: Option, - synthetic_version_id: bool, - } - - let mut delete_results = vec![DeleteResult::default(); delete.objects.len()]; - - struct AuthorizedDelete { - idx: usize, - object: ObjectToDelete, - } - - let mut authorized_deletes = Vec::with_capacity(delete.objects.len()); - // Issue #5740: keep the first per-key denial of this bulk request at - // warn and demote the rest to debug, so a denied 1000-key DeleteObjects - // cannot flood the log. - let mut bulk_denial_logged = false; - for (idx, obj_id) in delete.objects.iter().enumerate() { - let raw_version_id = obj_id.version_id.clone(); - let (version_id, version_uuid) = match normalize_delete_objects_version_id(raw_version_id.clone()) { - Ok(parsed) => parsed, - Err(err) => { - delete_results[idx].error = Some(s3s::dto::Error { - code: Some("NoSuchVersion".to_string()), - key: Some(obj_id.key.clone()), - message: Some(err), - version_id: raw_version_id, - }); - continue; - } - }; - - { - let req_info = req_info_mut(&mut req)?; - req_info.bucket = Some(bucket.clone()); - req_info.object = Some(obj_id.key.clone()); - req_info.version_id = version_id.clone(); - } - - let auth_res = authorize_request(&mut req, Action::S3Action(S3Action::DeleteObjectAction)).await; - if auth_res.is_err() { - if !bulk_denial_logged { - bulk_denial_logged = true; - req_info_mut(&mut req)?.suppress_denial_log = true; - } - delete_results[idx].error = Some(s3s::dto::Error { - code: Some("AccessDenied".to_string()), - key: Some(obj_id.key.clone()), - message: Some("Access Denied".to_string()), - version_id: version_id.clone(), - }); - continue; - } - - if bypass_governance { - let auth_res = authorize_request(&mut req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await; - if auth_res.is_err() { - if !bulk_denial_logged { - bulk_denial_logged = true; - req_info_mut(&mut req)?.suppress_denial_log = true; - } - delete_results[idx].error = Some(s3s::dto::Error { - code: Some("AccessDenied".to_string()), - key: Some(obj_id.key.clone()), - message: Some("Access Denied".to_string()), - version_id: version_id.clone(), - }); - continue; - } - } - - if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await { - delete_results[idx].error = Some(s3s::dto::Error { - code: Some("InvalidRequest".to_string()), - key: Some(obj_id.key.clone()), - message: Some(err.to_string()), - version_id: version_id.clone(), - }); - continue; - } - - let synthetic_version_id = version_id.is_none() && is_dir_object(&obj_id.key); - let object = ObjectToDelete { - object_name: obj_id.key.clone(), - version_id: version_uuid, - synthetic_version_id, - ..Default::default() - }; - delete_results[idx].synthetic_version_id = synthetic_version_id; - - authorized_deletes.push(AuthorizedDelete { idx, object }); - } - - if authorized_deletes.is_empty() { - let output = DeleteObjectsOutput { - deleted: Some(Vec::new()), - errors: Some(delete_results.into_iter().filter_map(|result| result.error).collect()), - ..Default::default() - }; - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - return result; - } - #[cfg(test)] - wait_for_delete_objects_auth_test_hook(&bucket).await; - req.extensions.insert(bucket_generation?); - let bucket_lock_enabled = object_lock_checks_required(&bucket).await; - - let delete_config_snapshot = Arc::new( - load_delete_config_snapshot(store.as_ref(), &bucket) - .await - .map_err(ApiError::from)?, - ); - let version_cfg = delete_config_snapshot.versioning_config(); - let replicate_deletes = authorized_deletes - .iter() - .any(|authorized| has_active_delete_rule(&delete_config_snapshot, &authorized.object.object_name)); - - struct PreparedDelete { - idx: usize, - object: ObjectToDelete, - opts: ObjectOptions, - skip_stat: bool, - } - - // Phase 1 (serial): derive storage options from the request-scoped - // configuration after every candidate has passed authorization. - let mut prepared_deletes: Vec = Vec::with_capacity(authorized_deletes.len()); - for authorized in authorized_deletes { - let AuthorizedDelete { idx, object } = authorized; - - let metadata = extract_metadata(&req.headers); - let opts: ObjectOptions = del_opts_with_versioning( - &bucket, - &object.object_name, - object.version_id.map(|f| f.to_string()), - &req.headers, - metadata, - version_cfg, - false, - ) - .map_err(ApiError::from)?; - - // backlog#929 (HP-8): the accounting branch after the store delete - // decides delete-marker vs object-delete from this exact snapshot, - // so evaluate it here with the same inputs to keep the stat-skip - // decision and the accounting path provably consistent. - let accounting_creates_delete_marker = object.version_id.is_none() && opts.versioned && !opts.version_suspended; - let skip_stat = can_skip_delete_objects_pre_stat(bucket_lock_enabled, &opts, accounting_creates_delete_marker); - - prepared_deletes.push(PreparedDelete { - idx, - object, - opts, - skip_stat, - }); - } - - struct AdmittedDelete { - idx: usize, - object: ObjectToDelete, - versioned: bool, - version_suspended: bool, - } - - // Phase 2 (bounded concurrency, backlog#929 / HP-8): collect the - // metadata needed for accounting and tier cleanup. Entries are - // independent per key, and `buffered` preserves input order. Object - // Lock admission is enforced later in set_disk under the write lock. - let store_ref = &store; - let bucket_ref = bucket.as_str(); - let admitted_deletes: Vec = - futures::stream::iter(prepared_deletes.into_iter().map(|prepared| async move { - let PreparedDelete { - idx, - mut object, - opts, - skip_stat, - } = prepared; - let synthetic_version_id = object.version_id.is_none() && is_dir_object(&object.object_name); - if !skip_stat { - match store_ref.get_object_info(bucket_ref, &object.object_name, &opts).await { - Ok(_) => {} - Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {} - Err(err) => return Err(ApiError::from(err)), - } - } - - if synthetic_version_id { - object.version_id = Some(Uuid::nil()); - } - - Ok::<_, ApiError>(AdmittedDelete { - idx, - object, - versioned: opts.versioned, - version_suspended: opts.version_suspended, - }) - })) - .buffered(DELETE_OBJECTS_PRE_STAT_CONCURRENCY) - .try_collect() - .await?; - - // Phase 3 (serial): apply outcomes in the original request order so - // per-key success/failure reporting is unchanged. - let mut object_to_delete = Vec::new(); - let mut object_to_delete_idx = Vec::new(); - let mut object_versioning = Vec::new(); - for admitted in admitted_deletes { - object_to_delete_idx.push(admitted.idx); - object_versioning.push((admitted.versioned, admitted.version_suspended)); - object_to_delete.push(admitted.object); - } - let cache_adapter = self.object_data_cache(); - let cache_keys_before_delete = object_to_delete - .iter() - .map(|object| object.object_name.clone()) - .collect::>(); - invalidate_object_data_cache_objects_before_mutation(&cache_adapter, &bucket, cache_keys_before_delete.iter()).await; - - let mut storage_delete_opts = ObjectOptions { - versioned: version_cfg.enabled(), - version_suspended: version_cfg.suspended(), - delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)), - object_lock_delete: Some(StorageObjectLockDeleteOptions { bypass_governance }), - ..Default::default() - }; - apply_bucket_generation_guard(&req, &bucket, &mut storage_delete_opts)?; - let (dobjs, errs, accounting) = store - .delete_objects_with_tier_delete_journal_and_accounting(&bucket, object_to_delete.clone(), storage_delete_opts) - .await; - - let _manager = get_concurrency_manager(); - let _bucket_clone = bucket.clone(); - let _deleted_objects = dobjs.clone(); - if !errs.is_empty() && errs.iter().all(|err| err.as_ref().is_some_and(is_err_bucket_not_found)) { - let result = Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string())); - let _ = helper.complete(&result); - return result; - } - - for (i, err) in errs.iter().enumerate() { - let didx = object_to_delete_idx[i]; - - match reduce_delete_objects_result( - &object_to_delete[i], - &dobjs[i], - err.as_ref(), - delete_results[didx].synthetic_version_id, - ) { - Ok(deleted_object) => { - delete_results[didx].delete_object = Some(deleted_object.clone()); - let (versioned, version_suspended) = object_versioning[i]; - let creates_delete_marker = object_to_delete[i].version_id.is_none() && versioned && !version_suspended; - let committed_delete_marker = dobjs[i].delete_marker; - let delete_accounting = accounting.get(i).and_then(Option::as_ref); - let update = delete_memory_update( - creates_delete_marker, - committed_delete_marker, - delete_request_targets_current(object_to_delete[i].version_id), - delete_accounting.and_then(|value| value.size), - delete_accounting.is_some_and(|value| value.removed_current_object), - ); - apply_delete_memory_update(&bucket, update).await; - } - Err(error) => { - delete_results[didx].error = Some(error); - } - } - } - - let deleted = delete_results - .iter() - .filter_map(|result| result.delete_object.as_ref().map(|object| (result, object))) - .map(|(result, object)| DeletedObject { - delete_marker: { if object.delete_marker { Some(true) } else { None } }, - delete_marker_version_id: delete_response_version_id( - object.delete_marker_version_id, - result.synthetic_version_id, - ), - key: Some(object.object_name.clone()), - version_id: delete_response_version_id(object.version_id, result.synthetic_version_id), - }) - .collect(); - let deleted_cache_keys = delete_results - .iter() - .filter_map(|result| result.delete_object.as_ref().map(|deleted| deleted.object_name.clone())) - .collect::>(); - invalidate_object_data_cache_objects_after_delete_success(&cache_adapter, &bucket, deleted_cache_keys.iter()).await; - - let errors = delete_results - .iter() - .filter_map(|v| v.error.clone()) - .collect::>(); - let output = DeleteObjectsOutput { - deleted: Some(deleted), - errors: Some(errors), - ..Default::default() - }; - let helper = if helper.wants_audit_object_info() { - let audit_objects = - successful_delete_audit_objects(&delete, delete_results.iter().map(|result| result.delete_object.is_some())); - helper.audit_objects(audit_objects) - } else { - helper - }; - - let replication_deletes = if replicate_deletes { - delete_results - .iter() - .filter_map(|result| result.delete_object.as_ref()) - .filter(|dobj| deleted_object_has_pending_replication_delete(dobj)) - .cloned() - .collect::>() - } else { - Vec::new() - }; - if !replication_deletes.is_empty() { - let bucket_for_replication = bucket.clone(); - let replication_task = tokio::spawn(async move { - let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Replication); - schedule_replication_deletes(replication_deletes, bucket_for_replication, REPLICATE_INCOMING_DELETE.to_string()) - .await; - }); - // The spawned task owns every locally committed delete. Dropping the - // join handle on request cancellation therefore cannot lose the tail. - let _ = replication_task.await; - } - - let req_headers = req.headers.clone(); - let notify = current_notify_interface_for_context(self.context.as_deref()); - let req_params = rustfs_targets::extract_params_header(&req_headers); - let resp_elements = - build_event_resp_elements(&S3Response::new(DeleteObjectsOutput::default()), &request_context.request_id); - let deleted_any = delete_results.iter().any(|result| result.delete_object.is_some()); - let notify_bucket = bucket.clone(); - spawn_background_with_context(Some(request_context), async move { - let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Notify); - for res in delete_results { - if let Some(dobj) = res.delete_object { - let event_name = delete_event_name_for_marker(dobj.delete_marker); - let event_args = EventArgsBuilder::new( - event_name, - notify_bucket.clone(), - convert_ecstore_object_info(ObjectInfo { - name: dobj.object_name.clone(), - bucket: notify_bucket.clone(), - ..Default::default() - }), - ) - .version_id(delete_response_version_id(dobj.version_id, res.synthetic_version_id).unwrap_or_default()) - .req_params(req_params.clone()) - .resp_elements(resp_elements.clone()) - .host(get_request_host(&req_headers)) - .user_agent(get_request_user_agent(&req_headers)) - .build(); - - notify.notify(event_args).await; - } - } - }); - - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - if deleted_any { - rustfs_scanner::record_dirty_usage_bucket(&bucket); - } - // Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead) - let manager = get_capacity_manager(); - manager.record_write_operation().await; - result - } - - #[instrument(level = "info", skip(self, req))] - pub async fn execute_delete_object(&self, mut req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObject); - let DeleteObjectInput { - bucket, key, version_id, .. - } = req.input.clone(); - - // Validate object key - validate_object_key(&key, "DELETE")?; - - let replica = req - .headers - .get(AMZ_BUCKET_REPLICATION_STATUS) - .map(|v| v.to_str().unwrap_or_default() == ReplicationStatusType::Replica.as_str()) - .unwrap_or_default(); - - if replica { - authorize_request(&mut req, Action::S3Action(S3Action::ReplicateDeleteAction)).await?; - } - - let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false); - if !recursive_force_delete_is_authorized(&req.headers, is_owner, replica) { - return Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Recursive force-delete is restricted to internal or administrative requests", - )); - } - validate_table_catalog_object_mutation(&bucket, &key).await?; - - // Establish bucket existence before any bucket-metadata work (matches - // PUT/GET): nonexistent buckets fail here instead of paying the - // versioning lookups in del_opts/get_opts first. Resolve the store - // through the request-bound server context (backlog#1052 S6), not the - // process-global handle. - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - validate_bucket_exists(&store, &bucket).await?; - - let metadata = extract_metadata(&req.headers); - // Clone version_id before it's moved - let version_id_clone = version_id.clone(); - let synthetic_version_id = version_id_clone.is_none() && is_dir_object(&key); - - let delete_config_snapshot = Arc::new( - load_delete_config_snapshot(store.as_ref(), &bucket) - .await - .map_err(ApiError::from)?, - ); - #[cfg(test)] - wait_for_delete_snapshot_test_hook(&bucket).await; - let version_cfg = delete_config_snapshot.versioning_config(); - let mut opts: ObjectOptions = - del_opts_with_versioning(&bucket, &key, version_id, &req.headers, metadata, version_cfg, replica) - .map_err(ApiError::from)?; - opts.delete_replication_config_snapshot = Some(Arc::clone(&delete_config_snapshot)); - opts.object_lock_delete = Some(StorageObjectLockDeleteOptions { - bypass_governance: has_bypass_governance_header(&req.headers), - }); - apply_bucket_generation_guard(&req, &bucket, &mut opts)?; - let force_delete = opts.delete_prefix; - - // let mut vid = opts.version_id.clone(); - - if replica { - opts.set_replica_status(ReplicationStatusType::Replica); - - // if opts.version_purge_status().is_empty() { - // vid = None; - // } - } - - let expected_current_version_id = expected_current_version_id(&req.headers)?; - if expected_current_version_id.is_some() && (force_delete || !opts.versioned) { - return Err(s3_error!( - InvalidRequest, - "Expected current version precondition requires a version-specific delete in a versioned bucket" - )); - } - validate_undo_delete_version(expected_current_version_id.as_deref(), opts.version_id.as_deref())?; - opts.expected_current_version_id = expected_current_version_id.clone(); - - let replicate_force_delete = force_delete && !replica && has_active_delete_rule(&delete_config_snapshot, &key); - let mut force_delete_intent = None; - - let get_opts = opts.clone(); - let existing_object_info = match store.get_object_info(&bucket, &key, &get_opts).await { - Ok(obj_info) => Some(obj_info), - Err(err) => { - // If object not found, allow deletion to proceed (will return 204 No Content) - if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { - return Err(ApiError::from(err).into()); - } - None - } - }; - #[cfg(test)] - wait_for_delete_source_test_hook(&bucket).await; - - let cache_adapter = self.object_data_cache(); - // A force (delete_prefix) delete removes every object under `key` as a - // prefix, so invalidating only the exact key would strand every cached - // body beneath it. Use the prefix primitive in that branch (ODC-27). - if force_delete { - let _ = invalidate_object_data_cache_prefix_before_mutation(&cache_adapter, &bucket, &key).await; - } else { - let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; - } - - if replicate_force_delete - && let Some((target_arns, generation)) = force_delete_target_set(&delete_config_snapshot, &key) - && !target_arns.is_empty() - { - let operation_id = - persist_force_delete_intent(store.clone(), bucket.clone(), key.clone(), target_arns.clone(), generation) - .await - .map_err(ApiError::from)?; - force_delete_intent = Some((operation_id, target_arns, generation)); - } - - let obj_info = { - match store - .delete_object_with_tier_delete_journal(&bucket, &key, opts.clone()) - .await - { - Ok(obj) => obj, - Err(err) => { - if let Some((operation_id, _, _)) = force_delete_intent.as_ref() - && let Err(cleanup_error) = - crate::storage::storage_api::complete_force_delete_intent(store.clone(), *operation_id).await - { - warn!( - bucket = %bucket, - object = %key, - operation_id = %operation_id, - error = %cleanup_error, - "failed to remove uncommitted force-delete intent after local delete failure" - ); - } - if is_err_bucket_not_found(&err) { - return Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string())); - } - - if is_err_object_not_found(&err) || is_err_version_not_found(&err) { - let (result, _helper) = complete_delete_noop(helper, bucket, key, version_id_clone); - return result; - } - - if matches!(&err, StorageError::PrefixAccessDenied(_, _)) - && let Some(existing_object_info) = existing_object_info.as_ref() - && let Some(reason) = check_object_lock_for_deletion( - &bucket, - existing_object_info, - has_bypass_governance_header(&req.headers), - ) - .await - { - return Err(S3Error::with_message(S3ErrorCode::AccessDenied, reason.error_message())); - } - - return Err(ApiError::from(err).into()); - } - } - }; - - if force_delete { - let _ = invalidate_object_data_cache_prefix_after_delete(&cache_adapter, &bucket, &key).await; - } else { - let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await; - } - - // Fast in-memory update for immediate quota and admin usage consistency. - // Prefix/force deletes and synthetic directory entries do not carry one - // committed object identity; leave their cache delta to reconciliation. - let update = if force_delete || obj_info.name.is_empty() || synthetic_version_id { - None - } else { - // The storage commit returns this object's metadata while its - // generation lock is held. Never fall back to a pre-delete stat: - // an overwrite can commit between that stat and this delete. - delete_memory_update( - delete_creates_delete_marker(&opts), - obj_info.delete_marker, - opts.version_id.is_none(), - quota_object_size(&obj_info).ok(), - delete_removes_current_object(&opts), - ) - }; - apply_delete_memory_update(&bucket, update).await; - - if obj_info.name.is_empty() { - if let Some((operation_id, target_arns, generation)) = force_delete_intent { - if let Err(error) = commit_force_delete_intent(store.clone(), operation_id).await { - warn!( - bucket = %bucket, - object = %key, - operation_id = %operation_id, - error = %error, - "failed to mark force-delete intent committed after local delete" - ); - } - let generation = i64::try_from(generation.unix_timestamp_nanos()).unwrap_or(i64::MAX); - schedule_replication_delete( - StorageDeletedObject { - object_name: key.clone(), - force_delete: true, - force_delete_id: Some(operation_id), - force_delete_target_arns: target_arns, - force_delete_generation: Some(generation), - ..Default::default() - }, - bucket.clone(), - REPLICATE_INCOMING_DELETE.to_string(), - ) - .await; - } else if replicate_force_delete { - let mut delete_object = StorageDeletedObject { - object_name: key.clone(), - force_delete: true, - ..Default::default() - }; - if let Some(replication_state) = delete_replication_state_from_config( - delete_config_snapshot - .replication_config() - .unwrap_or_else(|| unreachable!("force-delete requires a replication config")), - &ObjectInfo { - bucket: bucket.clone(), - name: key.clone(), - ..Default::default() - }, - None, - false, - ) { - set_deleted_object_replication_state(&mut delete_object, &replication_state); - } - schedule_replication_delete(delete_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await; - } - // Prefix/force-delete returns empty ObjectInfo; still emit bucket notification so webhooks match S3 DELETE. - helper = helper - .event_name(delete_event_name_for_marker(false)) - .object(ObjectInfo { - name: key.clone(), - bucket: bucket.clone(), - ..Default::default() - }) - .version_id(String::new()); - let result = Ok(S3Response::with_status(DeleteObjectOutput::default(), StatusCode::NO_CONTENT)); - // Match non-empty delete path: capacity manager write-op telemetry. - let manager = get_capacity_manager(); - manager.record_write_operation().await; - let _ = helper.complete(&result); - rustfs_scanner::record_dirty_usage_bucket(&bucket); - return result; - } - - let deleted_replication_info = existing_object_info - .as_ref() - .filter(|_| should_use_existing_delete_replication_info(&opts, opts.version_id.is_some())); - let _delete_tail_guard = DeleteTailActivityGuard::new(DeleteTailStage::Tail); - let deleted_object_source = deleted_replication_info.unwrap_or(&obj_info); - let replication_state_source = &obj_info; - let deleted_delete_marker_version = deleted_replication_info.is_some_and(|info| info.delete_marker); - - let delete_replication_version_id = delete_replication_version_id(deleted_object_source, deleted_delete_marker_version); - let schedule_delete_replication = if opts.replication_request && replica { - should_schedule_replica_delete_replication( - &delete_config_snapshot, - replication_state_source, - delete_replication_version_id, - ) - } else { - should_schedule_delete_replication( - &opts, - replication_state_source, - deleted_delete_marker_version, - opts.version_id.is_some(), - ) - }; - - if schedule_delete_replication { - let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Replication); - let mut deleted_object = StorageDeletedObject { - delete_marker: deleted_object_source.delete_marker && !deleted_delete_marker_version, - delete_marker_version_id: if deleted_object_source.delete_marker { - deleted_object_source.version_id - } else { - None - }, - object_name: key.clone(), - version_id: if deleted_object_source.delete_marker { - None - } else { - deleted_object_source.version_id - }, - delete_marker_mtime: deleted_object_source.mod_time, - replication_state: None, - ..Default::default() - }; - set_deleted_object_replication_state(&mut deleted_object, &replication_state_source.replication_state()); - enrich_delete_replication_state_if_needed(&delete_config_snapshot, &mut deleted_object, replication_state_source); - schedule_replication_delete(deleted_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await; - } - - let delete_marker = obj_info.delete_marker; - let version_id = obj_info.version_id; - let response_version_id = delete_response_version_id(version_id, synthetic_version_id); - - let output = DeleteObjectOutput { - delete_marker: Some(delete_marker), - version_id: response_version_id.clone(), - ..Default::default() - }; - - let event_name = delete_event_name_for_marker(delete_marker); - - helper = helper.event_name(event_name); - helper = helper.object(obj_info).version_id(response_version_id.unwrap_or_default()); - - let result = Ok(S3Response::new(output)); - // Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead) - let manager = get_capacity_manager(); - manager.record_write_operation().await; - let _ = helper.complete(&result); - rustfs_scanner::record_dirty_usage_bucket(&bucket); - result - } - - #[instrument(level = "debug", skip(self, req))] - pub async fn execute_head_object(&self, req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = OperationHelper::new(&req, EventName::ObjectAccessedHead, S3Operation::HeadObject).suppress_event(); - // mc get 2 - let HeadObjectInput { - bucket, - key, - version_id, - part_number, - range, - if_none_match, - if_match, - if_modified_since, - if_unmodified_since, - .. - } = req.input.clone(); - - // Validate object key - validate_object_key(&key, "HEAD")?; - // Parse part number from Option to Option with validation - let part_number: Option = parse_part_number_i32_to_usize(part_number, "HEAD")?; - - let rs = range.map(range_to_http_range_spec).transpose()?; - - if rs.is_some() && part_number.is_some() { - return Err(s3_error!(InvalidArgument, "range and part_number invalid")); - } - - // Establish bucket existence before any bucket-metadata work (matches - // PUT/GET): nonexistent buckets fail here instead of paying the - // versioning lookup in get_opts first. Resolve the store through the - // request-bound server context (backlog#1052 S6), not the - // process-global handle. - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - validate_bucket_exists(&store, &bucket).await?; - - let opts: ObjectOptions = get_opts(&bucket, &key, version_id, part_number, &req.headers) - .await - .map_err(ApiError::from)?; - - // Modification Points: Explicitly handles get_object_info errors, distinguishing between object absence and other errors - let info = match store.get_object_info(&bucket, &key, &opts).await { - Ok(info) => info, - Err(err) => { - // If the error indicates the object or its version was not found, return 404 (NoSuchKey) - if is_err_object_not_found(&err) || is_err_version_not_found(&err) { - if is_dir_object(&key) { - let has_children = match probe_prefix_has_children(store, &bucket, &key, false).await { - Ok(has_children) => has_children, - Err(e) => { - error!(bucket, key, error = %e, "Failed to probe children for prefix"); - false - } - }; - let msg = head_prefix_not_found_message(&bucket, &key, has_children); - return Err(S3Error::with_message(S3ErrorCode::NoSuchKey, msg)); - } - // Active-active replication lag window: an object missing - // locally may still be served by proxying the HEAD to a - // replication target (backlog#1675 P1-5). - if let Some(output) = Self::proxy_head_object_to_replication_targets(&req, &bucket, &key, &opts).await { - let response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; - let result = Ok(response); - let _ = helper - .version_id(req.input.version_id.clone().unwrap_or_default()) - .complete(&result); - return result; - } - return Err(S3Error::new(S3ErrorCode::NoSuchKey)); - } - // Other errors, such as insufficient permissions, still return the original error - return Err(ApiError::from(err).into()); - } - }; - if info.delete_marker { - if opts.version_id.is_none() { - return Err(S3Error::new(S3ErrorCode::NoSuchKey)); - } - return Err(S3Error::new(S3ErrorCode::MethodNotAllowed)); - } - if let Some(match_etag) = if_none_match - && let Some(strong_etag) = match_etag.into_etag() - && info - .etag - .as_ref() - .is_some_and(|etag| ETag::Strong(etag.clone()) == strong_etag) - { - return Err(S3Error::new(S3ErrorCode::NotModified)); - } - if let Some(modified_since) = if_modified_since { - // obj_time < givenTime + 1s - if info.mod_time.is_some_and(|mod_time| { - let give_time: OffsetDateTime = modified_since.into(); - mod_time < give_time.add(time::Duration::seconds(1)) - }) { - return Err(S3Error::new(S3ErrorCode::NotModified)); - } - } - if let Some(match_etag) = if_match { - if let Some(strong_etag) = match_etag.into_etag() - && info - .etag - .as_ref() - .is_some_and(|etag| ETag::Strong(etag.clone()) != strong_etag) - { - return Err(S3Error::new(S3ErrorCode::PreconditionFailed)); - } - } else if let Some(unmodified_since) = if_unmodified_since - && info.mod_time.is_some_and(|mod_time| { - let give_time: OffsetDateTime = unmodified_since.into(); - mod_time > give_time.add(time::Duration::seconds(1)) - }) - { - return Err(S3Error::new(S3ErrorCode::PreconditionFailed)); - } - // An authorized replication convergence check only needs etag/size/mtime - // to compare source and replica; it holds no customer key, so the SSE-C - // read validation is skipped for it (and only it). - let replication_check = replication_request_authorized(&req) - && get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_CHECK).as_deref() == Some("true"); - if !replication_check { - validate_sse_headers_for_read(&info.user_defined, &req.headers)?; - - // Validate SSE-C: if the object was encrypted with a customer-provided key, - // the caller must supply the matching key even for HEAD requests (per S3 spec). - validate_ssec_for_read( - &info.user_defined, - req.input.sse_customer_key.as_ref(), - req.input.sse_customer_key_md5.as_ref(), - )?; - } - - // Compute x-amz-expiration header from lifecycle prediction (before info is partially moved) - let expiration_header = resolve_put_object_expiration(&bucket, &info).await; - // Clone ObjectInfo for event notification only when an event will - // actually be built — the clone is expensive for multipart objects. - let event_info = helper.wants_object_info().then(|| info.clone()); - let content_type = { - if let Some(content_type) = &info.content_type { - match ContentType::from_str(content_type) { - Ok(res) => Some(res), - Err(err) => { - error!(content_type = %content_type, error = ?err, "Archive content-type parse failed"); - // - None - } - } - } else { - None - } - }; - let last_modified = info.mod_time.map(Timestamp::from); - - let content_length = info.get_actual_size().map_err(|e| { - error!(error = %e, "Failed to resolve actual object size"); - ApiError::from(e) - })?; - - let metadata_map = info.user_defined.clone(); - let server_side_encryption = metadata_map - .get("x-amz-server-side-encryption") - .map(|v| ServerSideEncryption::from(v.clone())); - let sse_customer_algorithm = metadata_map - .get("x-amz-server-side-encryption-customer-algorithm") - .map(|v| SSECustomerAlgorithm::from(v.clone())); - let sse_customer_key_md5 = metadata_map.get("x-amz-server-side-encryption-customer-key-md5").cloned(); - let sse_kms_key_id = metadata_map.get("x-amz-server-side-encryption-aws-kms-key-id").cloned(); - let storage_class = response_storage_class(&info, &metadata_map); - // checksum: classify once; additional algorithms (XXHash3/64/128, SHA-512, MD5) - // land in `extra` and are emitted as raw headers below (s3s has no typed field). - let ResponseChecksums { - crc32: checksum_crc32, - crc32c: checksum_crc32c, - sha1: checksum_sha1, - sha256: checksum_sha256, - crc64nvme: checksum_crc64nvme, - checksum_type, - extra: extra_checksum_headers, - } = if let Some(checksum_mode) = req.headers.get(AMZ_CHECKSUM_MODE) - && checksum_mode.to_str().unwrap_or_default() == "ENABLED" - && rs.is_none() - { - let (checksums, is_multipart) = info - .decrypt_checksums(opts.part_number.unwrap_or(0), &req.headers) - .map_err(ApiError::from)?; - classify_response_checksums(checksums, is_multipart) - } else { - ResponseChecksums::default() - }; - // Extract standard HTTP headers from user_defined metadata - // Note: These headers are stored with lowercase keys by extract_metadata_from_mime - let cache_control = metadata_map.get("cache-control").cloned(); - let content_disposition = metadata_map.get("content-disposition").cloned(); - let content_language = metadata_map.get("content-language").cloned(); - let website_redirect_location = metadata_map.get(AMZ_WEBSITE_REDIRECT_LOCATION).cloned(); - let expires = info.expires.map(Timestamp::from); - - // Calculate tag count from user_tags already in ObjectInfo - // This avoids an additional API call since user_tags is already populated by get_object_info - let tag_count = if !info.user_tags.is_empty() { - let tag_set = decode_tags(&info.user_tags); - tag_set.len() - } else { - 0 - }; - let output = HeadObjectOutput { - content_length: Some(content_length), - content_type, - content_encoding: info.content_encoding.clone(), - cache_control, - content_disposition, - content_language, - accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()), - website_redirect_location, - expires, - last_modified, - e_tag: info.etag.map(|etag| to_s3s_etag(&etag)), - metadata: filter_object_metadata(&metadata_map), - version_id: info.version_id.map(|v| v.to_string()), - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id: sse_kms_key_id, - checksum_crc32, - checksum_crc32c, - checksum_sha1, - checksum_sha256, - checksum_crc64nvme, - checksum_type, - storage_class, - // x-amz-restore from object metadata - restore: metadata_map.get(X_AMZ_RESTORE.as_str()).and_then(|v| { - let rs = parse_restore_obj_status(v).ok()?; - Some(rs.to_string2()) - }), - // x-amz-expiration from lifecycle prediction - expiration: expiration_header, - // metadata: object_metadata, - ..Default::default() - }; - - let version_id = req.input.version_id.clone().unwrap_or_default(); - if let Some(event_info) = event_info { - helper = helper.object(event_info); - } - helper = helper.version_id(version_id); - - // NOTE ON CORS: - // Bucket-level CORS headers are intentionally applied only for object retrieval - // operations (GET/HEAD) via `wrap_response_with_cors`. Other S3 operations that - // interact with objects (PUT/POST/DELETE/LIST, etc.) rely on the system-level - // CORS layer instead. In case both are applicable, this bucket-level CORS logic - // takes precedence for these read operations. - let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; - - // Emit additional-checksum headers (XXHash3/64/128, SHA-512) that s3s cannot - // carry on the typed HeadObjectOutput (#1257). - inject_additional_checksum_headers(&mut response.headers, &extra_checksum_headers); - - // Add x-amz-tagging-count header if object has tags - // Per S3 API spec, this header should be present in HEAD object response when tags exist - if tag_count > 0 { - let header_name = http::HeaderName::from_static(AMZ_TAG_COUNT); - if let Ok(header_value) = tag_count.to_string().parse::() { - response.headers.insert(header_name, header_value); - } else { - warn!("Failed to parse x-amz-tagging-count header; skipping"); - } - } - if let Some(retain_date) = metadata_map - .get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) - .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE)) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(retain_date) - { - response.headers.insert(header_name, header_value); - } - if let Some(mode) = metadata_map - .get(AMZ_OBJECT_LOCK_MODE_LOWER) - .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_MODE)) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_MODE_LOWER.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(mode) - { - response.headers.insert(header_name, header_value); - } - if let Some(legal_hold) = metadata_map - .get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) - .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_LEGAL_HOLD)) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(legal_hold) - { - response.headers.insert(header_name, header_value); - } - - if let Some(amz_restore) = metadata_map.get(X_AMZ_RESTORE.as_str()) { - let Ok(restore_status) = parse_restore_obj_status(amz_restore) else { - return Err(S3Error::with_message(S3ErrorCode::Custom("ErrMeta".into()), "parse amz_restore failed.")); - }; - if let Ok(header_value) = HeaderValue::from_str(restore_status.to_string2().as_str()) { - response.headers.insert(X_AMZ_RESTORE, header_value); - } - } - if let Some(amz_restore_request_date) = metadata_map.get(AMZ_RESTORE_REQUEST_DATE) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_RESTORE_REQUEST_DATE.as_bytes()) - { - let Ok(amz_restore_request_date) = OffsetDateTime::parse(amz_restore_request_date, &Rfc3339) else { - return Err(S3Error::with_message( - S3ErrorCode::Custom("ErrMeta".into()), - "parse amz_restore_request_date failed.", - )); - }; - let Ok(amz_restore_request_date) = amz_restore_request_date.format(&RFC1123) else { - return Err(S3Error::with_message( - S3ErrorCode::Custom("ErrMeta".into()), - "format amz_restore_request_date failed.", - )); - }; - if let Ok(header_value) = HeaderValue::from_str(&amz_restore_request_date) { - response.headers.insert(header_name, header_value); - } - } - if let Some(amz_restore_expiry_days) = metadata_map.get(AMZ_RESTORE_EXPIRY_DAYS) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_RESTORE_EXPIRY_DAYS.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(amz_restore_expiry_days) - { - response.headers.insert(header_name, header_value); - } - if info.replication_status != ReplicationStatusType::Empty - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_BUCKET_REPLICATION_STATUS.to_ascii_lowercase().as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(info.replication_status.as_str()) - { - response.headers.insert(header_name, header_value); - } - - let result = Ok(response); - let _ = helper.complete(&result); - - result - } - - #[instrument(level = "debug", skip(self, req))] - pub async fn execute_restore_object(&self, req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = OperationHelper::new(&req, EventName::ObjectRestorePost, S3Operation::RestoreObject); - let RestoreObjectInput { - bucket, - key: object, - restore_request: rreq, - version_id, - .. - } = req.input.clone(); - - validate_table_catalog_object_mutation(&bucket, &object).await?; - - let rreq = rreq.ok_or_else(|| { - S3Error::with_message(S3ErrorCode::Custom("ErrValidRestoreObject".into()), "restore request is required") - })?; - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - - let version_id_str = version_id.clone().unwrap_or_default(); - let mut opts = post_restore_opts(&version_id_str, &bucket, &object) - .await - .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrPostRestoreOpts".into()), "restore object failed."))?; - apply_bucket_generation_guard(&req, &bucket, &mut opts)?; - // `apply_bucket_generation_guard` deliberately tolerates a missing guard - // (only the S3 access layer installs one), so this must not hard-require - // it. Resolve the current generation instead, exactly as the copy path - // does. The fence is unaffected: the value is re-read from disk and - // compared below, before the restore is admitted. - let restore_bucket_incarnation_id = match opts.expected_bucket_incarnation_id { - Some(incarnation_id) => incarnation_id, - None => { - let incarnation_id = store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)?; - opts.expected_bucket_incarnation_id = Some(incarnation_id); - incarnation_id - } - }; - - // SELECT-type restores skip both the ongoing check and the metadata - // write below, so the accept guard would protect nothing for them — - // they keep the plain (read-locked) accept path. - let is_select = rreq.type_.as_ref().is_some_and(|t| t.as_str() == "SELECT"); - - // Hold the restore-accept guard across the restore-status read, the - // ongoing/already-restored decision, and the metadata write below, so - // two concurrent (non-SELECT) POST ?restore cannot both observe - // ongoing=false and both start a copy-back (backlog#1304). Reads and - // writes inside this scope run with no_lock; the guard is dropped - // before the copy-back is spawned so it never blocks readers. - // Contention on the accept guard (e.g. a concurrent accept or an - // in-flight commit on the same object) is transient — answer 503 - // SlowDown so SDK clients back off and retry instead of treating it - // as a hard failure. - let restore_bucket_lifecycle_guard = Some(acquire_copy_bucket_lifecycle_lock(store.as_ref(), &bucket).await?); - if store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)? != restore_bucket_incarnation_id { - return Err(ApiError::from(StorageError::BucketNotFound(bucket.clone())).into()); - } - let accept_guard = if is_select { - None - } else { - let guard = store - .acquire_restore_accept_guard(&bucket, &object) - .await - .map_err(|_| S3Error::with_message(S3ErrorCode::SlowDown, "restore object failed."))?; - opts.no_lock = true; - Some(guard) - }; - - let mut obj_info = store - .get_object_info(&bucket, &object, &opts) - .await - .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrInvalidObjectState".into()), "restore object failed."))?; - - // Check if object is in a transitioned state - if obj_info.transitioned_object.status != lifecycle::TRANSITION_COMPLETE { - return Err(S3Error::with_message( - S3ErrorCode::Custom("ErrInvalidTransitionedState".into()), - "restore object failed.", - )); - } - - // Validate restore request - if let Err(e) = validate_restore_request(&rreq, store.clone()) { - return Err(S3Error::with_message( - S3ErrorCode::Custom("ErrValidRestoreObject".into()), - format!("Restore object validation failed: {}", e), - )); - } - - // Check if restore is already in progress. AWS answers this with - // 409 RestoreAlreadyInProgress; a Custom code would serialize as a - // retryable 500 and make SDK clients retry the conflict (backlog#1304). - if obj_info.restore_ongoing && !is_select { - return Err(S3Error::with_message( - S3ErrorCode::RestoreAlreadyInProgress, - "Object restore is already in progress.", - )); - } - - let mut already_restored = false; - if let Some(restore_expires) = obj_info.restore_expires - && !obj_info.restore_ongoing - && restore_expires.unix_timestamp() != 0 - { - already_restored = true; - } - - let restore_expiry = lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), *rreq.days.as_ref().unwrap_or(&1)); - let mut metadata = (*obj_info.user_defined).clone(); - let restore_operation_id = (!is_select && !already_restored).then(Uuid::new_v4); - - let mut header = HeaderMap::new(); - - let event_object_info = obj_info.clone(); - let obj_info_ = obj_info.clone(); - if !is_select { - obj_info.metadata_only = true; - metadata.insert(AMZ_RESTORE_EXPIRY_DAYS.to_string(), rreq.days.unwrap_or(1).to_string()); - let request_date = OffsetDateTime::now_utc().format(&Rfc3339).map_err(|e| { - S3Error::with_message(S3ErrorCode::InternalError, format!("format restore request date failed: {}", e)) - })?; - metadata.insert(AMZ_RESTORE_REQUEST_DATE.to_string(), request_date); - if already_restored { - metadata.insert( - X_AMZ_RESTORE.as_str().to_string(), - RestoreStatus { - is_restore_in_progress: Some(false), - restore_expiry_date: Some(Timestamp::from(restore_expiry)), - } - .to_string(), - ); - } else { - metadata.insert( - X_AMZ_RESTORE.as_str().to_string(), - RestoreStatus { - is_restore_in_progress: Some(true), - restore_expiry_date: Some(Timestamp::from(OffsetDateTime::now_utc())), - } - .to_string(), - ); - if let Some(id) = restore_operation_id { - insert_str(&mut metadata, SUFFIX_RESTORE_OPERATION_ID, id.to_string()); - } - } - obj_info.user_defined = Arc::new(metadata); - - // Fence the compare-and-set write: if the accept guard was lost - // (lock-service degradation), another node may have concurrently - // accepted this restore — back off instead of committing a second - // ongoing flag and double-starting the copy-back. - if accept_guard.as_ref().is_some_and(|g| g.is_lock_lost()) { - return Err(S3Error::with_message(S3ErrorCode::SlowDown, "restore object failed.")); - } - - let mut restore_dst_opts = ObjectOptions { - version_id: obj_info_.version_id.map(|v| v.to_string()), - mod_time: obj_info_.mod_time, - no_lock: true, - expected_bucket_incarnation_id: Some(restore_bucket_incarnation_id), - ..Default::default() - }; - if let Some(guard) = restore_bucket_lifecycle_guard.as_ref() { - restore_dst_opts.add_bucket_lifecycle_lock_guard(guard); - } - if let Some(guard) = accept_guard.as_ref() { - guard.add_namespace_lock_fence(&mut restore_dst_opts); - } - store - .clone() - .copy_object( - &bucket, - &object, - &bucket, - &object, - &mut obj_info, - &ObjectOptions { - version_id: obj_info_.version_id.map(|v| v.to_string()), - // Inside the accept-guard critical section (see above). - no_lock: true, - ..Default::default() - }, - &restore_dst_opts, - ) - .await - .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrCopyObject".into()), "restore object failed."))?; - rustfs_scanner::record_dirty_usage_bucket(&bucket); - - if already_restored { - let output = RestoreObjectOutput { - request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)), - restore_output_path: None, - }; - helper = helper - .object(event_object_info.clone()) - .version_id(version_id_str.clone()) - .suppress_event(); - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - return result; - } - } - - // The accept decision is committed; release the object write lock so - // the background copy-back and concurrent reads are never blocked on it. - drop(accept_guard); - drop(restore_bucket_lifecycle_guard); - - // Handle output location for SELECT requests - if let Some(output_location) = &rreq.output_location - && let Some(s3) = &output_location.s3 - && !s3.bucket_name.is_empty() - { - let restore_object = Uuid::new_v4().to_string(); - if let Ok(header_value) = format!("{}{}{}", s3.bucket_name, s3.prefix, restore_object).parse() { - header.insert(X_AMZ_RESTORE_OUTPUT_PATH, header_value); - } - } - - // Spawn restoration task in the background. Pin the copy-back to the - // version the accept resolved and flagged: with a versionless request - // on a versioned bucket, a PUT landing between the accept and the - // copy-back would otherwise re-resolve "latest" to the new version, - // fail (not transitioned), and strand the flagged version at - // ongoing=true forever (backlog#1304). - let store_clone = store.clone(); - let bucket_clone = bucket.clone(); - let object_clone = object.clone(); - let rreq_clone = rreq.clone(); - let version_id_clone = obj_info_ - .version_id - .map(|v| v.to_string()) - .or_else(|| (opts.versioned || opts.version_suspended).then(|| Uuid::nil().to_string())); - let versioned = opts.versioned; - let version_suspended = opts.version_suspended; - let mut restore_operation_metadata = HashMap::new(); - if let Some(id) = restore_operation_id { - insert_str(&mut restore_operation_metadata, SUFFIX_RESTORE_OPERATION_ID, id.to_string()); - } - - spawn_traced(async move { - let opts = ObjectOptions { - transition: TransitionOptions { - restore_request: rreq_clone, - restore_expiry, - ..Default::default() - }, - version_id: version_id_clone, - versioned, - version_suspended, - expected_bucket_incarnation_id: Some(restore_bucket_incarnation_id), - user_defined: restore_operation_metadata, - ..Default::default() - }; - - if let Err(err) = store_clone - .restore_transitioned_object(&bucket_clone, &object_clone, &opts) - .await - { - warn!( - "unable to restore transitioned bucket/object {}/{}: {}", - bucket_clone, - object_clone, - err.to_string() - ); - } else { - rustfs_scanner::record_dirty_usage_bucket(&bucket_clone); - debug!(bucket = %bucket_clone, object = %object_clone, "Transitioned object restored"); - } - }); - - let output = RestoreObjectOutput { - request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)), - restore_output_path: None, - }; - helper = helper.object(event_object_info).version_id(version_id_str); - let result = Ok(S3Response::with_headers(output, header)); - let _ = helper.complete(&result); - result - } - - #[instrument(level = "debug", skip(self, req))] - pub async fn execute_select_object_content( - &self, - req: S3Request, - ) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - crate::app::select_object::execute_select_object_content(req).await - } - - #[instrument(level = "debug", skip(self, req))] - #[hotpath::measure(impl_type = "DefaultObjectUsecase")] - pub async fn execute_put_object_extract(&self, req: S3Request) -> S3Result> { - self.execute_put_object_extract_boxed(req).await - } - - fn execute_put_object_extract_boxed( - &self, - req: S3Request, - ) -> impl std::future::Future>> + Send + '_ { - Box::pin(self.execute_put_object_extract_inner(req)) - } - - async fn execute_put_object_extract_inner(&self, req: S3Request) -> S3Result> { - let helper = OperationHelper::new(&req, EventName::ObjectCreatedPut, S3Operation::PutObject).suppress_event(); - let request_context = helper.request_context_or_from_request(&req); - let auth_method = req.method.clone(); - let auth_uri = req.uri.clone(); - let auth_headers = req.headers.clone(); - let auth_extensions = req.extensions.clone(); - let auth_credentials = req.credentials.clone(); - let auth_region = req.region.clone(); - let auth_service = req.service.clone(); - let auth_trailing_headers = req.trailing_headers.clone(); - // Extract uploads reject SSE-KMS before reaching the SSE layer, so the principal is - // only carried for the day that restriction lifts; the NotImplemented answer below - // deliberately stays ahead of any key authorization. - let extract_principal = SseKmsPrincipal::from_request(&req); - if is_sse_kms_requested(&req.input, &req.headers) { - return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for extract uploads")); - } - let replication_authorized = replication_request_authorized(&req); - let mut bucket_generation_opts = ObjectOptions::default(); - apply_bucket_generation_guard(&req, &req.input.bucket, &mut bucket_generation_opts)?; - let expected_bucket_incarnation_id = bucket_generation_opts.expected_bucket_incarnation_id; - let input = req.input; - - let PutObjectInput { - body, - bucket, - key, - version_id, - cache_control, - content_disposition, - content_encoding, - content_length, - content_language, - content_type, - content_md5, - expires, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key, - sse_customer_key_md5, - ssekms_key_id, - storage_class, - tagging, - website_redirect_location, - .. - } = input; - - let event_version_id = version_id; - let (h_algo, h_key, h_md5) = extract_ssec_params_from_headers(&req.headers)?; - let sse_customer_algorithm = sse_customer_algorithm.or(h_algo); - let sse_customer_key = sse_customer_key.or(h_key); - let sse_customer_key_md5 = sse_customer_key_md5.or(h_md5); - - let original_sse = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?); - let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); - let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( - bucket_sse_config.as_ref().map(|(config, _timestamp)| config), - original_sse, - ssekms_key_id, - false, - ); - if effective_sse - .as_ref() - .is_some_and(|sse| sse.as_str().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) - { - return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for extract uploads")); - } - validate_sse_headers_for_write( - effective_sse.as_ref(), - effective_kms_key_id.as_ref(), - extract_ssekms_context_from_headers(&req.headers)?.as_ref(), - sse_customer_algorithm.as_ref(), - sse_customer_key.as_ref(), - sse_customer_key_md5.as_ref(), - true, - )?; - let Some(body) = body else { return Err(s3_error!(IncompleteBody)) }; - - let size = match content_length { - Some(c) => c, - None => { - if let Some(val) = req.headers.get(AMZ_DECODED_CONTENT_LENGTH) { - match atoi::atoi::(val.as_bytes()) { - Some(x) => x, - None => return Err(s3_error!(UnexpectedContent)), - } - } else { - return Err(s3_error!(UnexpectedContent)); - } - } - }; - if size < 0 { - return Err(s3_error!(UnexpectedContent)); - } - validate_object_key(&key, "PUT")?; - validate_table_catalog_object_mutation(&bucket, &key).await?; - let _ = self - .check_bucket_quota( - &bucket, - QuotaOperation::PutObject, - u64::try_from(size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - ) - .await?; - - // Apply adaptive buffer sizing based on file size for optimal streaming performance. - // Uses workload profile configuration (enabled by default) to select appropriate buffer size. - // Buffer sizes range from 32KB to 4MB depending on file size and configured workload profile. - let buffer_size = get_buffer_size_opt_in(size); - let body = - tokio::io::BufReader::with_capacity(buffer_size, StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io)))); - - let Some(ext) = Path::new(&key).extension().and_then(|s| s.to_str()) else { - return Err(s3_error!(InvalidArgument, "key extension not found")); - }; - - let ext = ext.to_owned(); - - let md5hex = if let Some(base64_md5) = content_md5 { - let md5 = base64_simd::STANDARD - .decode_to_vec(base64_md5.as_bytes()) - .map_err(|e| ApiError::from(StorageError::other(format!("Invalid content MD5: {e}"))))?; - Some(hex_simd::encode_to_string(&md5, hex_simd::AsciiCase::Lower)) - } else { - None - }; - - let sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); - let actual_size = size; - - let mut archive_reader = - HashReader::from_stream(body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)?; - - if let Err(err) = archive_reader.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { - return Err(ApiError::from(err).into()); - } - - let archive_etag = Arc::new(Mutex::new(None)); - let decoder = CompressionFormat::from_extension(&ext) - .get_decoder(ExtractArchiveEtagReader::new(archive_reader, archive_etag.clone())) - .map_err(|e| { - error!(error = ?e, "Archive decoder creation failed"); - s3_error!(InvalidArgument, "get_decoder err") - })?; - - let mut ar = Archive::new(decoder); - let mut entries = ar.entries().map_err(|e| { - error!(error = ?e, "Archive entry listing failed"); - s3_error!(InvalidArgument, "get entries err") - })?; - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - - let extract_options = resolve_put_object_extract_options(&req.headers)?; - let extract_limits = put_object_extract_limits(); - let extract_quota_check = if let Some(metadata_sys) = self.bucket_metadata_sys() { - let quota_checker = QuotaChecker::new(metadata_sys); - let check_result = - map_quota_check_outcome(&bucket, quota_checker.check_quota(&bucket, QuotaOperation::PutObject, 0).await)?; - Some(check_result) - } else { - None - }; - let extract_quota_enabled = extract_quota_check - .as_ref() - .is_some_and(|result| result.quota_limit.is_some()); - let version_id = match event_version_id { - Some(v) => v.to_string(), - None => String::new(), - }; - - let notify = current_notify_interface_for_context(self.context.as_deref()); - let req_params = rustfs_targets::extract_params_header(&req.headers); - let host = get_request_host(&req.headers); - let port = get_request_port(&req.headers); - let user_agent = get_request_user_agent(&req.headers); - let mut wrote_any_entry = false; - let mut extracted_entry_count = 0usize; - let mut total_unpacked_size = 0u64; - let object_lock_config_snapshot = store.object_lock_config_snapshot(&bucket).await.map_err(ApiError::from)?; - let object_lock_config_state = object_lock_config_snapshot.state(); - - while let Some(entry) = entries.next().await { - let mut f = match entry { - Ok(f) => f, - Err(e) => { - if extract_options.ignore_errors { - warn!(error = %e, "Archive entry read skipped due to ignore-errors"); - continue; - } - error!(error = %e, "Archive entry read failed"); - return Err(s3_error!(InvalidArgument, "Failed to read archive entry: {:?}", e)); - } - }; - extracted_entry_count = extracted_entry_count.saturating_add(1); - validate_put_object_extract_entry_count(extracted_entry_count, extract_limits)?; - - let fpath = match f.path() { - Ok(path) => path, - Err(e) => { - if extract_options.ignore_errors { - warn!(error = %e, "Archive path decode skipped due to ignore-errors"); - continue; - } - return Err(s3_error!(InvalidArgument, "Failed to decode archive entry path")); - } - }; - - let is_dir = f.header().entry_type().is_dir(); - let fpath = match normalize_extract_entry_key(&fpath.to_string_lossy(), extract_options.prefix.as_deref(), is_dir) { - Ok(fpath) => fpath, - Err(err) => { - if extract_options.ignore_errors { - warn!(error = %err, "Unsafe archive path skipped due to ignore-errors"); - continue; - } - return Err(err); - } - }; - validate_put_object_extract_entry_path(&fpath, extract_limits)?; - validate_table_catalog_object_mutation(&bucket, &fpath).await?; - - let mut auth_req = S3Request { - input: PutObjectInput::default(), - method: auth_method.clone(), - uri: auth_uri.clone(), - headers: auth_headers.clone(), - extensions: auth_extensions.clone(), - credentials: auth_credentials.clone(), - region: auth_region.clone(), - service: auth_service.clone(), - trailing_headers: auth_trailing_headers.clone(), - }; - { - let req_info = req_info_mut(&mut auth_req)?; - req_info.bucket = Some(bucket.clone()); - req_info.object = Some(fpath.clone()); - req_info.version_id = None; - } - let entry_size = f.header().size().unwrap_or_default(); - validate_put_object_extract_entry_size(&fpath, entry_size, extract_limits)?; - total_unpacked_size = total_unpacked_size - .checked_add(entry_size) - .ok_or_else(|| s3_error!(InvalidArgument, "Archive total unpacked size overflowed while processing entries"))?; - validate_put_object_extract_total_size(total_unpacked_size, extract_limits)?; - if let Some(quota_check) = extract_quota_check.as_ref() { - ensure_legacy_archive_size_within_quota(quota_check, total_unpacked_size)?; - } - let mut size = - i64::try_from(entry_size).map_err(|_| s3_error!(InvalidArgument, "Archive entry size does not fit into i64"))?; - // mtime 0 means "unset" in tar headers, and xl.meta cannot represent an - // epoch mod_time anyway (0 nanos decodes as no-mod_time, making the version - // unreadable — rustfs#4842), so fall back to the upload time instead. - let archive_entry_mod_time = f - .header() - .mtime() - .ok() - .filter(|&modified_at_secs| modified_at_secs > 0) - .and_then(|modified_at_secs| OffsetDateTime::from_unix_timestamp(modified_at_secs as i64).ok()); - let mut metadata = HashMap::new(); - let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); - apply_put_request_metadata( - &mut metadata, - &req.headers, - &fpath, - cache_control.clone(), - content_disposition.clone(), - content_encoding.clone(), - content_language.clone(), - content_type.clone(), - expires.clone(), - website_redirect_location.clone(), - tagging.clone(), - storage_class.clone(), - )?; - apply_bucket_default_lock_retention( - &bucket, - object_lock_config_state, - &mut metadata, - has_explicit_object_lock_retention, - )?; - let mut opts = put_opts_with_replication_authorization( - &bucket, - &fpath, - None, - &req.headers, - metadata.clone(), - replication_authorized, - ) - .await - .map_err(ApiError::from)?; - if let Some(quota_check) = extract_quota_check.as_ref() { - apply_quota_admission(&mut opts, quota_check)?; - } - opts.expected_bucket_incarnation_id = expected_bucket_incarnation_id; - opts.object_lock_config_snapshot = Some(Arc::clone(&object_lock_config_snapshot)); - let pax_authorization = - apply_extract_entry_pax_extensions(&mut f, &bucket, &fpath, object_lock_config_state, &mut metadata, &mut opts) - .await?; - for (name, value) in &pax_authorization.headers { - auth_req.headers.insert(name.clone(), value.clone()); - } - if let Some(version_id) = opts.version_id.as_ref() { - req_info_mut(&mut auth_req)?.version_id = Some(version_id.clone()); - } - authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectAction)).await?; - if pax_authorization.object_lock_mode.is_some() || pax_authorization.object_lock_retain_until_date.is_some() { - authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?; - } - if pax_authorization.object_lock_legal_hold_status.is_some() { - authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await?; - } - if opts.version_id.is_some() || pax_authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS) { - authorize_request(&mut auth_req, Action::S3Action(S3Action::ReplicateObjectAction)).await?; - } - let effective_object_lock_legal_hold_status = pax_authorization - .object_lock_legal_hold_status - .clone() - .or_else(|| object_lock_legal_hold_status.clone()); - let (effective_object_lock_mode, effective_object_lock_retain_until_date) = - if pax_authorization.object_lock_mode.is_some() || pax_authorization.object_lock_retain_until_date.is_some() { - ( - pax_authorization.object_lock_mode.clone(), - pax_authorization.object_lock_retain_until_date.clone(), - ) - } else { - (object_lock_mode.clone(), object_lock_retain_until_date.clone()) - }; - if archive_entry_mod_time.is_some() { - opts.mod_time = archive_entry_mod_time; - } - - debug!("Extracting file: {}, size: {} bytes", fpath, size); - - if is_dir { - if extract_options.ignore_dirs { - debug!("Skipping directory entry during archive extract: {}", fpath); - continue; - } - size = 0; - } - - let actual_size = size; - - let should_compress = - !is_dir && is_disk_compressible(&HeaderMap::new(), &fpath) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64; - - let mut write_plan = WritePlan::new(); - let mut hrd = if is_dir { - HashReader::from_stream(std::io::Cursor::new(Vec::new()), size, actual_size, None, None, false) - .map_err(ApiError::from)? - } else if should_compress { - let algorithm = CompressionAlgorithm::default(); - insert_str(&mut metadata, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); - insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, size.to_string()); - - let hrd = HashReader::from_stream(f, size, actual_size, None, None, false).map_err(ApiError::from)?; - write_plan = write_plan.with_compression(algorithm); - hrd - } else { - HashReader::from_stream(f, size, actual_size, None, None, false).map_err(ApiError::from)? - }; - apply_put_request_object_lock_opts( - &bucket, - object_lock_config_state, - effective_object_lock_legal_hold_status, - effective_object_lock_mode, - effective_object_lock_retain_until_date, - &mut opts, - )?; - if let Some(material) = sse_encryption(EncryptionRequest { - bucket: &bucket, - key: &fpath, - server_side_encryption: effective_sse.clone(), - ssekms_key_id: effective_kms_key_id.clone(), - ssekms_context: extract_ssekms_context_from_headers(&req.headers)?, - sse_customer_algorithm: sse_customer_algorithm.clone(), - sse_customer_key: sse_customer_key.clone(), - sse_customer_key_md5: sse_customer_key_md5.clone(), - content_size: actual_size, - principal: extract_principal.as_ref(), - }) - .await? - { - effective_sse = Some(material.server_side_encryption.clone()); - effective_kms_key_id = material.kms_key_id.clone(); - - write_plan = write_plan.with_encryption(material.write_encryption(None)); - - let encryption_metadata = encryption_material_to_metadata(&material)?; - metadata.extend(encryption_metadata.clone()); - opts.user_defined.extend(encryption_metadata); - } - hrd = write_plan.apply(hrd, actual_size).map_err(ApiError::from)?; - opts.user_defined.extend(metadata); - - // Each extracted member is an independent user write and joins - // bucket replication like a regular PUT (MinIO PutObjectExtract - // parity). One immutable decision drives both the pending metadata - // and the post-commit schedule below, same contract as the PUT path - // (https://github.com/rustfs/backlog/issues/1320); inbound replica - // writes are declined inside `must_replicate_object`. - let dsc = must_replicate_object( - &bucket, - &fpath, - &opts.user_defined, - "".to_string(), - opts.delete_marker_replication_status(), - opts.clone(), - ) - .await; - if dsc.replicate_any() { - insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); - insert_str( - &mut opts.user_defined, - SUFFIX_REPLICATION_STATUS, - dsc.pending_status().unwrap_or_default(), - ); - } - - let mut reader = PutObjReader::new(hrd); - let cache_adapter = self.object_data_cache(); - let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &fpath).await; - - let (obj_info, backfilled_old_current_size) = match store - .put_object_with_old_current_size(&bucket, &fpath, &mut reader, &opts) - .await - { - Ok(result) => result, - Err(e) => { - if extract_options.ignore_errors { - warn!(error = %e, "Archive object write skipped due to ignore-errors"); - continue; - } - return Err(ApiError::from(e).into()); - } - }; - let committed_size = quota_accounting_object_size(&obj_info, extract_quota_enabled)?; - let extract_versioned = BucketVersioningSys::prefix_enabled(&bucket, &fpath).await; - match previous_current_size_from_backfill(backfilled_old_current_size) { - Some(previous_current_size) => { - if extract_versioned { - record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; - } else { - record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; - } - } - None => { - record_bucket_object_write_unknown_previous_memory(&bucket, committed_size, extract_versioned).await; - } - } - let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &fpath).await; - - // Reuse the per-entry pre-commit decision (see `dsc` above) so the - // persisted pending marker and the schedule always agree. - if dsc.replicate_any() { - schedule_object_replication(obj_info.clone(), store.clone(), dsc).await; - } - - if !wrote_any_entry { - rustfs_scanner::record_dirty_usage_bucket(&bucket); - wrote_any_entry = true; - } - - let _manager = get_concurrency_manager(); - let _fpath_clone = fpath.clone(); - let _bucket_clone = bucket.clone(); - let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag)); - - let output = PutObjectOutput { - e_tag, - ..Default::default() - }; - - let event_args = rustfs_notify::EventArgs { - event_name: put_event_name_for_post_object(false), - bucket_name: bucket.clone(), - object: convert_ecstore_object_info(obj_info.clone()), - req_params: req_params.clone(), - resp_elements: build_event_resp_elements(&S3Response::new(output.clone()), &request_context.request_id), - version_id: version_id.clone(), - host: host.clone(), - port, - user_agent: user_agent.clone(), - }; - - let notify = notify.clone(); - spawn_background_with_context(Some(request_context.clone()), async move { - notify.notify(event_args).await; - }); - } - - let mut checksums = PutObjectChecksums { - crc32: input.checksum_crc32, - crc32c: input.checksum_crc32c, - sha1: input.checksum_sha1, - sha256: input.checksum_sha256, - crc64nvme: input.checksum_crc64nvme, - }; - apply_trailing_checksums( - input.checksum_algorithm.as_ref().map(|a| a.as_str()), - &req.trailing_headers, - &mut checksums, - ); - - warn!( - "put object extract checksum_crc32={:?}, checksum_crc32c={:?}, checksum_sha1={:?}, checksum_sha256={:?}, checksum_crc64nvme={:?}", - checksums.crc32, checksums.crc32c, checksums.sha1, checksums.sha256, checksums.crc64nvme, - ); - - drop(entries); - let mut decoder = match ar.into_inner() { - Ok(decoder) => decoder, - Err(_) => return Err(s3_error!(InvalidArgument, "Failed to finalize archive reader")), - }; - tokio::io::copy(&mut decoder, &mut tokio::io::sink()) - .await - .map_err(map_extract_archive_error)?; - let archive_etag = archive_etag - .lock() - .ok() - .and_then(|etag| etag.clone()) - .map(|etag| to_s3s_etag(&etag)); - - let output = PutObjectOutput { - e_tag: archive_etag, - checksum_crc32: checksums.crc32, - checksum_crc32c: checksums.crc32c, - checksum_sha1: checksums.sha1, - checksum_sha256: checksums.sha256, - checksum_crc64nvme: checksums.crc64nvme, - ..Default::default() - }; - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - result - } -} - -fn object_attributes_requested(object_attributes: &[ObjectAttributes], name: &'static str) -> bool { - object_attributes.iter().any(|value| { - value.as_str().split(',').any(|part| { - part.trim_matches(|c: char| c.is_whitespace() || c == '"' || c == '\'') - .eq_ignore_ascii_case(name) - }) - }) -} - -/// Fail closed when deciding whether an object-lock-sensitive operation may -/// skip its existing-object lookup. -pub(super) async fn object_lock_checks_required(bucket: &str) -> bool { - get_bucket_metadata(bucket) - .await - .map_or(true, |metadata| metadata.object_locking()) -} - -fn object_lock_checks_required_for_state(state: &metadata_sys::ObjectLockConfigState) -> bool { - match state { - metadata_sys::ObjectLockConfigState::Configured { .. } | metadata_sys::ObjectLockConfigState::Fabricated => true, - metadata_sys::ObjectLockConfigState::ConfirmedAbsent => false, - } -} - -/// rustfs/backlog#1009: map the rename_data old-size backfill onto the -/// `previous_current_size` value the usage-accounting helpers expect. Outer -/// `None` = unknown (no quorum agreement, or a peer predates the field) — the -/// caller must fall back to the degraded accounting path. -fn previous_current_size_from_backfill(backfill: Option) -> Option> { - backfill.map(|observation| match observation { - OldCurrentSize::Present(size) => Some(size.max(0) as u64), - OldCurrentSize::Absent => None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use http::{Extensions, HeaderMap, HeaderName, HeaderValue, Method, Uri}; - use s3s::dto::{ - DefaultRetention, Delete, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, - DeleteReplicationStatus, Destination, ExistingObjectReplication, ExistingObjectReplicationStatus, ObjectIdentifier, - ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRule, ReplicaModifications, ReplicaModificationsStatus, - ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, RestoreRequest, ServerSideEncryptionByDefault, - ServerSideEncryptionConfiguration, ServerSideEncryptionRule, SourceSelectionCriteria, - }; - use std::pin::Pin; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; - use std::task::{Context, Poll}; - use tokio::io::{AsyncRead, ReadBuf}; - use tokio_tar::{Builder, EntryType, Header}; - - #[derive(Debug)] - struct MockUploadStreamSha256Mismatch; - - impl std::fmt::Display for MockUploadStreamSha256Mismatch { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("UploadStreamError: Sha256Mismatch") - } - } - - impl std::error::Error for MockUploadStreamSha256Mismatch {} - - #[tokio::test] - async fn cancelled_eager_put_commit_owner_reaps_stalled_storage_task() { - let health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); - let task_health = Arc::clone(&health); - let cancellation = tokio_util::sync::CancellationToken::new(); - let task_cancellation = cancellation.clone(); - let task = spawn_traced_join(async move { - let _progress = task_health.track_write_storage().expect("write tracking must be enabled"); - task_cancellation.cancelled().await; - }); - let owner = EagerPutCommitOwner::new(task, cancellation, Duration::from_millis(10)); - let request = spawn_traced_join(owner.join()); - - tokio::time::timeout(Duration::from_secs(2), async { - while !health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("stalled owner must publish write-storage progress"); - - request.abort(); - let _ = request.await; - tokio::time::timeout(Duration::from_secs(2), async { - while health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("cancelled owner must abort and reap the stalled storage task"); - } - - #[test] - fn delete_response_version_id_preserves_null_and_synthetic_semantics() { - let version_id = Uuid::new_v4(); - - assert_eq!(delete_response_version_id(Some(version_id), false), Some(version_id.to_string())); - assert_eq!(delete_response_version_id(Some(Uuid::nil()), false), Some("null".to_string())); - assert_eq!(delete_response_version_id(Some(Uuid::nil()), true), None); - assert_eq!(delete_response_version_id(None, false), None); - } - - #[test] - fn io_queue_congestion_warn_throttle_emits_once_per_interval() { - let throttle = IoQueueCongestionWarnThrottle::new(); - // The first congested request logs immediately. - assert_eq!(throttle.claim(0), Some(0)); - // Requests inside the window are counted, not logged. - assert_eq!(throttle.claim(1), None); - assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS - 1), None); - // The next emission reports how many stayed silent. - assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS), Some(2)); - assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS + 1), None); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_disk_admission_preserves_slow_down() { - let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1))); - let primary = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() { - DiskReadAdmission::Primary(permit) => permit, - other => panic!("expected primary admission, got {other:?}"), - }; - let degraded = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() { - DiskReadAdmission::Degraded(permit) => permit, - other => panic!("expected degraded admission, got {other:?}"), - }; - - let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await; - assert!(matches!(result, Err(ColdFillError::Storage(StorageError::SlowDown)))); - - drop(degraded); - drop(primary); - } - - #[tokio::test] - async fn cold_fill_closed_disk_admission_is_not_slow_down() { - let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1))); - manager.close_disk_read_admission_for_test(); - - let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await; - assert!(matches!(result, Err(ColdFillError::DiskAdmissionClosed))); - } - - // classify_response_checksums is the single point that splits decrypted checksum - // pairs into the five s3s-typed fields and the additional-algorithm `extra` - // headers, replacing five copies of the loop. Lock its behaviour (#1252). - #[test] - fn classify_response_checksums_splits_typed_and_extra() { - // Typed algorithms fill named fields; nothing spills into extra. - let c = classify_response_checksums( - vec![ - ("CRC32".to_string(), "AAAAAA==".to_string()), - ("SHA256".to_string(), "c2hhMjU2".to_string()), - ("CRC64NVME".to_string(), "Zm9vYmFyCg==".to_string()), - ], - false, - ); - assert_eq!(c.crc32.as_deref(), Some("AAAAAA==")); - assert_eq!(c.sha256.as_deref(), Some("c2hhMjU2")); - assert_eq!(c.crc64nvme.as_deref(), Some("Zm9vYmFyCg==")); - assert!(c.extra.is_empty(), "typed algorithms must not land in extra"); - - // Additional algorithms land in extra keyed by their response-header name. - let c = classify_response_checksums( - vec![ - ("XXHASH3".to_string(), "eHhoMw==".to_string()), - ("XXHASH64".to_string(), "eHhoNjQ=".to_string()), - ("XXHASH128".to_string(), "eHhoMTI4".to_string()), - ("SHA512".to_string(), "c2hhNTEy".to_string()), - ("MD5".to_string(), "bWQ1".to_string()), - ], - false, - ); - assert!(c.crc32.is_none() && c.sha256.is_none() && c.crc64nvme.is_none()); - let names: Vec<&str> = c.extra.iter().map(|(n, _)| *n).collect(); - for expected in [ - "x-amz-checksum-xxhash3", - "x-amz-checksum-xxhash64", - "x-amz-checksum-xxhash128", - "x-amz-checksum-sha512", - "x-amz-checksum-md5", - ] { - assert!(names.contains(&expected), "extra missing {expected}: {names:?}"); - } - assert_eq!(c.extra.len(), 5); - - // The checksum-type marker is captured as the type, not mistaken for an algorithm. - let c = classify_response_checksums(vec![(AMZ_CHECKSUM_TYPE.to_string(), "COMPOSITE".to_string())], false); - assert!(c.checksum_type.is_some()); - assert!(c.extra.is_empty() && c.crc32.is_none()); - - let c = classify_response_checksums(vec![("CRC32".to_string(), "AAAAAA==-2".to_string())], true); - assert_eq!(c.checksum_type.as_ref().map(ChecksumType::as_str), Some("COMPOSITE")); - - // Empty input yields an all-default result. - let c = classify_response_checksums(Vec::<(String, String)>::new(), false); - assert!(c.crc32.is_none() && c.extra.is_empty() && c.checksum_type.is_none()); - } - - // additional_checksum_echo_pairs derives the PutObject/UploadPart response echo for - // additional algorithms from the server-computed checksum, and nothing for the - // five typed ones (those go through typed output fields). - #[test] - fn additional_checksum_echo_pairs_only_for_new_algorithms() { - // Typed algorithm → no echo pair. - let sha256 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, b"data"); - assert!(additional_checksum_echo_pairs(&sha256).is_empty()); - - // Additional algorithm → exactly one (header, value) pair matching the digest. - let xxh3 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::XXHASH3, b"data"); - let pairs = additional_checksum_echo_pairs(&xxh3); - assert_eq!(pairs.len(), 1); - assert_eq!(pairs[0].0, "x-amz-checksum-xxhash3"); - assert_eq!(pairs[0].1, xxh3.as_ref().unwrap().encoded); - - // MD5 additional checksum is echoed too. - let md5 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::MD5, b"data"); - let pairs = additional_checksum_echo_pairs(&md5); - assert_eq!(pairs.len(), 1); - assert_eq!(pairs[0].0, "x-amz-checksum-md5"); - - // None → empty. - assert!(additional_checksum_echo_pairs(&None).is_empty()); - } - - #[test] - fn inject_additional_checksum_headers_writes_all_pairs() { - let mut headers = HeaderMap::new(); - inject_additional_checksum_headers( - &mut headers, - &[ - ("x-amz-checksum-xxhash3", "eHhoMw==".to_string()), - ("x-amz-checksum-md5", "bWQ1".to_string()), - ], - ); - assert_eq!(headers.get("x-amz-checksum-xxhash3").unwrap(), "eHhoMw=="); - assert_eq!(headers.get("x-amz-checksum-md5").unwrap(), "bWQ1"); - // Empty input is a no-op. - let mut empty = HeaderMap::new(); - inject_additional_checksum_headers(&mut empty, &[]); - assert!(empty.is_empty()); - } - - #[test] - fn inject_accept_ranges_header_writes_static_bytes_value() { - let mut headers = HeaderMap::new(); - inject_accept_ranges_header(&mut headers); - - assert_eq!(headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES); - } - - #[tokio::test] - async fn finalize_get_object_response_injects_accept_ranges_header() { - let req = build_request(GetObjectInput::default(), Method::GET); - let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event(); - let response = DefaultObjectUsecase::finalize_get_object_response( - helper, - "bucket", - &req.method, - &req.headers, - None, - String::new(), - GetObjectOutput::default(), - Vec::new(), - ) - .await - .expect("finalize response"); - - assert_eq!(response.headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES); - } - - fn build_request(input: T, method: Method) -> S3Request { - S3Request { - input, - method, - uri: Uri::from_static("/"), - headers: HeaderMap::new(), - extensions: Extensions::new(), - credentials: None, - region: None, - service: None, - trailing_headers: None, - } - } - - #[test] - fn internal_object_info_lookup_opts_drops_http_preconditions() { - let version_id = Uuid::new_v4().to_string(); - let opts = ObjectOptions { - version_id: Some(version_id.clone()), - no_lock: true, - http_preconditions: Some(HTTPPreconditions { - if_none_match: Some("\"etag\"".to_string()), - if_match: Some("\"other\"".to_string()), - ..Default::default() - }), - ..Default::default() - }; - - let lookup_opts = internal_object_info_lookup_opts(opts); - - assert!(lookup_opts.http_preconditions.is_none()); - assert_eq!(lookup_opts.version_id.as_deref(), Some(version_id.as_str())); - assert!(lookup_opts.no_lock); - } - - // A malformed bucket-default algorithm reaches this resolution only through - // corrupt or hand-edited bucket metadata (PutBucketEncryption validates the - // value), so the invariant is pinned here rather than end-to-end: the copy - // path must resolve managed AES256 exactly like PUT/extract. With an - // unencrypted same-name source and no SSE-C, the resolved default alone - // keeps `copy_changes_encryption` true, so the metadata-only shortcut stays - // off while `sse_encryption` mints a fresh DEK (backlog#1826). - #[test] - fn copy_bucket_default_unknown_sse_algorithm_falls_back_to_aes256() { - let config = ServerSideEncryptionConfiguration { - rules: vec![ServerSideEncryptionRule { - apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault { - sse_algorithm: ServerSideEncryption::from(String::from("garbage")), - kms_master_key_id: None, - }), - bucket_key_enabled: None, - }], - }; - - let effective_sse = config - .rules - .first() - .and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref()) - .map(bucket_default_write_sse); - - assert_eq!(effective_sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); - - // Valid algorithms map to themselves, byte-identical to the PUT path. - for (configured, expected) in [ - (ServerSideEncryption::AES256, ServerSideEncryption::AES256), - (ServerSideEncryption::AWS_KMS, ServerSideEncryption::AWS_KMS), - ] { - let sse = ServerSideEncryptionByDefault { - sse_algorithm: ServerSideEncryption::from_static(configured), - kms_master_key_id: None, - }; - assert_eq!(bucket_default_write_sse(&sse).as_str(), expected); - } - } - - fn bucket_sse_config_with(algorithm: &str, kms_key_id: Option<&str>) -> ServerSideEncryptionConfiguration { - ServerSideEncryptionConfiguration { - rules: vec![ServerSideEncryptionRule { - apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault { - sse_algorithm: ServerSideEncryption::from(String::from(algorithm)), - kms_master_key_id: kms_key_id.map(|id| SSEKMSKeyId::from(id.to_string())), - }), - bucket_key_enabled: None, - }], - } - } - - #[test] - fn resolve_bucket_default_sse_prefers_the_request_over_the_bucket_default() { - let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key")); - - let (sse, kms_key_id) = resolve_bucket_default_sse( - Some(&config), - Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), - Some(SSEKMSKeyId::from("request-key".to_string())), - false, - ); - - assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); - assert_eq!(kms_key_id.as_deref(), Some("request-key")); - } - - #[test] - fn resolve_bucket_default_sse_fills_gaps_from_the_bucket_default() { - let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key")); - - let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false); - - assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AWS_KMS)); - assert_eq!(kms_key_id.as_deref(), Some("bucket-key")); - } - - #[test] - fn resolve_bucket_default_sse_falls_back_to_aes256_for_an_unknown_algorithm() { - // Reachable only through corrupt or hand-edited bucket metadata; - // PutBucketEncryption rejects unknown algorithms. All three call sites - // now share this single decision (backlog#1826). - let config = bucket_sse_config_with("garbage", None); - - let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false); - - assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); - assert!(kms_key_id.is_none()); - } - - #[test] - fn resolve_bucket_default_sse_suppresses_the_default_for_explicit_ssec() { - let config = bucket_sse_config_with(ServerSideEncryption::AES256, Some("bucket-key")); - - let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, true); - - assert!(sse.is_none(), "an SSE-C destination must not also get managed encryption"); - assert!(kms_key_id.is_none()); - } - - #[test] - fn resolve_bucket_default_sse_returns_nothing_without_a_bucket_default() { - let (sse, kms_key_id) = resolve_bucket_default_sse(None, None, None, false); - - assert!(sse.is_none()); - assert!(kms_key_id.is_none()); - } - - #[test] - fn put_request_user_metadata_cannot_suppress_bucket_default_retention() { - let mut metadata = - HashMap::from([(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::GOVERNANCE.to_string())]); - apply_put_request_metadata( - &mut metadata, - &HeaderMap::new(), - "object", - None, - None, - None, - None, - None, - None, - None, - None, - None, - ) - .unwrap(); - - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: Some(ObjectLockRule { - default_retention: Some(DefaultRetention { - mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), - days: Some(1), - years: None, - }), - }), - }, - updated_at: OffsetDateTime::now_utc(), - }; - apply_bucket_default_lock_retention("bucket", &state, &mut metadata, false).unwrap(); - - assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("COMPLIANCE")); - assert!(metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); - assert_eq!(metadata.get("x-amz-meta-x-amz-object-lock-mode").map(String::as_str), Some("GOVERNANCE")); - - let mut replication_headers = HeaderMap::new(); - insert_header(&mut replication_headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true"); - insert_header( - &mut replication_headers, - rustfs_utils::http::SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP, - "2026-01-01T00:00:00Z", - ); - let mut replica_metadata = HashMap::new(); - let explicit_clear = has_replication_retention_update(&replication_headers, true); - apply_bucket_default_lock_retention("bucket", &state, &mut replica_metadata, explicit_clear).unwrap(); - assert!(!replica_metadata.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); - assert!(!replica_metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); - } - - fn pax_record(key: &str, value: &[u8]) -> Vec { - let body_len = 1 + key.len() + 1 + value.len() + 1; - let mut len = body_len + 1; - loop { - let actual_len = len.to_string().len() + body_len; - if actual_len == len { - break; - } - len = actual_len; - } - - let mut record = format!("{len} {key}=").into_bytes(); - record.extend_from_slice(value); - record.push(b'\n'); - assert_eq!(record.len(), len); - record - } - - #[tokio::test] - async fn snowball_pax_rejects_unpaired_object_lock_retention() { - let record = pax_record("minio.metadata.x-amz-object-lock-mode", b"GOVERNANCE"); - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - let mut metadata = HashMap::from([ - (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()), - (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), - ]); - let mut opts = ObjectOptions::default(); - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - let err = apply_extract_entry_pax_extensions(&mut entry, "bucket", "object", &state, &mut metadata, &mut opts) - .await - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("COMPLIANCE")); - } - - #[tokio::test] - async fn snowball_pax_privileged_fields_require_independent_authorization() { - let mut retention = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"GOVERNANCE"); - retention.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); - let cases = [ - ("retention", retention, (true, false, false)), - ( - "legal-hold", - pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON"), - (false, true, false), - ), - ( - "version-id", - pax_record("minio.versionId", Uuid::nil().to_string().as_bytes()), - (false, false, true), - ), - ( - "replication-status", - pax_record("minio.metadata.x-amz-replication-status", b"REPLICA"), - (false, false, true), - ), - ]; - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - for (case, record, expected) in cases { - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - - let mut opts = ObjectOptions::default(); - let authorization = - apply_extract_entry_pax_extensions(&mut entry, "bucket", "object", &state, &mut HashMap::new(), &mut opts) - .await - .unwrap(); - - assert_eq!( - ( - authorization.object_lock_mode.is_some() || authorization.object_lock_retain_until_date.is_some(), - authorization.object_lock_legal_hold_status.is_some(), - opts.version_id.is_some() || authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS), - ), - expected, - "{case} must request only its own additional authorization" - ); - match case { - "retention" => { - assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); - assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); - } - "legal-hold" => assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)), - "version-id" => assert_eq!(opts.version_id.as_deref(), Some(Uuid::nil().to_string().as_str())), - "replication-status" => assert_eq!( - authorization - .headers - .get(AMZ_BUCKET_REPLICATION_STATUS) - .and_then(|value| value.to_str().ok()), - Some("REPLICA") - ), - _ => unreachable!(), - } - } - } - - #[tokio::test] - async fn snowball_pax_rejects_invalid_retention_and_replication_values() { - let mut invalid_mode = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"INVALID"); - invalid_mode.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); - let mut invalid_date = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"COMPLIANCE"); - invalid_date.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"not-a-date")); - let cases = [ - ("invalid-mode", invalid_mode), - ("invalid-date", invalid_date), - ( - "invalid-replication-status", - pax_record("minio.metadata.x-amz-replication-status", b"INVALID"), - ), - ("invalid-version-id", pax_record("minio.versionId", b"not-a-uuid")), - ]; - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - for (case, record) in cases { - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - - let err = apply_extract_entry_pax_extensions( - &mut entry, - "bucket", - "object", - &state, - &mut HashMap::new(), - &mut ObjectOptions::default(), - ) - .await - .unwrap_err(); - - assert!( - err.code() == &S3ErrorCode::InvalidArgument || err.code() == &S3ErrorCode::MalformedXML, - "{case}" - ); - } - } - - #[tokio::test] - async fn snowball_pax_preserves_canonical_minio_metadata_and_valid_retention() { - let mut record = pax_record("minio.metadata.Content-Type", b"text/plain"); - record.extend(pax_record("minio.metadata.X-Amz-Meta-Owner", b"alice")); - record.extend(pax_record("minio.metadata.project", b"alpha-demo")); - record.extend(pax_record("minio.metadata.x-amz-tagging", b"classification=public")); - record.extend(pax_record("minio.versionId", Uuid::nil().to_string().as_bytes())); - record.extend(pax_record("minio.metadata.x-amz-replication-status", b"REPLICA")); - record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"GOVERNANCE")); - record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); - record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON")); - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object.txt", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - let mut metadata = HashMap::from([ - (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()), - (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), - ]); - let mut opts = ObjectOptions::default(); - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - let authorization = - apply_extract_entry_pax_extensions(&mut entry, "bucket", "object.txt", &state, &mut metadata, &mut opts) - .await - .unwrap(); - - assert_eq!(metadata.get("content-type").map(String::as_str), Some("text/plain")); - assert_eq!(metadata.get("owner").map(String::as_str), Some("alice")); - assert_eq!(metadata.get("project").map(String::as_str), Some("alpha-demo")); - assert_eq!(metadata.get(AMZ_OBJECT_TAGGING).map(String::as_str), Some("classification=public")); - assert!(!metadata.contains_key("x-amz-tagging")); - assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("GOVERNANCE")); - assert_eq!( - metadata.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER).map(String::as_str), - Some("2099-01-01T00:00:00Z") - ); - assert_eq!(metadata.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).map(String::as_str), Some("ON")); - assert!(metadata.contains_key("x-rustfs-internal-objectlock-legalhold-timestamp")); - assert!(metadata.contains_key("x-minio-internal-objectlock-legalhold-timestamp")); - assert_eq!(metadata.get(AMZ_BUCKET_REPLICATION_STATUS).map(String::as_str), Some("REPLICA")); - assert_eq!(opts.version_id.as_deref(), Some("00000000-0000-0000-0000-000000000000")); - assert!(authorization.object_lock_mode.is_some()); - assert!(authorization.object_lock_retain_until_date.is_some()); - assert!(authorization.object_lock_legal_hold_status.is_some()); - assert!(opts.version_id.is_some()); - assert!(authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS)); - } - - #[tokio::test] - async fn snowball_pax_rejects_legal_hold_without_bucket_object_lock() { - let record = pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON"); - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - let mut metadata = HashMap::new(); - - let err = apply_extract_entry_pax_extensions( - &mut entry, - "bucket", - "object", - &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, - &mut metadata, - &mut ObjectOptions::default(), - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)); - } - - #[tokio::test] - async fn snowball_pax_rejects_invalid_legal_hold_status() { - let record = pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"INVALID"); - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - let mut metadata = HashMap::new(); - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - let err = apply_extract_entry_pax_extensions( - &mut entry, - "bucket", - "object", - &state, - &mut metadata, - &mut ObjectOptions::default(), - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::MalformedXML); - assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)); - } - - #[test] - fn build_put_like_object_lock_metadata_rejects_mode_without_retain_until_date() { - let err = build_put_like_object_lock_metadata( - "test-bucket", - &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, - None, - Some(ObjectLockMode::from_static(ObjectLockMode::GOVERNANCE)), - None, - ) - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); - } - - #[test] - fn object_lock_checks_required_reuses_authoritative_state() { - assert!(!object_lock_checks_required_for_state( - &metadata_sys::ObjectLockConfigState::ConfirmedAbsent - )); - - let configured = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - assert!(object_lock_checks_required_for_state(&configured)); - assert!(object_lock_checks_required_for_state(&metadata_sys::ObjectLockConfigState::Fabricated)); - } - - #[test] - fn build_put_like_object_lock_metadata_rejects_retain_until_date_without_mode() { - let retain_until = Timestamp::from(OffsetDateTime::now_utc().add(time::Duration::days(1))); - let err = build_put_like_object_lock_metadata( - "test-bucket", - &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, - None, - None, - Some(retain_until), - ) - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); - } - - const NO_BUCKET_LOCK: metadata_sys::ObjectLockConfigState = metadata_sys::ObjectLockConfigState::ConfirmedAbsent; - - fn bucket_default_retention_state(mode: &'static str) -> metadata_sys::ObjectLockConfigState { - metadata_sys::ObjectLockConfigState::Configured { - config: s3s::dto::ObjectLockConfiguration { - object_lock_enabled: Some(s3s::dto::ObjectLockEnabled::from_static(s3s::dto::ObjectLockEnabled::ENABLED)), - rule: Some(s3s::dto::ObjectLockRule { - default_retention: Some(s3s::dto::DefaultRetention { - mode: Some(ObjectLockRetentionMode::from_static(mode)), - days: Some(1), - years: None, - }), - }), - }, - updated_at: OffsetDateTime::now_utc(), - } - } - - fn object_info_with_lock_metadata(metadata: HashMap) -> ObjectInfo { - ObjectInfo { - user_defined: Arc::new(metadata), - mod_time: Some(OffsetDateTime::now_utc()), - ..Default::default() - } - } - - fn compliance_retained_object_info() -> ObjectInfo { - let mut metadata = HashMap::new(); - metadata.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()); - metadata.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()); - object_info_with_lock_metadata(metadata) - } - - fn legal_hold_object_info() -> ObjectInfo { - let mut metadata = HashMap::new(); - metadata.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), ObjectLockLegalHoldStatus::ON.to_string()); - object_info_with_lock_metadata(metadata) - } - - /// rustfs/backlog#1009: the backfill→accounting mapping must mirror the - /// prelookup exactly — a live latest version maps to `Some(size)` (clamped - /// at 0 like the prelookup's `.max(0)`), absent/delete-marker maps to - /// `None`, and an unknown backfill maps to outer `None` so the caller - /// takes the degraded path instead of fabricating "new object". - #[test] - fn previous_current_size_from_backfill_mirrors_prelookup_semantics() { - assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(42))), Some(Some(42))); - assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(-7))), Some(Some(0))); - assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Absent)), Some(None)); - assert_eq!(previous_current_size_from_backfill(None), None); - } - - #[test] - fn validate_existing_object_lock_allows_versioned_new_version_with_compliance_retention() { - let opts = ObjectOptions { - versioned: true, - version_id: None, - ..Default::default() - }; - - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect("versioned put should create a new version"); - } - - #[test] - fn validate_existing_object_lock_allows_versioned_new_version_with_legal_hold() { - let opts = ObjectOptions { - versioned: true, - version_id: None, - ..Default::default() - }; - - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) - .expect("versioned put should create a new version"); - } - - #[test] - fn validate_existing_object_lock_blocks_unversioned_compliance_overwrite() { - let err = validate_existing_object_lock_for_write( - &NO_BUCKET_LOCK, - &compliance_retained_object_info(), - &ObjectOptions::default(), - ) - .expect_err("unversioned overwrite should still be blocked"); - - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - #[test] - fn validate_existing_object_lock_blocks_suspended_version_compliance_overwrite() { - let opts = ObjectOptions { - versioned: true, - version_suspended: true, - version_id: None, - ..Default::default() - }; - let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect_err("suspended versioning overwrite should still be blocked"); - - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - #[test] - fn validate_existing_object_lock_blocks_explicit_version_compliance_overwrite() { - let opts = ObjectOptions { - versioned: true, - version_id: Some(Uuid::new_v4().to_string()), - ..Default::default() - }; - let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect_err("explicit version overwrite should still be blocked"); - - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - /// The source's lock state governs the replica (rustfs/backlog#1953): - /// an authorized replication write carrying the locking category's source - /// timestamp may overwrite a locked version; the set layer's LWW then - /// decides per category. - #[test] - fn validate_existing_object_lock_allows_authorized_replication_overwrite() { - let opts = ObjectOptions { - versioned: true, - version_id: Some(Uuid::new_v4().to_string()), - replication_request: true, - replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - replication_legalhold_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - ..Default::default() - }; - - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect("replication write must bypass the destination COMPLIANCE lock"); - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) - .expect("replication write must bypass the destination legal hold"); - } - - /// Without the locking category's source timestamp the LWW merge cannot - /// judge it, so the write stays rejected instead of lifting the lock. - #[test] - fn validate_existing_object_lock_rejects_replication_overwrite_without_lock_timestamp() { - let opts = ObjectOptions { - versioned: true, - version_id: Some(Uuid::new_v4().to_string()), - replication_request: true, - replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - ..Default::default() - }; - - let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect_err("COMPLIANCE lock must hold without a retention source timestamp"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) - .expect_err("legal hold must hold without a legal-hold source timestamp"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - /// The bucket default retention locks a version without explicit - /// retention keys; the pre-check judges the same authoritative state as - /// the set-layer gate, so a tagging-only replication write is rejected - /// and one carrying the retention source timestamp passes to LWW. - #[test] - fn validate_existing_object_lock_judges_bucket_default_retention_for_replication_overwrite() { - let default_protected = object_info_with_lock_metadata(HashMap::new()); - let tagging_only = ObjectOptions { - versioned: true, - version_id: Some(Uuid::new_v4().to_string()), - replication_request: true, - replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - ..Default::default() - }; - let with_retention_decision = ObjectOptions { - replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - ..tagging_only.clone() - }; - - for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] { - let state = bucket_default_retention_state(mode); - let err = validate_existing_object_lock_for_write(&state, &default_protected, &tagging_only) - .expect_err("bucket default retention must hold without a retention source timestamp"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied, "{mode}"); - validate_existing_object_lock_for_write(&state, &default_protected, &with_retention_decision) - .expect("the retention source timestamp hands the default retention to LWW"); - } - - // Without a bucket default the same version is simply unlocked. - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &default_protected, &tagging_only) - .expect("no bucket default, no lock"); - } - - #[test] - fn is_put_object_extract_requested_accepts_meta_header() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); - - assert!(is_put_object_extract_requested(&headers)); - } - - #[test] - fn is_put_object_extract_requested_accepts_compat_header_case_insensitive() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_EXTRACT_COMPAT, HeaderValue::from_static(" TRUE ")); - - assert!(is_put_object_extract_requested(&headers)); - } - - #[test] - fn is_put_object_extract_requested_rejects_missing_or_false_value() { - let mut headers = HeaderMap::new(); - assert!(!is_put_object_extract_requested(&headers)); - - headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("false")); - assert!(!is_put_object_extract_requested(&headers)); - } - - #[test] - fn normalize_snowball_prefix_trims_slashes_and_whitespace() { - assert_eq!( - normalize_snowball_prefix(" /batch/incoming/ ").unwrap(), - Some("batch/incoming".to_string()) - ); - assert_eq!(normalize_snowball_prefix("///").unwrap(), None); - } - - #[test] - fn normalize_snowball_prefix_rejects_parent_dir_components() { - assert!(normalize_snowball_prefix("../victim-bucket").is_err()); - assert!(normalize_snowball_prefix("safe/../../victim-bucket").is_err()); - assert!(normalize_snowball_prefix("safe\\..\\victim-bucket").is_err()); - } - - #[test] - fn normalize_extract_entry_key_applies_prefix_and_directory_suffix() { - assert_eq!( - normalize_extract_entry_key("nested/path.txt", Some("imports"), false).unwrap(), - "imports/nested/path.txt" - ); - assert_eq!( - normalize_extract_entry_key("nested/dir/", Some("imports"), true).unwrap(), - "imports/nested/dir/" - ); - assert_eq!(normalize_extract_entry_key("top-level", None, false).unwrap(), "top-level"); - } - - #[test] - fn normalize_extract_entry_key_rejects_bucket_escape_paths() { - assert!(normalize_extract_entry_key("../victim-bucket/evil.txt", None, false).is_err()); - assert!(normalize_extract_entry_key("safe/../../victim-bucket/evil.txt", None, false).is_err()); - assert!(normalize_extract_entry_key("safe\\..\\victim-bucket\\evil.txt", None, false).is_err()); - assert!(normalize_extract_entry_key("evil.txt", Some("../victim-bucket"), false).is_err()); - } - - #[test] - fn should_use_zero_copy_rejects_boundary_at_1mb() { - let headers = HeaderMap::new(); - - assert!(!should_use_zero_copy(1024 * 1024, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_small_objects() { - let headers = HeaderMap::new(); - - assert!(!should_use_zero_copy(1024 * 1024 - 1, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_one_megabyte() { - let headers = HeaderMap::new(); - - assert!(!should_use_zero_copy(1024 * 1024, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_encrypted_requests() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SERVER_SIDE_ENCRYPTION, HeaderValue::from_static("AES256")); - - assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn aws_chunked_put_prefers_decoded_content_length() { - let mut headers = HeaderMap::new(); - headers.insert("content-encoding", HeaderValue::from_static("aws-chunked")); - headers.insert(AMZ_DECODED_CONTENT_LENGTH, HeaderValue::from_static("71680")); - - let decoded = decoded_content_length_from_headers(&headers).expect("decoded content length should parse"); - assert!(request_uses_aws_chunked(&headers)); - assert_eq!(decoded, Some(71680)); - - let resolved = match (request_uses_aws_chunked(&headers), decoded, Some(99999)) { - (true, Some(decoded), _) => decoded, - (_, _, Some(c)) => c, - (_, Some(decoded), None) => decoded, - _ => unreachable!("test provides a valid size source"), - }; - - assert_eq!(resolved, 71680); - } - - #[test] - fn should_buffer_get_object_in_memory_respects_hard_safety_cap() { - let info = ObjectInfo::default(); - let configured_threshold = 20_i64 * 1024 * 1024 * 1024; - let response_len = 80_i64 * 1024 * 1024; - let should_buffer = - should_buffer_get_object_in_memory_with_threshold(&info, response_len, None, false, configured_threshold, 1, true); - - assert!( - !should_buffer, - "64MiB hard cap must force streaming when response exceeds cap even if configured threshold is much higher" - ); - } - - #[test] - fn should_buffer_get_object_in_memory_allows_small_non_range_requests() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(should_buffer_get_object_in_memory_with_threshold( - &info, - 1024 * 1024, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - 1024 * 1024, - Some(1), - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - 1024 * 1024, - None, - true, - configured_threshold, - 1, - true - )); - } - - #[test] - fn should_buffer_get_object_in_memory_requires_seek_buffer_opt_in() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - 1024, - None, - false, - configured_threshold, - 1, - false - )); - } - - #[test] - fn should_buffer_get_object_in_memory_respects_configured_threshold_below_cap() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(should_buffer_get_object_in_memory_with_threshold( - &info, - configured_threshold, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - configured_threshold + 1, - None, - false, - configured_threshold, - 1, - true - )); - } - - #[test] - fn should_buffer_get_object_in_memory_rejects_unknown_lengths_and_disabled_thresholds() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - 0, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - -1, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold(&info, 1024, None, false, 0, 1, true)); - } - - #[test] - fn should_buffer_get_object_in_memory_reduces_threshold_under_concurrency() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(should_buffer_get_object_in_memory_with_threshold( - &info, - configured_threshold, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - configured_threshold, - None, - false, - configured_threshold, - 32, - true - )); - assert!(should_buffer_get_object_in_memory_with_threshold( - &info, - 4_i64 * 1024 * 1024, - None, - false, - configured_threshold, - rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD, - true - )); - } - - /// Polls the cache until the detached fill (ODC-15) populates the entry, so - /// a follow-up GET is a deterministic hit rather than racing the fill task. - async fn wait_for_cache_hit( - adapter: &crate::app::object_data_cache::ObjectDataCacheAdapter, - bucket: &str, - object: &str, - etag: &str, - size: u64, - ) { - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket, - object, - version_id: None, - etag, - size, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - for _ in 0..400 { - if matches!(adapter.lookup_body(&plan).await, rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_)) { - return; - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - panic!("detached fill did not populate the cache within the timeout"); - } - - struct ReadProbeReader { - reads: Arc, - } - - impl AsyncRead for ReadProbeReader { - fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll> { - self.reads.fetch_add(1, AtomicOrdering::Relaxed); - Poll::Ready(Ok(())) - } - } - - struct DataProbeReader { - reads: Arc, - data: std::io::Cursor>, - } - - struct ColdFillMatrixReader { - inner: tokio::io::DuplexStream, - first_poll_recorded: bool, - completion_recorded: bool, - first_polls: Arc, - completed: Arc, - bytes_read: Arc, - } - - impl AsyncRead for ColdFillMatrixReader { - fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - if !self.first_poll_recorded { - self.first_poll_recorded = true; - self.first_polls.fetch_add(1, AtomicOrdering::Relaxed); - } - let before = buf.filled().len(); - match Pin::new(&mut self.inner).poll_read(cx, buf) { - Poll::Ready(Ok(())) => { - let read = buf.filled().len().saturating_sub(before); - self.bytes_read.fetch_add(read, AtomicOrdering::Relaxed); - if read == 0 && !self.completion_recorded { - self.completion_recorded = true; - self.completed.fetch_add(1, AtomicOrdering::Relaxed); - } - Poll::Ready(Ok(())) - } - other => other, - } - } - } - - impl AsyncRead for DataProbeReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - self.reads.fetch_add(1, AtomicOrdering::Relaxed); - - let remaining = buf.remaining(); - if remaining == 0 { - return Poll::Ready(Ok(())); - } - - let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); - let source = self.data.get_ref(); - if position >= source.len() { - return Poll::Ready(Ok(())); - } - - let end = position.saturating_add(remaining).min(source.len()); - buf.put_slice(&source[position..end]); - self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX)); - Poll::Ready(Ok(())) - } - } - - struct PendingReader; - - impl AsyncRead for PendingReader { - fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll> { - Poll::Pending - } - } - - // Emits `fail_after` bytes from `data`, then returns a hard read error. Used - // to inject the "read K bytes then Err" partial-read case (#1324). - struct ErrAfterReader { - data: std::io::Cursor>, - fail_after: usize, - emitted: usize, - } - - impl AsyncRead for ErrAfterReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - if self.emitted >= self.fail_after { - return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error"))); - } - let remaining = buf.remaining(); - if remaining == 0 { - return Poll::Ready(Ok(())); - } - let want = (self.fail_after - self.emitted).min(remaining); - let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); - let source = self.data.get_ref(); - let end = position.saturating_add(want).min(source.len()); - if end <= position { - return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error"))); - } - let chunk_len = end - position; - buf.put_slice(&source[position..end]); - self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX)); - self.emitted += chunk_len; - Poll::Ready(Ok(())) - } - } - - fn cursor_reader(bytes: &[u8]) -> std::io::Cursor> { - std::io::Cursor::new(bytes.to_vec()) - } - - // #1324: the strict materialization helper is the shared exact-length gate - // for the encrypted, seek, and cache memory branches. For a declared length N - // only an exact N-byte read succeeds; a short read (N-1), an over-long read - // (N+1), and a mid-stream read error all hard-fail. This is the reversal - // guard for every one of those sources at once: restoring WARN-and-serve or a - // partial fallback would flip the short/over-long/error assertions to Ok. - #[tokio::test] - async fn strict_materialize_object_body_requires_exact_length() { - // Exact length: the only accepted outcome. - let buf = strict_materialize_object_body(cursor_reader(b"hello"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ) - .await - .expect("exact-length read must materialize"); - assert_eq!(buf, b"hello"); - assert_eq!(buf.capacity(), 5, "exact materialization must allocate only the declared body length"); - - let exact_large = vec![7_u8; 64 * 1024]; - let buf = strict_materialize_object_body( - std::io::Cursor::new(exact_large.clone()), - exact_large.len(), - GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ, - ) - .await - .expect("64 KiB exact-length read must materialize"); - assert_eq!(buf.capacity(), exact_large.len()); - - let mut overlong_large = exact_large; - overlong_large.push(9); - let overlong = strict_materialize_object_body( - std::io::Cursor::new(overlong_large), - 64 * 1024, - GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ, - ) - .await; - assert!(matches!( - overlong, - Err(StrictMaterializeError::LengthMismatch { - expected: 65_536, - actual: 65_537 - }) - )); - - // Short read (actual = expected - 1): a clean EOF before the declared - // length must be a hard error, never a truncated served body. - let short = strict_materialize_object_body(cursor_reader(b"hell"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; - assert!( - matches!( - short, - Err(StrictMaterializeError::LengthMismatch { - expected: 5, - actual: 4, - .. - }) - ), - "short read must fail with a length mismatch, got {short:?}", - short = short.as_ref().map(|b| b.len()) - ); - - // Over-long read (actual = expected + 1): must fail rather than silently - // truncate to the committed Content-Length. - let long = strict_materialize_object_body(cursor_reader(b"hello!"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; - assert!( - matches!(long, Err(StrictMaterializeError::LengthMismatch { expected: 5, actual: 6 })), - "over-long read must fail with a length mismatch, got {long:?}", - long = long.as_ref().map(|b| b.len()) - ); - - // Read K bytes then Err: must surface the read error and never return the - // partially consumed buffer (which the caller could otherwise re-stream). - let reader = ErrAfterReader { - data: cursor_reader(b"hello"), - fail_after: 3, - emitted: 0, - }; - let errored = strict_materialize_object_body(reader, 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; - assert!( - matches!(errored, Err(StrictMaterializeError::Read { consumed: 3, .. })), - "a mid-stream read error must be reported as a read failure" - ); - } - - #[test] - fn cold_fill_zero_timeout_policy_disables_deadline() { - let policy = GetObjectTimeoutPolicy { - get_object_timeout: Duration::ZERO, - ..GetObjectTimeoutPolicy::default() - }; - let wrapper = RequestTimeoutWrapper::with_request_id(policy.clone(), "cold-fill-zero-timeout"); - assert!(cold_fill_deadline(&wrapper, &policy, 1).is_none()); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_producer_deadline_is_capped_at_ten_minutes() { - let disabled = GetObjectTimeoutPolicy { - get_object_timeout: Duration::ZERO, - ..GetObjectTimeoutPolicy::default() - }; - let now = tokio::time::Instant::now(); - assert_eq!(cold_fill_producer_deadline(&disabled, 1) - now, Duration::from_secs(600)); - - let long = GetObjectTimeoutPolicy { - get_object_timeout: Duration::from_secs(3600), - enable_dynamic_timeout: false, - ..GetObjectTimeoutPolicy::default() - }; - let now = tokio::time::Instant::now(); - assert_eq!(cold_fill_producer_deadline(&long, 1) - now, Duration::from_secs(600)); - } - - #[tokio::test] - async fn cold_fill_startup_wait_stops_when_last_consumer_cancels() { - let cancellation = tokio_util::sync::CancellationToken::new(); - let waiting = tokio::spawn({ - let cancellation = cancellation.clone(); - async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, None).await } - }); - tokio::task::yield_now().await; - - cancellation.cancel(); - - let result = tokio::time::timeout(Duration::from_secs(1), waiting) - .await - .expect("startup wait must observe cancellation") - .expect("startup wait task must not panic"); - assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled))); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_startup_wait_with_deadline_still_observes_cancellation() { - let cancellation = tokio_util::sync::CancellationToken::new(); - let deadline = tokio::time::Instant::now() + Duration::from_secs(60); - let waiting = tokio::spawn({ - let cancellation = cancellation.clone(); - async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await } - }); - tokio::task::yield_now().await; - - cancellation.cancel(); - - let result = waiting.await.expect("startup wait task must not panic"); - assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled))); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_startup_wait_reports_deadline_exceeded() { - let cancellation = tokio_util::sync::CancellationToken::new(); - let deadline = tokio::time::Instant::now() + Duration::from_millis(1); - - let result = await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await; - - assert!(matches!(result, Err(ColdFillStartupWaitError::DeadlineExceeded))); - } - - #[tokio::test] - async fn cold_fill_late_miss_second_chance_hits_without_reader() { - let adapter = ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("second-chance cache config must be valid"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "late-bucket", - object: "late-object", - version_id: None, - etag: "late-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - assert!(matches!( - adapter.lookup_body(&plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Miss - )); - let request_lookups = adapter.cache().stats().lookups; - assert_eq!(request_lookups, 1, "the authoritative request lookup must be counted once"); - - let reservation = adapter.reserve_body(&plan).expect("late producer must reserve"); - let reserved = reservation.wrap_bytes(Bytes::from_static(b"body")); - let _ = adapter.fill_reserved_body(&plan, reserved).await; - let coordinator = adapter.cold_fill_coordinator(); - let cache_key = plan.key().cloned().expect("late plan must be cacheable"); - let adapter = Arc::new(adapter); - let readers = Arc::new(AtomicUsize::new(0)); - let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, { - let adapter = Arc::clone(&adapter); - let readers = Arc::clone(&readers); - move |producer| { - let adapter = Arc::clone(&adapter); - let plan = plan.clone(); - let readers = Arc::clone(&readers); - async move { - if let Some(body) = lookup_cold_fill_second_chance(&adapter, &plan).await { - producer.finish_shared(Ok(body)); - return; - } - readers.fetch_add(1, AtomicOrdering::Relaxed); - producer.bypass(); - } - } - }) - .await; - let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { - panic!("late request must observe the completed fill, got {outcome:?}"); - }; - assert_eq!(body, Bytes::from_static(b"body")); - assert_eq!( - adapter.cache().stats().lookups, - request_lookups, - "the producer second chance must not count another request lookup" - ); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 0); - } - - #[tokio::test] - async fn cold_fill_timeout_is_shared_and_releases_resources() { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("timeout cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "timeout-bucket", - object: "timeout-object", - version_id: None, - etag: "timeout-etag", - size: 1, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let key = plan.key().cloned().expect("timeout body must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else { - panic!("first timeout request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let disk_permits = Arc::new(tokio::sync::Semaphore::new(1)); - let disk_gate = Arc::clone(&disk_permits); - let readers = Arc::new(AtomicUsize::new(0)); - let reader_count = Arc::clone(&readers); - let producer_task = tokio::spawn(start_cold_fill_producer( - producer, - reservation, - move || async move { - let permit = disk_gate - .acquire_owned() - .await - .map_err(|_| ColdFillError::DiskAdmissionClosed)?; - let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); - io.disk_permit = Some(permit.into()); - Ok(io) - }, - move || async move { - reader_count.fetch_add(1, AtomicOrdering::Relaxed); - Ok(GetObjectReader { - stream: Box::new(PendingReader), - object_info: ObjectInfo { - size: 1, - actual_size: 1, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 1, - deadline: Some(tokio::time::Instant::now() + Duration::from_millis(20)), - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - )); - tokio::time::timeout(Duration::from_secs(1), async { - while readers.load(AtomicOrdering::Relaxed) == 0 { - tokio::task::yield_now().await; - } - }) - .await - .expect("producer reader must open"); - let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else { - panic!("second timeout request must follow"); - }; - - let (leader_result, follower_result) = - tokio::time::timeout(Duration::from_secs(2), async { tokio::join!(leader.wait(), follower.wait()) }) - .await - .expect("typed timeout must wake all waiters"); - assert!(matches!( - leader_result, - ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - assert!(matches!( - follower_result, - ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); - assert_eq!(disk_permits.available_permits(), 1); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - assert!(matches!( - adapter.lookup_body(&plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Miss - )); - producer_task.await.expect("producer task must join"); - assert!(adapter.reserve_body(&plan).is_some(), "timeout must release the body reservation"); - let ColdFillRole::Produce(successor) = coordinator.join(key) else { - panic!("timeout must release the session for a successor"); - }; - drop(successor); - } - - #[tokio::test] - async fn cold_fill_survives_leader_request_cancellation_without_second_producer() { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("cancellation cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "cancel-bucket", - object: "cancel-object", - version_id: None, - etag: "cancel-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let key = plan.key().cloned().expect("cancellation body must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else { - panic!("first cancellation request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let readers = Arc::new(AtomicUsize::new(0)); - let reader_count = Arc::clone(&readers); - let writer_slot = Arc::new(Mutex::new(None)); - let writer_output = Arc::clone(&writer_slot); - let producer_task = tokio::spawn(start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - move || async move { - reader_count.fetch_add(1, AtomicOrdering::Relaxed); - let (writer, reader) = tokio::io::duplex(16); - *writer_output.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(writer); - Ok(GetObjectReader { - stream: Box::new(reader), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - )); - tokio::time::timeout(Duration::from_secs(1), async { - while readers.load(AtomicOrdering::Relaxed) == 0 { - tokio::task::yield_now().await; - } - }) - .await - .expect("cancellation producer reader must open"); - let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else { - panic!("second cancellation request must follow"); - }; - drop(leader); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); - let ColdFillRole::Wait(late) = coordinator.join(key) else { - panic!("leader cancellation must not open a successor session"); - }; - drop(late); - - let mut writer = writer_slot - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take() - .expect("reader factory must publish writer"); - tokio::io::AsyncWriteExt::write_all(&mut writer, b"body") - .await - .expect("body write must succeed"); - tokio::io::AsyncWriteExt::shutdown(&mut writer) - .await - .expect("body writer must close"); - let ColdFillWaitOutcome::Ready(result) = follower.wait().await else { - panic!("follower must receive producer result"); - }; - assert_eq!(result.expect("surviving producer must succeed"), Bytes::from_static(b"body")); - producer_task.await.expect("producer task must join"); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); - } - - #[tokio::test] - async fn cold_fill_reservation_rejection_streams_without_materializing() { - let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default()); - let plan = rustfs_object_data_cache::ObjectDataCacheGetPlan::Disabled; - let ColdFillRole::Produce(mut producer) = coordinator.join(rustfs_object_data_cache::ObjectDataCacheKey::new( - "bucket", - "object", - None, - "etag", - 4, - rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - )) else { - panic!("first rejected reservation request must produce"); - }; - let leader = producer.waiter(); - let permits = Arc::new(AtomicUsize::new(0)); - let readers = Arc::new(AtomicUsize::new(0)); - let permit_count = Arc::clone(&permits); - let reader_count = Arc::clone(&readers); - start_cold_fill_producer( - producer, - None, - move || async move { - permit_count.fetch_add(1, AtomicOrdering::Relaxed); - Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) - }, - move || async move { - reader_count.fetch_add(1, AtomicOrdering::Relaxed); - Err(StorageError::other("reader must not open")) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter: Arc::new(ObjectDataCacheAdapter::disabled()), - engine_plan: plan, - }, - ) - .await; - assert!(matches!(leader.wait().await, ColdFillWaitOutcome::Bypass)); - assert_eq!(permits.load(AtomicOrdering::Relaxed), 0); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 0); - - let fallback_reads = Arc::new(AtomicUsize::new(0)); - let fallback_reader = DataProbeReader { - reads: Arc::clone(&fallback_reads), - data: std::io::Cursor::new(b"body".to_vec()), - }; - let info = ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }; - let mut fallback_body = DefaultObjectUsecase::build_get_object_body( - fallback_reader, - &info, - 4, - "req-cold-fill", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - "bucket", - "object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("reservation bypass must construct the normal streaming fallback"); - let chunk = fallback_body - .next() - .await - .expect("fallback stream must yield a body chunk") - .expect("fallback stream must not fail"); - assert_eq!(chunk, Bytes::from_static(b"body")); - assert!(fallback_reads.load(AtomicOrdering::Relaxed) > 0); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 0, "cold-fill materialization must remain unopened"); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_internal_movement_and_restore_reads_never_join_sessions() { - let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default()); - let info = ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }; - let mut restore = ObjectOptions::default(); - restore.transition.restore_request.days = Some(1); - let cases = [ - ObjectOptions { - raw_data_movement_read: true, - ..Default::default() - }, - ObjectOptions { - data_movement: true, - ..Default::default() - }, - restore, - ]; - - for opts in &cases { - assert!(matches!( - lookup_get_object_body_cache_hook("bucket", "object", &None, opts, &info).await, - GetObjectBodyCacheHookLookup::Ineligible - )); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - let delete_marker = ObjectInfo { - delete_marker: true, - etag: Some("delete-marker-etag".to_string()), - ..Default::default() - }; - let delete_marker_part = ObjectOptions { - part_number: Some(2), - ..Default::default() - }; - assert!(matches!( - lookup_get_object_body_cache_hook("bucket", "object", &None, &delete_marker_part, &delete_marker).await, - GetObjectBodyCacheHookLookup::Ineligible - )); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_generation_change_bypasses_before_opening_body() { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("generation retry cache config must be valid"), - ); - let request = |data_dir_u128| rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "generation-bucket", - object: "generation-object", - version_id: None, - etag: "generation-etag", - size: 4, - data_dir_u128: Some(data_dir_u128), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }; - let initial_plan = adapter.plan_get(request(1)); - let changed_plan = GetObjectBodyCachePlan::Cacheable(adapter.plan_get(request(2))); - let cache_key = initial_plan.key().cloned().expect("initial generation must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let body_opens = Arc::new(AtomicUsize::new(0)); - let producer_attempts = Arc::new(AtomicUsize::new(0)); - - let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, { - let body_opens = Arc::clone(&body_opens); - let producer_attempts = Arc::clone(&producer_attempts); - move |producer| { - let body_opens = Arc::clone(&body_opens); - let producer_attempts = Arc::clone(&producer_attempts); - let changed_plan = changed_plan.clone(); - let initial_plan = initial_plan.clone(); - async move { - producer_attempts.fetch_add(1, AtomicOrdering::Relaxed); - let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, &changed_plan, &initial_plan) - else { - return; - }; - body_opens.fetch_add(1, AtomicOrdering::Relaxed); - producer.bypass(); - } - } - }) - .await; - - assert!(matches!(outcome, ColdFillCoordinateOutcome::Bypass)); - assert_eq!(producer_attempts.load(AtomicOrdering::Relaxed), 1); - assert_eq!(body_opens.load(AtomicOrdering::Relaxed), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - async fn real_cold_fill_test_context() -> (Arc, Arc) { - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let ambient = current_app_context().expect("real cold-fill tests require an ambient AppContext"); - let context = temp_env::with_vars( - [ - (rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE, Some("true")), - (rustfs_config::ENV_OBJECT_DATA_CACHE_MODE, Some("fill_materialize_enabled")), - (rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_BYTES, Some("8388608")), - (rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_ENTRY_BYTES, Some("2097152")), - (rustfs_config::ENV_OBJECT_DATA_CACHE_MIN_FREE_MEMORY_PERCENT, Some("0")), - ], - || Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())), - ); - assert!(context.object_data_cache().materialize_fill_enabled()); - (store, context) - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn object_progress_tracks_real_get_and_small_put_lock_waits() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); - let context = temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE, Some("false"))], async { - crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await - }) - .await; - let store = context.object_store(); - let bucket = format!("object-progress-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("object progress bucket must be created"); - put_real_cold_fill_object(&store, &bucket, object, b"initial").await; - - let metadata_entered = Arc::new(tokio::sync::Barrier::new(2)); - let metadata_resume = Arc::new(tokio::sync::Barrier::new(2)); - crate::storage::options::install_versioning_config_test_hook( - bucket.clone(), - Arc::clone(&metadata_entered), - Arc::clone(&metadata_resume), - ); - let metadata_input = GetObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .build() - .expect("metadata GET input must build"); - let metadata_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let metadata_get = tokio::spawn(async move { - metadata_usecase - .execute_get_object(build_request(metadata_input, Method::GET)) - .await - }); - tokio::time::timeout(Duration::from_secs(2), metadata_entered.wait()) - .await - .expect("GET must enter the bucket metadata stage"); - assert!(object_traffic_health.snapshot().read_stalled); - assert!(!metadata_get.is_finished(), "GET must still be waiting in bucket metadata"); - metadata_resume.wait().await; - let metadata_response = tokio::time::timeout(Duration::from_secs(10), metadata_get) - .await - .expect("metadata GET must finish after release") - .expect("metadata GET task must join") - .expect("metadata GET must succeed after release"); - assert!(!object_traffic_health.snapshot().read_stalled); - drop(metadata_response); - - let read_lock = store - .new_ns_lock(&bucket, object) - .await - .expect("read test namespace lock must be created") - .get_write_lock(Duration::from_secs(5)) - .await - .expect("read test namespace lock must be held"); - let get_input = GetObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .build() - .expect("GET input must build"); - let get_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let get = tokio::spawn(async move { get_usecase.execute_get_object(build_request(get_input, Method::GET)).await }); - tokio::time::timeout(Duration::from_secs(2), async { - while !object_traffic_health.read_storage_stalled_for_test() { - tokio::task::yield_now().await; - } - }) - .await - .expect("blocked GET must publish a storage stall"); - assert!(!get.is_finished(), "GET must still be waiting for the held namespace lock"); - drop(read_lock); - let get_response = tokio::time::timeout(Duration::from_secs(10), get) - .await - .expect("GET must finish after releasing the lock") - .expect("GET task must join") - .expect("GET must succeed after releasing the lock"); - assert!(!object_traffic_health.snapshot().read_stalled); - drop(get_response); - - let write_lock = store - .new_ns_lock(&bucket, object) - .await - .expect("write test namespace lock must be created") - .get_write_lock(Duration::from_secs(5)) - .await - .expect("write test namespace lock must be held"); - let post_store_entered = Arc::new(tokio::sync::Barrier::new(2)); - let post_store_resume = Arc::new(tokio::sync::Barrier::new(2)); - install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume)); - let payload = Bytes::from_static(b"replacement"); - let put_input = PutObjectInput::builder() - .bucket(bucket) - .key(object.to_string()) - .body(Some(StreamingBlob::from(s3s::Body::from(payload.clone())))) - .content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64"))) - .build() - .expect("PUT input must build"); - let put_usecase = DefaultObjectUsecase::with_context(Some(context)); - let put = tokio::spawn(async move { - put_usecase - .execute_put_object(&FS::new(), build_request(put_input, Method::PUT)) - .await - }); - tokio::time::timeout(Duration::from_secs(2), async { - while !object_traffic_health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("blocked small PUT must publish a storage stall"); - assert!(!put.is_finished(), "PUT must still be waiting for the held namespace lock"); - drop(write_lock); - tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait()) - .await - .expect("PUT must reach the first post-store hook"); - assert!(!object_traffic_health.snapshot().write_stalled); - assert!(!put.is_finished(), "PUT must remain blocked after the store guard has ended"); - post_store_resume.wait().await; - tokio::time::timeout(Duration::from_secs(10), put) - .await - .expect("PUT must finish after releasing the lock") - .expect("PUT task must join") - .expect("PUT must succeed after releasing the lock"); - let recovered = object_traffic_health.snapshot(); - assert!(!recovered.read_stalled); - assert!(!recovered.write_stalled); - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn cancelled_put_request_completes_post_commit_publication() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (store, context) = real_cold_fill_test_context().await; - let bucket = format!("put-owner-tail-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("PUT owner-tail bucket must be created"); - - let old_body = Bytes::from_static(b"old body that must be invalidated"); - let old_info = put_real_cold_fill_object(&store, &bucket, object, &old_body).await; - let adapter = context.object_data_cache(); - let old_plan = real_cold_fill_plan(&adapter, &bucket, object, &old_info); - - let post_store_entered = Arc::new(tokio::sync::Barrier::new(2)); - let post_store_resume = Arc::new(tokio::sync::Barrier::new(2)); - install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume)); - - let payload = Bytes::from_static(b"published despite caller cancellation"); - let put_input = PutObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .body(Some(StreamingBlob::from(s3s::Body::from(payload.clone())))) - .content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64"))) - .build() - .expect("PUT input must build"); - let put_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let put = tokio::spawn(async move { - put_usecase - .execute_put_object(&FS::new(), build_request(put_input, Method::PUT)) - .await - }); - - tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait()) - .await - .expect("PUT must reach the post-store owner-tail hook"); - assert_eq!( - adapter.fill_body(&old_plan, old_body.clone()).await, - rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted, - "test must republish the old body while the owner tail is paused" - ); - put.abort(); - post_store_resume.wait().await; - let _ = put.await.expect_err("outer request task must be cancelled"); - - tokio::time::timeout(Duration::from_secs(10), async { - loop { - if matches!( - adapter.lookup_body(&old_plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Miss - ) { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("post-commit owner tail must invalidate stale body cache after caller cancellation"); - - let recovered = store - .get_object_info(&bucket, object, &ObjectOptions::default()) - .await - .expect("cancelled request's owned commit must still publish the object"); - assert_eq!(recovered.size, i64::try_from(payload.len()).expect("test payload length must fit i64")); - } - - #[tokio::test] - async fn object_progress_tracks_zero_byte_and_zero_copy_put_lock_waits() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); - let context = - crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await; - let store = context.object_store(); - let bucket = format!("progress-buffered-{}", Uuid::new_v4()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("buffered PUT progress bucket must be created"); - - let extra_body_object = "zero-byte-extra.bin"; - let extra_body_input = PutObjectInput::builder() - .bucket(bucket.clone()) - .key(extra_body_object.to_string()) - .body(Some(StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"x"))))) - .content_length(Some(88)) - .build() - .expect("zero-byte extra-body PUT input must build"); - let extra_body_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let mut extra_body_request = build_request(extra_body_input, Method::PUT); - extra_body_request.headers = streaming_headers(Some("0")); - let extra_body_err = extra_body_usecase - .execute_put_object(&FS::new(), extra_body_request) - .await - .expect_err("decoded zero-byte PUT with body data must fail"); - assert_eq!(extra_body_err.code(), &S3ErrorCode::UnexpectedContent); - assert!(!object_traffic_health.snapshot().write_stalled); - let lookup_err = store - .get_object_info(&bucket, extra_body_object, &ObjectOptions::default()) - .await - .expect_err("rejected zero-byte PUT must not create an object"); - assert!(is_err_object_not_found(&lookup_err)); - - let zero_object = "zero-byte.bin"; - let zero_write_lock = store - .new_ns_lock(&bucket, zero_object) - .await - .expect("zero-byte PUT namespace lock must be created") - .get_write_lock(Duration::from_secs(30)) - .await - .expect("zero-byte PUT namespace lock must be held"); - let (body_polled_tx, body_polled_rx) = tokio::sync::oneshot::channel(); - let (body_release_tx, body_release_rx) = tokio::sync::oneshot::channel(); - let pending_zero_body = StreamingBlob::wrap(futures::stream::once(async move { - body_polled_tx.send(()).expect("zero-byte body poll signal must be received"); - body_release_rx.await.expect("zero-byte body EOF must be released"); - Ok::(Bytes::new()) - })); - let zero_input = PutObjectInput::builder() - .bucket(bucket.clone()) - .key(zero_object.to_string()) - .body(Some(pending_zero_body)) - .content_length(Some(87)) - .build() - .expect("zero-byte PUT input must build"); - let zero_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let mut zero_request = build_request(zero_input, Method::PUT); - zero_request.headers = streaming_headers(Some("0")); - let zero_put = tokio::spawn(async move { zero_usecase.execute_put_object(&FS::new(), zero_request).await }); - - tokio::time::timeout(Duration::from_secs(30), body_polled_rx) - .await - .expect("zero-byte PUT body must be polled for EOF") - .expect("zero-byte PUT body poll signal must be sent"); - assert!(!object_traffic_health.snapshot().write_stalled); - assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for request EOF"); - - body_release_tx.send(()).expect("zero-byte PUT body EOF must be released"); - tokio::time::timeout(Duration::from_secs(30), async { - while !object_traffic_health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("fully received zero-byte PUT must publish a storage stall"); - assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for the held namespace lock"); - - drop(zero_write_lock); - tokio::time::timeout(Duration::from_secs(30), zero_put) - .await - .expect("zero-byte PUT must finish after releasing the lock") - .expect("zero-byte PUT task must join") - .expect("zero-byte PUT must succeed after releasing the lock"); - assert!(!object_traffic_health.snapshot().write_stalled); - - let zero_copy_object = "zero-copy-eager.jpg"; - let zero_copy_payload = Bytes::from(vec![b'z'; 1024 * 1024 + 1]); - let zero_copy_size = i64::try_from(zero_copy_payload.len()).expect("zero-copy payload length must fit i64"); - let zero_copy_headers = HeaderMap::new(); - assert!(!is_disk_compressible(&zero_copy_headers, zero_copy_object)); - assert_eq!( - zero_copy_eager_put_path_status(zero_copy_size, &zero_copy_headers, false, false, false), - PUT_EAGER_STATUS_ELIGIBLE, - "test payload must exercise the production zero-copy eager path", - ); - let zero_copy_write_lock = store - .new_ns_lock(&bucket, zero_copy_object) - .await - .expect("zero-copy PUT namespace lock must be created") - .get_write_lock(Duration::from_secs(30)) - .await - .expect("zero-copy PUT namespace lock must be held"); - let zero_copy_input = PutObjectInput::builder() - .bucket(bucket) - .key(zero_copy_object.to_string()) - .body(Some(StreamingBlob::from(s3s::Body::from(zero_copy_payload)))) - .content_length(Some(zero_copy_size)) - .build() - .expect("zero-copy PUT input must build"); - let zero_copy_usecase = DefaultObjectUsecase::with_context(Some(context)); - let zero_copy_put = tokio::spawn(async move { - zero_copy_usecase - .execute_put_object(&FS::new(), build_request(zero_copy_input, Method::PUT)) - .await - }); - - tokio::time::timeout(Duration::from_secs(30), async { - while !object_traffic_health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("blocked zero-copy eager PUT must publish a storage stall"); - assert!( - !zero_copy_put.is_finished(), - "zero-copy PUT must still be waiting for the held namespace lock" - ); - - drop(zero_copy_write_lock); - tokio::time::timeout(Duration::from_secs(30), zero_copy_put) - .await - .expect("zero-copy PUT must finish after releasing the lock") - .expect("zero-copy PUT task must join") - .expect("zero-copy PUT must succeed after releasing the lock"); - assert!(!object_traffic_health.snapshot().write_stalled); - } - - async fn put_real_cold_fill_object(store: &Arc, bucket: &str, object: &str, body: &[u8]) -> ObjectInfo { - let mut reader = PutObjReader::from_vec(body.to_vec()); - store - .put_object(bucket, object, &mut reader, &ObjectOptions::default()) - .await - .expect("real cold-fill test object must be written") - } - - fn real_cold_fill_plan( - adapter: &ObjectDataCacheAdapter, - bucket: &str, - object: &str, - info: &ObjectInfo, - ) -> rustfs_object_data_cache::ObjectDataCacheGetPlan { - let length = info - .get_actual_size() - .expect("real cold-fill test metadata must expose plaintext size"); - let GetObjectBodyCachePlan::Cacheable(plan) = build_get_object_body_cache_plan( - adapter, - GetObjectBodyCacheRequest { - bucket, - key: object, - info, - response_content_length: length, - has_range: false, - part_number: None, - encryption_applied: false, - }, - ) else { - panic!("real cold-fill test object must be cacheable"); - }; - plan - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn execute_get_object_rejects_conditions_before_joining_cold_fill() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (store, context) = real_cold_fill_test_context().await; - let bucket = format!("cold-condition-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("real cold-fill condition bucket must be created"); - let body = vec![b'a'; 1_300_000]; - let info = put_real_cold_fill_object(&store, &bucket, object, &body).await; - let adapter = context.object_data_cache(); - let plan = real_cold_fill_plan(&adapter, &bucket, object, &info); - let coordinator = adapter.cold_fill_coordinator(); - let ColdFillRole::Produce(producer) = - coordinator.join(plan.key().cloned().expect("real cold-fill plan must expose its key")) - else { - panic!("test must reserve the initial cold-fill producer"); - }; - - let input = GetObjectInput::builder() - .bucket(bucket) - .key(object.to_string()) - .build() - .expect("real cold-fill GET input must build"); - let mut req = build_request(input, Method::GET); - let etag = info.etag.expect("real cold-fill test object must have an ETag"); - req.headers.insert( - http::header::IF_NONE_MATCH, - HeaderValue::from_str(&format!("\"{etag}\"")).expect("ETag header must be valid"), - ); - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(req)) - .await - .expect("conditional GET must not wait for the reserved cold-fill session") - .expect_err("matching If-None-Match must reject the GET"); - - assert_eq!(result.code(), &S3ErrorCode::NotModified); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - drop(producer); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn execute_get_object_maps_cold_fill_session_rejection_to_slow_down_without_opening_reader() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (store, context) = real_cold_fill_test_context().await; - let bucket = format!("cold-rejected-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("real cold-fill rejection bucket must be created"); - let body = vec![b'a'; 1_300_000]; - let info = put_real_cold_fill_object(&store, &bucket, object, &body).await; - let adapter = context.object_data_cache(); - let plan = real_cold_fill_plan(&adapter, &bucket, object, &info); - let cache_key = plan.key().cloned().expect("real cold-fill plan must expose its key"); - let coordinator = adapter.cold_fill_coordinator(); - let mut held_producers = Vec::new(); - for index in 0..2048 { - let saturation_key = rustfs_object_data_cache::ObjectDataCacheKey::new( - "cold-fill-saturation", - format!("object-{index}"), - None, - "etag", - 4, - rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - ); - match coordinator.join(saturation_key) { - ColdFillRole::Produce(producer) => held_producers.push(producer), - ColdFillRole::Rejected => break, - ColdFillRole::Wait(_) | ColdFillRole::Bypass => panic!("unique saturation keys must produce or reject"), - } - } - assert_eq!(coordinator.active_session_count_for_test(), held_producers.len()); - assert!(!held_producers.is_empty(), "saturation must reserve cold-fill sessions"); - - let reader_opens = Arc::new(AtomicU64::new(0)); - *COLD_FILL_READER_OPEN_PROBE - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((cache_key, Arc::clone(&reader_opens))); - let input = GetObjectInput::builder() - .bucket(bucket) - .key(object.to_string()) - .build() - .expect("real cold-fill rejection GET input must build"); - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(build_request(input, Method::GET))) - .await - .expect("rejected real GET must not wait for a cold-fill session") - .expect_err("rejected real GET must return an S3 error"); - *COLD_FILL_READER_OPEN_PROBE - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - - assert_eq!(result.code(), &S3ErrorCode::SlowDown); - assert_eq!(reader_opens.load(Ordering::Relaxed), 0, "rejected GET must not open its body reader"); - assert_eq!(coordinator.active_session_count_for_test(), held_producers.len()); - drop(held_producers); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn execute_get_object_generation_change_bypasses_old_cold_fill_plan() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (store, context) = real_cold_fill_test_context().await; - let bucket = format!("cold-generation-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("real cold-fill generation bucket must be created"); - let initial_body = vec![b'a'; 1_300_000]; - let changed_body = vec![b'b'; initial_body.len()]; - let initial_info = put_real_cold_fill_object(&store, &bucket, object, &initial_body).await; - let adapter = context.object_data_cache(); - let initial_plan = real_cold_fill_plan(&adapter, &bucket, object, &initial_info); - let coordinator = adapter.cold_fill_coordinator(); - let ColdFillRole::Produce(producer) = - coordinator.join(initial_plan.key().cloned().expect("real cold-fill plan must expose its key")) - else { - panic!("test must reserve the initial cold-fill producer"); - }; - - let input = GetObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .build() - .expect("real cold-fill GET input must build"); - // The request is intentionally held behind the first producer while a - // 1.3 MiB replacement write changes its generation. Disable dynamic - // sizing for this test so runner I/O load cannot consume the five-second - // production minimum before the behavior under test is released. - let usecase = DefaultObjectUsecase::with_context_and_get_object_timeout_policy( - Some(context), - GetObjectTimeoutPolicy { - enable_dynamic_timeout: false, - ..GetObjectTimeoutPolicy::default() - }, - ); - let request = tokio::spawn(async move { usecase.execute_get_object(build_request(input, Method::GET)).await }); - tokio::time::timeout(Duration::from_secs(2), async { - while coordinator.global_waiter_count_for_test() != 1 { - tokio::task::yield_now().await; - } - }) - .await - .expect("real GET must join the reserved cold-fill session"); - - let changed_info = put_real_cold_fill_object(&store, &bucket, object, &changed_body).await; - assert_ne!(initial_info.etag, changed_info.etag); - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - - let mut response = tokio::time::timeout(Duration::from_secs(10), request) - .await - .expect("generation-changing GET must complete") - .expect("generation-changing GET task must join") - .expect("generation-changing GET must fall back successfully"); - let mut response_body = response.output.body.take().expect("GET response must include a body"); - let mut actual = Vec::with_capacity(changed_body.len()); - while let Some(chunk) = response_body.next().await { - actual.extend_from_slice(&chunk.expect("fallback body chunk must be readable")); - } - - assert_eq!(actual, changed_body); - assert!(matches!( - adapter.lookup_body(&initial_plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Miss - )); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_open_error_retries_once_then_single_successor_succeeds() { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("open retry cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "open-retry-bucket", - object: "open-retry-object", - version_id: None, - etag: "open-retry-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let cache_key = plan.key().cloned().expect("open retry plan must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let open_attempts = Arc::new(AtomicUsize::new(0)); - let open_attempts_for_start = Arc::clone(&open_attempts); - - let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { - let reservation = adapter.reserve_body(&plan); - let adapter = Arc::clone(&adapter); - let plan = plan.clone(); - let open_attempts = Arc::clone(&open_attempts_for_start); - async move { - start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - move || async move { - let attempt = open_attempts.fetch_add(1, AtomicOrdering::Relaxed); - if attempt == 0 { - return Err(StorageError::other("first open fails")); - } - Ok(GetObjectReader { - stream: Box::new(std::io::Cursor::new(Vec::::new())), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: Some(Bytes::from_static(b"body")), - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter, - engine_plan: plan, - }, - ) - .await - } - }) - .await; - - let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { - panic!("the unique successor must publish the body"); - }; - assert_eq!(body, Bytes::from_static(b"body")); - assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_open_timeout_retries_once_then_is_terminal() { - tokio::time::pause(); - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("open timeout cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "open-timeout-bucket", - object: "open-timeout-object", - version_id: None, - etag: "open-timeout-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let cache_key = plan.key().cloned().expect("open timeout plan must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let open_attempts = Arc::new(AtomicUsize::new(0)); - - let deadline = tokio::time::Instant::now() + Duration::from_millis(10); - let task = tokio::spawn({ - let adapter = Arc::clone(&adapter); - let coordinator = Arc::clone(&coordinator); - let plan = plan.clone(); - let open_attempts = Arc::clone(&open_attempts); - async move { - coordinate_cold_fill(&coordinator, cache_key, None, Some(deadline), move |producer| { - let adapter = Arc::clone(&adapter); - let plan = plan.clone(); - let open_attempts = Arc::clone(&open_attempts); - let reservation = adapter.reserve_body(&plan); - let producer_deadline = producer.deadline(); - async move { - start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - move || async move { - open_attempts.fetch_add(1, AtomicOrdering::Relaxed); - std::future::pending::>().await - }, - ColdFillProducerExecution { - expected: 4, - deadline: producer_deadline, - adapter, - engine_plan: plan, - }, - ) - .await - } - }) - .await - } - }); - while open_attempts.load(AtomicOrdering::Relaxed) == 0 { - tokio::task::yield_now().await; - } - tokio::time::advance(Duration::from_millis(11)).await; - let outcome = task.await.expect("open timeout task must join"); - assert!(matches!( - outcome, - ColdFillCoordinateOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - - assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_pre_reader_failure_promotes_one_of_two_thousand_waiters() { - const REQUESTS: usize = 2000; - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("successor cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "successor-bucket", - object: "successor-object", - version_id: None, - etag: "successor-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let cache_key = plan.key().cloned().expect("successor plan must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let admission_attempts = Arc::new(AtomicUsize::new(0)); - let open_attempts = Arc::new(AtomicUsize::new(0)); - let first_open_release = Arc::new(tokio::sync::Semaphore::new(0)); - let mut tasks = tokio::task::JoinSet::new(); - - for _ in 0..REQUESTS { - let adapter = Arc::clone(&adapter); - let coordinator = Arc::clone(&coordinator); - let cache_key = cache_key.clone(); - let plan = plan.clone(); - let admission_attempts = Arc::clone(&admission_attempts); - let open_attempts = Arc::clone(&open_attempts); - let first_open_release = Arc::clone(&first_open_release); - tasks.spawn(async move { - coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { - let reservation = adapter.reserve_body(&plan); - let adapter = Arc::clone(&adapter); - let plan = plan.clone(); - let admission_attempts = Arc::clone(&admission_attempts); - let open_attempts = Arc::clone(&open_attempts); - let first_open_release = Arc::clone(&first_open_release); - async move { - start_cold_fill_producer( - producer, - reservation, - move || async move { - admission_attempts.fetch_add(1, AtomicOrdering::Relaxed); - Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) - }, - move || async move { - if open_attempts.fetch_add(1, AtomicOrdering::Relaxed) == 0 { - first_open_release - .acquire() - .await - .expect("first open release gate must remain open") - .forget(); - return Err(StorageError::other("first open fails")); - } - Ok(GetObjectReader { - stream: Box::new(std::io::Cursor::new(Vec::::new())), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: Some(Bytes::from_static(b"body")), - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter, - engine_plan: plan, - }, - ) - .await - } - }) - .await - }); - } - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if coordinator.global_waiter_count_for_test() == REQUESTS - 1 && open_attempts.load(AtomicOrdering::Relaxed) == 1 - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("all followers must join before the first open fails"); - first_open_release.add_permits(1); - - while let Some(result) = tasks.join_next().await { - let ColdFillCoordinateOutcome::Ready(Ok(body)) = result.expect("successor request task must join") else { - panic!("all followers must receive the successor body"); - }; - assert_eq!(body, Bytes::from_static(b"body")); - } - assert_eq!(admission_attempts.load(AtomicOrdering::Relaxed), 2); - assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - fn install_cold_fill_publication_barrier( - plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan, - ) -> Arc { - let barrier = Arc::new(ColdFillPublicationBarrier { - reached: tokio::sync::Semaphore::new(0), - release: tokio::sync::Semaphore::new(0), - }); - let key = plan.key().cloned().expect("publication barrier plan must be cacheable"); - *COLD_FILL_PUBLICATION_BARRIER - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((key, Arc::clone(&barrier))); - barrier - } - - fn clear_cold_fill_publication_barrier() { - *COLD_FILL_PUBLICATION_BARRIER - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - } - - fn publication_test_adapter() -> Arc { - Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("publication cache config must be valid"), - ) - } - - fn publication_test_plan(adapter: &ObjectDataCacheAdapter, object: &str) -> rustfs_object_data_cache::ObjectDataCacheGetPlan { - adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "publication-bucket", - object, - version_id: None, - etag: "publication-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }) - } - - #[tokio::test] - #[serial_test::serial(cold_fill_publication_barrier)] - async fn cold_fill_last_consumer_cancel_releases_session_before_publication_barrier() { - let adapter = publication_test_adapter(); - let plan = publication_test_plan(&adapter, "cancel"); - let barrier = install_cold_fill_publication_barrier(&plan); - let coordinator = adapter.cold_fill_coordinator(); - let key = plan.key().cloned().expect("publication plan must be cacheable"); - let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { - panic!("publication request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let disk_permits = Arc::new(tokio::sync::Semaphore::new(1)); - let disk_gate = Arc::clone(&disk_permits); - let producer_task = tokio::spawn(scope_cold_fill_disk_permit_owner_for_test( - ColdFillDiskPermitOwner::Producer, - start_cold_fill_producer( - producer, - reservation, - move || async move { - let permit = disk_gate - .acquire_owned() - .await - .map_err(|_| ColdFillError::DiskAdmissionClosed)?; - let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); - io.disk_permit = Some(permit.into()); - Ok(io) - }, - || async { - Ok(GetObjectReader { - stream: Box::new(std::io::Cursor::new(b"body".to_vec())), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - ), - )); - - let reached = barrier.reached.acquire().await.expect("publication barrier must remain open"); - reached.forget(); - assert_eq!( - disk_permits.available_permits(), - 1, - "the producer disk permit and its gauge guard must end before publication" - ); - let clear_adapter = Arc::clone(&adapter); - let clear = tokio::spawn(async move { - clear_adapter - .clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual) - .await - }); - tokio::task::yield_now().await; - assert!(!clear.is_finished(), "clear must wait while publication owns its reservation"); - drop(leader); - tokio::time::timeout(Duration::from_secs(1), async { - while coordinator.active_session_count_for_test() != 0 { - tokio::task::yield_now().await; - } - }) - .await - .expect("last-consumer cancellation must release the session immediately"); - tokio::time::timeout(Duration::from_secs(1), clear) - .await - .expect("clear must finish after publication cancellation") - .expect("clear task must join"); - producer_task.await.expect("producer task must join"); - - barrier.release.add_permits(1); - clear_cold_fill_publication_barrier(); - drop(adapter.reserve_body(&plan).expect("publication reservation must be released")); - } - - #[tokio::test(start_paused = true)] - #[serial_test::serial(cold_fill_publication_barrier)] - async fn cold_fill_hard_deadline_releases_session_at_publication_barrier() { - let adapter = publication_test_adapter(); - let plan = publication_test_plan(&adapter, "deadline"); - let barrier = install_cold_fill_publication_barrier(&plan); - let coordinator = adapter.cold_fill_coordinator(); - let key = plan.key().cloned().expect("publication plan must be cacheable"); - let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { - panic!("publication request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let deadline = tokio::time::Instant::now() + Duration::from_millis(20); - let producer_task = tokio::spawn(start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - || async { - Ok(GetObjectReader { - stream: Box::new(std::io::Cursor::new(Vec::::new())), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: Some(Bytes::from_static(b"body")), - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: Some(deadline), - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - )); - - let reached = barrier.reached.acquire().await.expect("publication barrier must remain open"); - reached.forget(); - tokio::time::advance(Duration::from_millis(20)).await; - assert!(matches!( - leader.wait().await, - ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - assert_eq!(coordinator.active_session_count_for_test(), 0); - producer_task.await.expect("producer task must join"); - - barrier.release.add_permits(1); - clear_cold_fill_publication_barrier(); - drop( - adapter - .reserve_body(&plan) - .expect("deadline must release the publication reservation"), - ); - tokio::time::timeout( - Duration::from_secs(1), - adapter.clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual), - ) - .await - .expect("clear must complete after publication deadline"); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_without_request_timeout_stops_at_ten_minute_hard_cap() { - let adapter = publication_test_adapter(); - let plan = publication_test_plan(&adapter, "hard-cap"); - let coordinator = adapter.cold_fill_coordinator(); - let key = plan.key().cloned().expect("hard-cap plan must be cacheable"); - let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { - panic!("hard-cap request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let producer_task = tokio::spawn(start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - || async { - Ok(GetObjectReader { - stream: Box::new(PendingReader), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - )); - let wait = tokio::spawn(async move { leader.wait().await }); - - tokio::time::advance(Duration::from_secs(599)).await; - tokio::task::yield_now().await; - assert!(!wait.is_finished(), "hard cap must not fire before 600 seconds"); - assert!(adapter.reserve_body(&plan).is_none(), "reservation must remain owned before the hard cap"); - - tokio::time::advance(Duration::from_secs(1)).await; - assert!(matches!( - wait.await.expect("hard-cap waiter must join"), - ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - producer_task.await.expect("producer task must join"); - assert_eq!(coordinator.active_session_count_for_test(), 0); - drop( - adapter - .reserve_body(&plan) - .expect("hard cap must release the body reservation"), - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_same_key_cold_fill_consumes_one_reader() { - const REQUESTS: usize = 2000; - const BODY_BYTES: usize = 64 * 1024; - const BODY_BYTES_U64: u64 = 64 * 1024; - const BODY_BYTES_I64: i64 = 64 * 1024; - - for key_count in [1_usize, 4, 32] { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 128 * 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024 * 1024, - min_free_memory_percent: 0, - fill_concurrency_per_cpu: 64, - fill_concurrency_max: 64, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("matrix cache config must be valid"), - ); - let coordinator = adapter.cold_fill_coordinator(); - let disk_permits = Arc::new(tokio::sync::Semaphore::new(key_count)); - let writers = Arc::new(tokio::sync::Mutex::new(Vec::with_capacity(key_count))); - let permit_acquires = Arc::new(AtomicUsize::new(0)); - let reader_factories = Arc::new(AtomicUsize::new(0)); - let first_polls = Arc::new(AtomicUsize::new(0)); - let completed = Arc::new(AtomicUsize::new(0)); - let bytes_read = Arc::new(AtomicUsize::new(0)); - let mut tasks = tokio::task::JoinSet::new(); - - for request in 0..REQUESTS { - let key_index = request % key_count; - let object = format!("matrix-object-{key_index}"); - let engine_plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "matrix-bucket", - object: &object, - version_id: None, - etag: "matrix-etag", - size: BODY_BYTES_U64, - data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let cache_key = engine_plan.key().cloned().expect("matrix body must be cacheable"); - let adapter = Arc::clone(&adapter); - let coordinator = Arc::clone(&coordinator); - let disk_permits = Arc::clone(&disk_permits); - let writers = Arc::clone(&writers); - let permit_acquires = Arc::clone(&permit_acquires); - let reader_factories = Arc::clone(&reader_factories); - let first_polls = Arc::clone(&first_polls); - let completed = Arc::clone(&completed); - let bytes_read = Arc::clone(&bytes_read); - tasks.spawn(async move { - let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { - let reservation = adapter.reserve_body(&engine_plan); - let adapter = Arc::clone(&adapter); - let disk_permits = Arc::clone(&disk_permits); - let writers = Arc::clone(&writers); - let permit_acquires = Arc::clone(&permit_acquires); - let reader_factories = Arc::clone(&reader_factories); - let first_polls = Arc::clone(&first_polls); - let completed = Arc::clone(&completed); - let bytes_read = Arc::clone(&bytes_read); - let fill_plan = engine_plan.clone(); - async move { - start_cold_fill_producer( - producer, - reservation, - || async move { - permit_acquires.fetch_add(1, AtomicOrdering::Relaxed); - let permit = disk_permits - .acquire_owned() - .await - .map_err(|_| ColdFillError::DiskAdmissionClosed)?; - let mut io = - DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); - io.disk_permit = Some(permit.into()); - Ok(io) - }, - || async move { - reader_factories.fetch_add(1, AtomicOrdering::Relaxed); - let (writer, reader) = tokio::io::duplex(BODY_BYTES * 2); - writers.lock().await.push(writer); - Ok(GetObjectReader { - stream: Box::new(ColdFillMatrixReader { - inner: reader, - first_poll_recorded: false, - completion_recorded: false, - first_polls, - completed, - bytes_read, - }), - object_info: ObjectInfo { - size: BODY_BYTES_I64, - actual_size: BODY_BYTES_I64, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: BODY_BYTES, - deadline: None, - adapter, - engine_plan: fill_plan, - }, - ) - .await - } - }) - .await; - let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { - panic!("matrix request must receive the shared body, got {outcome:?}"); - }; - assert_eq!(body.len(), BODY_BYTES); - assert!(body.iter().all(|byte| *byte == 7)); - (key_index, body.as_ptr() as usize) - }); - } - - tokio::time::timeout(Duration::from_secs(30), async { - loop { - if writers.lock().await.len() == key_count - && coordinator.global_waiter_count_for_test() == REQUESTS - key_count - && first_polls.load(AtomicOrdering::Relaxed) == key_count - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("all matrix followers must join before releasing bodies"); - - let mut body_writers = std::mem::take(&mut *writers.lock().await); - let body = vec![7_u8; BODY_BYTES]; - for writer in &mut body_writers { - tokio::io::AsyncWriteExt::write_all(writer, &body) - .await - .expect("matrix body write must succeed"); - tokio::io::AsyncWriteExt::shutdown(writer) - .await - .expect("matrix body writer must close"); - } - let mut backing_pointers = std::collections::HashMap::>::new(); - tokio::time::timeout(Duration::from_secs(30), async { - while let Some(result) = tasks.join_next().await { - let (key_index, body_pointer) = result.expect("matrix GET task must complete"); - backing_pointers.entry(key_index).or_default().insert(body_pointer); - } - }) - .await - .expect("matrix GET tasks must complete before the watchdog"); - - assert_eq!(permit_acquires.load(AtomicOrdering::Relaxed), key_count); - assert_eq!(reader_factories.load(AtomicOrdering::Relaxed), key_count); - assert_eq!(first_polls.load(AtomicOrdering::Relaxed), key_count); - assert_eq!(completed.load(AtomicOrdering::Relaxed), key_count); - assert_eq!(bytes_read.load(AtomicOrdering::Relaxed), key_count * BODY_BYTES); - assert_eq!(backing_pointers.len(), key_count); - assert!( - backing_pointers.values().all(|pointers| pointers.len() == 1), - "all followers of one key must share one backing allocation" - ); - assert_eq!( - backing_pointers - .values() - .flatten() - .copied() - .collect::>() - .len(), - key_count - ); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - assert_eq!(disk_permits.available_permits(), key_count); - - for key_index in 0..key_count { - let object = format!("matrix-object-{key_index}"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "matrix-bucket", - object: &object, - version_id: None, - etag: "matrix-etag", - size: BODY_BYTES_U64, - data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - assert!(matches!( - adapter.lookup_body(&plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_) - )); - } - } - } - - // #1324: the in-memory (buffered/cache) source is guarded by - // MemoryTrackedBytesStream. A buffer whose length disagrees with the declared - // content length must yield a stream error on first poll instead of a clean - // short body or an over-long body. Reverting to the old warn-and-serve - // behavior would make these assertions observe Ok chunks. - #[tokio::test] - #[serial_test::serial] - async fn memory_tracked_bytes_stream_fails_short_body() { - let mut stream = MemoryTrackedBytesStream::new( - Bytes::from_static(b"test"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - None, - GetObjectBodyLifecycle::disabled(), - ); - let err = stream - .next() - .await - .expect("mismatched memory body must yield an item") - .expect_err("a short memory body must fail the stream instead of serving a truncated body"); - assert_eq!( - err.downcast_ref::().map(std::io::Error::kind), - Some(std::io::ErrorKind::InvalidData) - ); - assert!(stream.next().await.is_none(), "stream must terminate after the error"); - } - - #[tokio::test] - #[serial_test::serial] - async fn memory_tracked_bytes_stream_fails_over_long_body() { - let mut stream = MemoryTrackedBytesStream::new( - Bytes::from_static(b"hello!"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - None, - GetObjectBodyLifecycle::disabled(), - ); - let err = stream - .next() - .await - .expect("mismatched memory body must yield an item") - .expect_err("an over-long memory body must fail the stream instead of serving mismatched bytes"); - assert_eq!( - err.downcast_ref::().map(std::io::Error::kind), - Some(std::io::ErrorKind::InvalidData) - ); - } - - #[test] - fn memory_blob_preserves_exact_remaining_length() { - let blob = DefaultObjectUsecase::build_memory_bytes_blob( - Bytes::from_static(b"hello"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - GetObjectBodyLifecycle::disabled(), - ); - - assert_eq!(blob.remaining_length().exact(), Some(5)); - } - - #[test] - #[serial_test::serial] - fn memory_blob_once_fast_path_holds_guard_until_bytes_drop() { - temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || { - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let blob = DefaultObjectUsecase::build_memory_bytes_blob( - Bytes::from_static(b"hello"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - GetObjectBodyLifecycle::tracked(guard), - ); - let mut body = s3s::Body::from(blob); - let bytes = body.take_bytes().expect("opt-in exact memory body should stay on Body::Once"); - - assert_eq!(bytes, Bytes::from_static(b"hello")); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - drop(bytes); - assert_eq!(GetObjectGuard::concurrent_count(), initial); - }); - } - - #[test] - #[serial_test::serial] - fn memory_blob_once_fast_path_rejects_length_mismatch() { - temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || { - let blob = DefaultObjectUsecase::build_memory_bytes_blob( - Bytes::from_static(b"test"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - GetObjectBodyLifecycle::disabled(), - ); - let mut body = s3s::Body::from(blob); - - assert!(body.take_bytes().is_none(), "mismatched memory body must keep the guarded stream path"); - }); - } - - #[tokio::test] - async fn get_object_streaming_reader_times_out_when_body_stalls() { - let reader = GetObjectStreamingReader::new( - PendingReader, - "test-bucket", - "stalled-object", - "req-stalled-stream", - None, - 1, - Duration::from_millis(1), - GetObjectBodyLifecycle::disabled(), - None, - ); - let mut stream = ReaderStream::with_capacity(reader, 1024); - - let err = stream - .next() - .await - .expect("reader stream should yield timeout") - .expect_err("stalled reader should return an error"); - - assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); - } - - #[tokio::test] - async fn get_object_streaming_reader_fails_closed_without_active_reader() { - use tokio::io::AsyncReadExt; - - let mut reader = GetObjectStreamingReader::new( - cursor_reader(b"x"), - "test-bucket", - "missing-reader-object", - "req-missing-reader", - None, - 1, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - None, - ); - reader.inner.take(); - - let err = reader - .read_to_end(&mut Vec::new()) - .await - .expect_err("an impossible missing active reader must fail closed"); - - assert_eq!(err.kind(), std::io::ErrorKind::Other); - assert_eq!(err.to_string(), "get object streaming reader lost its active read outside resume"); - } - - #[tokio::test] - async fn put_object_body_read_timeout_guard_aborts_on_stall() { - // Inner stream never yields and never reports EOF (a proxy that forwarded - // a partial body then went silent while holding the connection open). - let inner = StreamingBlob::wrap(futures::stream::pending::>()); - let mut guarded = guard_put_object_body_read_timeout( - inner, - "test-bucket", - "stalled-object", - "req-1", - Some(1024), - Duration::from_millis(1), - ); - - let err = guarded - .next() - .await - .expect("guard should yield a stall error") - .expect_err("stalled body should return an error"); - let io_err = err - .downcast_ref::() - .expect("stall error should wrap an io::Error"); - assert_eq!(io_err.kind(), std::io::ErrorKind::TimedOut); - - // After a stall the guard terminates the stream instead of re-polling the - // abandoned inner stream. - assert!(guarded.next().await.is_none()); - } - - #[tokio::test] - async fn put_object_body_read_timeout_guard_preserves_length_and_passes_through() { - let body = StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"hello world"))); - assert_eq!(body.remaining_length().exact(), Some(11)); - - let mut guarded = - guard_put_object_body_read_timeout(body, "test-bucket", "ok-object", "req-2", Some(11), Duration::from_secs(60)); - // remaining_length must be forwarded, not reset to unknown. - assert_eq!(guarded.remaining_length().exact(), Some(11)); - - let mut collected = Vec::new(); - while let Some(chunk) = guarded.next().await { - collected.extend_from_slice(&chunk.expect("chunk should read")); - } - assert_eq!(collected, b"hello world"); - } - - #[tokio::test] - async fn put_object_body_read_timeout_guard_disabled_passthrough() { - let body = StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"data"))); - let mut guarded = guard_put_object_body_read_timeout(body, "test-bucket", "ok-object", "req-3", Some(4), Duration::ZERO); - - let mut collected = Vec::new(); - while let Some(chunk) = guarded.next().await { - collected.extend_from_slice(&chunk.expect("chunk should read")); - } - assert_eq!(collected, b"data"); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_streaming_reader_holds_request_guard_until_eof() { - use tokio::io::AsyncReadExt; - - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let mut reader = GetObjectStreamingReader::new( - std::io::Cursor::new(b"hello".to_vec()), - "test-bucket", - "complete-object", - "req-complete-stream", - None, - 5, - Duration::ZERO, - GetObjectBodyLifecycle::tracked(guard), - None, - ); - let mut out = Vec::new(); - - reader - .read_to_end(&mut out) - .await - .expect("complete streaming body should read successfully"); - - assert_eq!(out, b"hello"); - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_streaming_reader_errors_on_short_eof() { - use tokio::io::AsyncReadExt; - - // The inner reader delivers 5 bytes then a clean EOF, but the advertised - // Content-Length is 10. The reader must surface an error rather than a clean EOF, so - // the client sees a failed transfer instead of silently persisting a truncated body - // (the "incomplete data mirroring" of #2955). - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let mut reader = GetObjectStreamingReader::new( - std::io::Cursor::new(b"short".to_vec()), - "test-bucket", - "truncated-object", - "req-short-eof", - None, - 10, - Duration::ZERO, - GetObjectBodyLifecycle::tracked(guard), - None, - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("short body under a larger Content-Length must fail the stream"); - assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); - let incomplete_body = err - .get_ref() - .and_then(|inner| inner.downcast_ref::()) - .expect("short eof should include remaining bytes as IncompleteBody"); - assert_eq!(incomplete_body.remaining, 5); - assert_eq!(out, b"short", "bytes read before the short EOF are still delivered"); - - drop(reader); - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - #[test] - #[serial_test::serial] - fn get_object_streaming_reader_releases_request_guard_when_dropped_incomplete() { - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let reader = GetObjectStreamingReader::new( - std::io::Cursor::new(b"short".to_vec()), - "test-bucket", - "dropped-object", - "req-dropped-stream", - None, - 10, - Duration::ZERO, - GetObjectBodyLifecycle::tracked(guard), - None, - ); - drop(reader); - - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - // Emits all of `data`, then either the injected error or a clean EOF. Drives - // the mid-stream resume state machine through its typed-error and - // premature-EOF triggers without a store. - struct FailAtEndReader { - data: std::io::Cursor>, - error: Option, - } - - impl FailAtEndReader { - fn new(data: &[u8], error: Option) -> Self { - Self { - data: std::io::Cursor::new(data.to_vec()), - error, - } - } - } - - impl AsyncRead for FailAtEndReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); - let source_len = self.data.get_ref().len(); - if position >= source_len { - return match self.error.take() { - Some(error) => Poll::Ready(Err(error)), - None => Poll::Ready(Ok(())), - }; - } - let want = buf.remaining().min(source_len - position); - if want == 0 { - return Poll::Ready(Ok(())); - } - buf.put_slice(&self.data.get_ref()[position..position + want]); - self.data.set_position(u64::try_from(position + want).unwrap_or(u64::MAX)); - Poll::Ready(Ok(())) - } - } - - fn relocation_read_error() -> std::io::Error { - std::io::Error::other(StorageError::FileNotFound) - } - - fn counting_resume_control( - reopen_count: Arc, - mut reopen: impl FnMut(usize) -> Result + Send + Sync + 'static, - ) -> GetObjectResumeControl { - let reopen: GetObjectReopen = Box::new(move |emitted| { - reopen_count.fetch_add(1, Ordering::Relaxed); - let outcome = reopen(emitted); - Box::pin(async move { outcome }) - }); - GetObjectResumeControl::new( - reopen, - RetryTimer::new( - GET_OBJECT_RESUME_MAX_ATTEMPTS, - Duration::from_millis(1), - Duration::from_millis(2), - rustfs_utils::retry::NO_JITTER, - 0, - ), - ) - } - - #[tokio::test] - async fn get_object_streaming_reader_resumes_after_relocation_error() { - use tokio::io::AsyncReadExt; - - // Every typed relocation variant the codec read path can surface - // mid-body must arm the resume flow. - for variant in [ - StorageError::FileNotFound, - StorageError::ObjectNotFound("test-bucket".to_string(), "relocated-object".to_string()), - StorageError::InsufficientReadQuorum("test-bucket".to_string(), "relocated-object".to_string()), - StorageError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "relocated shard disappeared")), - ] { - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| { - assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); - Ok(FailAtEndReader::new(b"world", None)) - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(std::io::Error::other(variant))), - "test-bucket", - "relocated-object", - "req-resume-typed-error", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect("a resumed body must deliver the full committed content"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 1); - } - } - - #[tokio::test(start_paused = true)] - async fn get_object_streaming_reader_releases_failed_disk_permit_before_reopen() { - use tokio::io::AsyncReadExt; - - let manager = Arc::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 0)); - let initial_permit = match manager - .admit_disk_read(Duration::ZERO) - .await - .expect("test disk admission must remain open") - { - DiskReadAdmission::Primary(permit) => permit, - other => panic!("initial read must hold the only primary permit, got {other:?}"), - }; - let reopen_count = Arc::new(AtomicUsize::new(0)); - let reopen: GetObjectReopen> = Box::new({ - let manager = Arc::clone(&manager); - let reopen_count = Arc::clone(&reopen_count); - move |emitted| { - assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); - reopen_count.fetch_add(1, Ordering::Relaxed); - let manager = Arc::clone(&manager); - Box::pin(async move { - match manager - .admit_disk_read(Duration::from_millis(1)) - .await - .map_err(|_| GetObjectResumeFailure::Fatal)? - { - DiskReadAdmission::Primary(permit) => { - Ok(DiskReadPermitReader::new(FailAtEndReader::new(b"world", None), permit.into())) - } - _ => Err(GetObjectResumeFailure::Retryable), - } - }) - } - }); - let control = GetObjectResumeControl::new( - reopen, - RetryTimer::new( - GET_OBJECT_RESUME_MAX_ATTEMPTS, - Duration::from_millis(1), - Duration::from_millis(2), - rustfs_utils::retry::NO_JITTER, - 0, - ), - ); - let initial_reader = - DiskReadPermitReader::new(FailAtEndReader::new(b"hello ", Some(relocation_read_error())), initial_permit.into()); - let mut reader = GetObjectStreamingReader::new( - initial_reader, - "test-bucket", - "relocated-object", - "req-resume-single-permit", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - - reader - .read_to_end(&mut out) - .await - .expect("resume must not wait on the failed reader's permit"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 1); - assert_eq!( - manager.io_queue_status().permits_in_use, - 0, - "the replacement reader must release its permit at EOF" - ); - } - - #[tokio::test] - async fn get_object_streaming_reader_resumes_after_premature_eof() { - use tokio::io::AsyncReadExt; - - // The legacy duplex read path surfaces vanished object data as a clean - // EOF before the committed length; the resume flow must treat it like - // the typed relocation error. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| { - assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); - Ok(FailAtEndReader::new(b"world", None)) - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", None), - "test-bucket", - "truncated-object", - "req-resume-short-eof", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect("a resumed body must deliver the full committed content"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 1); - } - - #[tokio::test] - async fn get_object_streaming_reader_clean_eof_does_not_resume() { - use tokio::io::AsyncReadExt; - - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| { - panic!("a cleanly completed body must never reopen"); - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello world", None), - "test-bucket", - "complete-object", - "req-resume-clean-eof", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - reader.read_to_end(&mut out).await.expect("complete body must read"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 0); - } - - #[tokio::test] - async fn get_object_streaming_reader_fatal_resume_failure_returns_original_error() { - use tokio::io::AsyncReadExt; - - // A fatal reopen failure (the reopened object is a different version) - // must surface the original trigger error after exactly one attempt, - // with only the originally emitted prefix delivered. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Fatal)); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(relocation_read_error())), - "test-bucket", - "replaced-object", - "req-resume-fatal", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("a fatal resume failure must fail the body with the original error"); - - assert!( - err.get_ref().is_some_and(|inner| inner.is::()), - "the surfaced error must be the original typed trigger, got: {err}" - ); - assert_eq!(out, b"hello "); - assert_eq!( - reopen_count.load(Ordering::Relaxed), - 1, - "a fatal failure must short-circuit the retry budget" - ); - } - - #[tokio::test] - async fn get_object_streaming_reader_exhausts_resume_budget() { - use tokio::io::AsyncReadExt; - - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Retryable)); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(relocation_read_error())), - "test-bucket", - "vanished-object", - "req-resume-budget", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("an exhausted resume budget must fail the body with the original error"); - - assert!( - err.get_ref().is_some_and(|inner| inner.is::()), - "the surfaced error must be the original typed trigger, got: {err}" - ); - assert_eq!(out, b"hello "); - assert_eq!( - reopen_count.load(Ordering::Relaxed), - usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), - "resume must stop after its reopen budget" - ); - } - - #[tokio::test] - async fn get_object_streaming_reader_rearms_resume_after_a_successful_resume() { - use tokio::io::AsyncReadExt; - - // A successful resume restores the armed state: a second mid-stream - // relocation error on the replacement stream must resume again. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted { - 6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))), - 8 => Ok(FailAtEndReader::new(b"rld", None)), - other => panic!("unexpected reopen offset {other}"), - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(relocation_read_error())), - "test-bucket", - "twice-relocated-object", - "req-resume-rearm", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect("a re-armed resume must deliver the full committed content"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 2); - } - - #[tokio::test] - async fn get_object_streaming_reader_resume_budget_is_per_body() { - use tokio::io::AsyncReadExt; - - // The retry budget is consumed across the whole body, not reset per - // error: one successful resume plus two failed reopens exhausts it. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted { - 6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))), - _ => Err(GetObjectResumeFailure::Retryable), - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(relocation_read_error())), - "test-bucket", - "budget-shared-object", - "req-resume-budget-per-body", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("the shared budget must exhaust and surface the latest trigger error"); - - assert!( - err.get_ref().is_some_and(|inner| inner.is::()), - "the surfaced error must be the typed trigger, got: {err}" - ); - assert_eq!(out, b"hello wo"); - assert_eq!( - reopen_count.load(Ordering::Relaxed), - usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), - "the budget spans every resume of the same body" - ); - } - - #[tokio::test] - async fn get_object_streaming_reader_non_relocation_error_passes_through() { - use tokio::io::AsyncReadExt; - - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| { - panic!("a non-relocation read error must not reopen"); - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(std::io::Error::new(std::io::ErrorKind::InvalidData, "corrupt"))), - "test-bucket", - "corrupt-object", - "req-resume-passthrough", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("a non-relocation error must fail the body unchanged"); - - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); - assert_eq!(out, b"hello "); - assert_eq!(reopen_count.load(Ordering::Relaxed), 0); - } - - #[tokio::test] - async fn get_object_streaming_reader_error_after_full_delivery_does_not_resume() { - use tokio::io::AsyncReadExt; - - // The committed length is already delivered when the inner stream - // errors, so the error must keep the existing fail-loud behavior - // instead of arming a resume. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| { - panic!("an error after full delivery must not reopen"); - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello world", Some(relocation_read_error())), - "test-bucket", - "fully-delivered-object", - "req-resume-after-full", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("a post-completion inner error still surfaces instead of being swallowed"); - - assert!( - err.get_ref().is_some_and(|inner| inner.is::()), - "the surfaced error must be the inner typed error, got: {err}" - ); - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 0); - } - - #[test] - fn get_object_resume_identity_requires_same_version() { - let version_id = Uuid::from_u128(0x1234); - let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); - let later_mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_100).expect("valid timestamp"); - let identity = GetObjectResumeIdentity { - version_id: Some(version_id), - mod_time: Some(mod_time), - size: 11, - etag: Some("etag-a".to_string()), - range_dependent_size: false, - }; - let info = ObjectInfo { - version_id: Some(version_id), - mod_time: Some(mod_time), - size: 11, - etag: Some("etag-a".to_string()), - ..Default::default() - }; - assert!(identity.matches(&info, 0)); - assert!(identity.matches(&info, 6), "a plain read reports the range-invariant oi.size"); - // Rebalance regenerates data_dir for the same version: identity must - // still match so a relocated read can resume. - assert!(identity.matches( - &ObjectInfo { - data_dir: Some(Uuid::from_u128(0xbeef)), - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - version_id: Some(Uuid::from_u128(0x5678)), - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - version_id: None, - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - mod_time: Some(later_mod_time), - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - size: 12, - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - etag: Some("etag-b".to_string()), - ..info.clone() - }, - 0 - )); - assert!(!identity.matches(&ObjectInfo { etag: None, ..info }, 0)); - } - - #[test] - fn get_object_resume_identity_normalizes_range_dependent_size() { - // Encrypted and compressed reads report the per-read delivered length - // as object_info.size, so the reopened subrange reports size - emitted - // for the same version. - let version_id = Uuid::from_u128(0x1234); - let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); - let identity = GetObjectResumeIdentity { - version_id: Some(version_id), - mod_time: Some(mod_time), - size: 11, - etag: Some("etag-a".to_string()), - range_dependent_size: true, - }; - let reopened = ObjectInfo { - version_id: Some(version_id), - mod_time: Some(mod_time), - size: 5, - etag: Some("etag-a".to_string()), - ..Default::default() - }; - assert!(identity.matches(&reopened, 6), "the reopened subrange reports size - emitted"); - assert!(identity.matches( - &ObjectInfo { - size: 11, - ..reopened.clone() - }, - 0 - )); - assert!( - !identity.matches( - &ObjectInfo { - size: 11, - ..reopened.clone() - }, - 6 - ), - "an unshrunk range-dependent size after emitted bytes is a different object" - ); - assert!(!identity.matches(&ObjectInfo { size: 4, ..reopened }, 6)); - } - - #[test] - fn get_object_resume_range_offsets() { - // A full-object read that emitted nothing reopens range-free so the - // replacement stream keeps the codec fast path. - assert!(GetObjectResumeContext::resume_range(0, -1, 0).is_none()); - - // Mid-stream full-object resume: open-ended from the emitted offset. - let range = GetObjectResumeContext::resume_range(0, -1, 6).expect("a mid-stream resume must carry a range"); - assert!(!range.is_suffix_length); - assert_eq!((range.start, range.end), (6, -1)); - - // Ranged reads resume at absolute offsets with the committed end - // preserved (suffix ranges and partNumber GETs are resolved to absolute - // offsets before these values are captured). - let range = GetObjectResumeContext::resume_range(10, 19, 0).expect("a ranged resume must carry a range"); - assert!(!range.is_suffix_length); - assert_eq!((range.start, range.end), (10, 19)); - let range = GetObjectResumeContext::resume_range(10, 19, 5).expect("a ranged resume must carry a range"); - assert_eq!((range.start, range.end), (15, 19)); - } - - async fn real_get_resume_test_context() -> (Vec, Arc, Arc) { - let (disk_paths, store) = crate::app::gating_test_env::shared_gating_ecstore_and_disk_paths().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let ambient = current_app_context().expect("resume wiring tests require an ambient AppContext"); - let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); - (disk_paths, store, context) - } - - // Uploads a real multipart object through the store and returns the - // concatenated body, so resume wiring tests can verify byte-exact delivery - // against on-disk part files. - async fn put_real_multipart_object( - store: &Arc, - bucket: &str, - object: &str, - part_size: usize, - part_count: usize, - fill: u8, - ) -> Vec { - use crate::app::storage_api::multipart_usecase::contract::multipart::{CompletePart, MultipartOperations as _}; - - let upload = store - .new_multipart_upload(bucket, object, &ObjectOptions::default()) - .await - .expect("create multipart upload"); - let mut parts = Vec::new(); - let mut body = Vec::with_capacity(part_size * part_count); - for part_id in 1..=part_count { - let part_fill = fill.wrapping_add(u8::try_from(part_id - 1).expect("test part index must fit u8")); - let part_body = vec![part_fill; part_size]; - body.extend_from_slice(&part_body); - let mut reader = PutObjReader::from_vec(part_body); - let part = store - .put_object_part(bucket, object, &upload.upload_id, part_id, &mut reader, &ObjectOptions::default()) - .await - .expect("upload multipart part"); - parts.push(CompletePart { - part_num: part_id, - etag: part.etag, - ..Default::default() - }); - } - store - .clone() - .complete_multipart_upload(bucket, object, &upload.upload_id, parts, &ObjectOptions::default()) - .await - .expect("complete multipart upload"); - body - } - - // Deletes the given part files from every version data dir present on the - // disks, simulating rebalance removing the object data while xl.meta stays - // readable. Returns the number of version dirs visited and files removed. - fn delete_object_part_shards( - disk_paths: &[std::path::PathBuf], - bucket: &str, - object: &str, - part_numbers: &[usize], - ) -> (usize, usize) { - let mut version_dirs = 0; - let mut deleted = 0; - for disk_path in disk_paths { - let object_dir = disk_path.join(bucket).join(object); - let entries = match std::fs::read_dir(&object_dir) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => panic!("object directory must be readable: {error}"), - }; - for entry in entries { - let entry = entry.expect("object directory entry must read"); - if !entry.file_type().expect("entry file type must read").is_dir() { - continue; - } - version_dirs += 1; - for part_number in part_numbers { - let part_file = entry.path().join(format!("part.{part_number}")); - if part_file.exists() { - std::fs::remove_file(&part_file).expect("part shard must be removable"); - deleted += 1; - } - } - } - } - (version_dirs, deleted) - } - - // The surfaced mid-stream failure must be the original trigger: a typed - // relocation StorageError from the codec read path, or an IncompleteBody - // (UnexpectedEof) from the duplex path. The resume flow must never - // fabricate a different error. - fn assert_original_trigger_error(error: &(dyn std::error::Error + Send + Sync + 'static)) { - let Some(io_error) = error.downcast_ref::() else { - panic!("body error must be an io::Error, got: {error}"); - }; - let is_trigger = io_error.kind() == std::io::ErrorKind::UnexpectedEof || is_object_relocation_error(io_error); - assert!(is_trigger, "body error must be the original relocation trigger, got: {error}"); - } - - #[tokio::test] - #[serial_test::serial] - // SAFETY: the test mutates one process env var before any use; nextest runs - // each test in its own process, so the mutation cannot race another test. - #[allow(unsafe_code)] - async fn execute_get_object_resume_exhausts_budget_when_object_data_vanishes() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - // The resume phase runs inside the body stall budget (default 10s), - // and three real reopen attempts against missing shards approach it on - // loaded CI disks; widen the budget so this test asserts the resume - // outcome instead of racing the stall timer. - unsafe { std::env::set_var(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, "120") }; - - let (disk_paths, store, context) = real_get_resume_test_context().await; - let bucket = format!("resume-vanish-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create resume failure-path bucket"); - let part_size = 6 * 1024 * 1024; - let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xAA).await; - - // Remove the part.2/part.3 shards on every disk before the GET starts, - // so no file descriptor for them can exist: the stream must fail at the - // part-2 boundary, and every reopen resolves intact metadata whose data - // is gone, so the whole resume budget burns down. - let (version_dirs, deleted) = delete_object_part_shards(&disk_paths, &bucket, object, &[2, 3]); - assert!(version_dirs > 0, "the multipart object must have at least one version data directory"); - assert_eq!(deleted, version_dirs * 2); - - let input = GetObjectInput::builder() - .bucket(bucket) - .key(object.to_string()) - .build() - .expect("resume failure-path GET input must build"); - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed); - let mut response = usecase - .execute_get_object(build_request(input, Method::GET)) - .await - .expect("the GET commits a response; the body fails mid-stream"); - let mut response_body = response.output.body.take().expect("GET response must include a body"); - let mut collected = Vec::new(); - let mut stream_error = None; - while let Some(chunk) = response_body.next().await { - match chunk { - Ok(bytes) => collected.extend_from_slice(&bytes), - Err(error) => { - stream_error = Some(error); - break; - } - } - } - - assert_eq!( - collected, - &body[..part_size], - "only the first part can be delivered before the object data vanishes" - ); - assert_original_trigger_error( - stream_error - .as_deref() - .expect("the body stream must fail at the missing part"), - ); - let attempts = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before; - assert_eq!( - attempts, - usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), - "resume must exhaust its reopen budget before failing" - ); - } - - #[tokio::test] - #[serial_test::serial] - async fn execute_get_object_resumes_from_relocated_pool_without_splicing_body() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (temp_dir, pool_disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let ambient = current_app_context().expect("multi-pool resume test requires an ambient AppContext"); - let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); - let bucket = format!("resume-relocate-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create multi-pool resume bucket"); - let part_size = 24 * 1024 * 1024; - let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xA5).await; - let upload_pool = pool_disk_paths - .iter() - .position(|paths| { - paths - .iter() - .any(|path| path.join(&bucket).join(object).join("xl.meta").is_file()) - }) - .expect("multipart object must be placed in one source pool"); - if upload_pool != 0 { - for (source_disk, target_disk) in pool_disk_paths[upload_pool].iter().zip(&pool_disk_paths[0]) { - let source_object = source_disk.join(&bucket).join(object); - let target_bucket = target_disk.join(&bucket); - std::fs::create_dir_all(&target_bucket).expect("create normalized target bucket directory"); - std::fs::rename(source_object, target_bucket.join(object)).expect("normalize the test object into the old pool"); - } - } - let source_pool = 0; - let target_pool = 1; - - let input = GetObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .build() - .expect("multi-pool resume GET input must build"); - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let mut response = usecase - .execute_get_object(build_request(input, Method::GET)) - .await - .expect("multi-pool GET must commit its response"); - let mut response_body = response - .output - .body - .take() - .expect("multi-pool GET response must include a body"); - - // Open the source reader before publishing the relocated object. Build - // each replica outside the bucket and rename it into place atomically so - // background maintenance never observes a metadata-less target object. - let mut staged_targets = Vec::with_capacity(pool_disk_paths[target_pool].len()); - for (source_disk, target_disk) in pool_disk_paths[source_pool].iter().zip(&pool_disk_paths[target_pool]) { - let source_dir = source_disk.join(&bucket).join(object); - let target_dir = target_disk.join(&bucket).join(object); - let staging_dir = temp_dir.path().join(format!("resume-relocate-{}", Uuid::new_v4())); - std::fs::create_dir_all(&staging_dir).expect("create relocated target staging directory"); - for entry in std::fs::read_dir(&source_dir).expect("read source object directory") { - let entry = entry.expect("read source object entry"); - if !entry.file_type().expect("read source object entry type").is_dir() { - continue; - } - let target_entry = staging_dir.join(entry.file_name()); - std::fs::create_dir_all(&target_entry).expect("create relocated target data directory"); - for child in std::fs::read_dir(entry.path()).expect("read source object data directory") { - let child = child.expect("read source object data entry"); - std::fs::copy(child.path(), target_entry.join(child.file_name())).expect("copy relocated object data entry"); - } - } - std::fs::copy(source_dir.join("xl.meta"), staging_dir.join("xl.meta")).expect("stage relocated object metadata"); - staged_targets.push((staging_dir, target_dir)); - } - let (version_dirs, deleted) = delete_object_part_shards(&pool_disk_paths[source_pool], &bucket, object, &[2, 3]); - assert!(version_dirs > 0, "the source pool must have at least one version data directory"); - assert_eq!(deleted, version_dirs * 2); - - for ((staging_dir, target_dir), source_disk) in staged_targets.into_iter().zip(&pool_disk_paths[source_pool]) { - std::fs::rename(staging_dir, target_dir).expect("publish relocated target object"); - let source_meta = source_disk.join(&bucket).join(object).join("xl.meta"); - std::fs::remove_file(source_meta).expect("remove relocated source object metadata"); - } - store - .get_object_info(&bucket, object, &ObjectOptions::default()) - .await - .expect("the relocated object must resolve from the target pool"); - - let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed); - let mut collected = Vec::new(); - while let Some(chunk) = response_body.next().await { - match chunk { - Ok(chunk) => collected.extend_from_slice(&chunk), - Err(err) => panic!( - "relocated GET from pool {source_pool} must resume from pool {target_pool} after {} attempts: {err:?}", - GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before - ), - } - } - - assert_eq!(collected, body, "resumed production GET must preserve the complete body byte-for-byte"); - assert_eq!( - GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before, - 1, - "the relocated body must reopen exactly once" - ); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_resume_reopen_rejects_a_replaced_object_version() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - use tokio::io::AsyncReadExt as _; - - let (_disk_paths, store, _context) = real_get_resume_test_context().await; - let bucket = format!("resume-identity-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create resume identity bucket"); - let body = vec![0xAA; 1024 * 1024]; - put_real_cold_fill_object(&store, &bucket, object, &body).await; - let info = store - .get_object_info(&bucket, object, &ObjectOptions::default()) - .await - .expect("read the committed object metadata"); - - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - &bucket, - object, - ObjectOptions::default(), - &HeaderMap::new(), - &info, - 0, - -1, - ); - - // Positive control: the same version reopens and streams the body. - let manager = get_concurrency_manager(); - let permits_before = manager.io_queue_status().permits_in_use; - let mut reader = ctx.reopen(0).await.expect("reopening the same version must succeed"); - assert_eq!( - manager.io_queue_status().permits_in_use, - permits_before + 1, - "the resumed stream must hold disk-read admission like the initial read" - ); - let mut reopened_body = Vec::new(); - reader - .read_to_end(&mut reopened_body) - .await - .expect("the reopened reader must stream the body"); - assert_eq!(reopened_body, body); - // The reopened reader holds the object read lock; drop it before the - // delete below requests the write lock. - drop(reader); - assert_eq!( - manager.io_queue_status().permits_in_use, - permits_before, - "dropping the resumed stream must release its disk-read admission" - ); - - // A nonzero-offset reopen must splice the remaining bytes exactly. - let mut reader = ctx.reopen(1024).await.expect("reopening at a nonzero offset must succeed"); - let mut tail = Vec::new(); - reader - .read_to_end(&mut tail) - .await - .expect("the offset reader must stream the remaining body"); - assert_eq!(tail, body[1024..], "the resumed stream must continue from the emitted offset exactly"); - drop(reader); - - // Delete and re-PUT the key, then the stale context must refuse to - // splice the replacement version into the committed response. - store - .delete_object(&bucket, object, ObjectOptions::default()) - .await - .expect("delete the original object"); - let replacement_body = vec![0xBB; 2 * 1024 * 1024]; - put_real_cold_fill_object(&store, &bucket, object, &replacement_body).await; - let result = ctx.reopen(0).await; - assert!( - matches!(result, Err(GetObjectResumeFailure::Fatal)), - "reopening a replaced version must fail closed" - ); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_resume_context_pins_latest_read_to_resolved_version() { - let (_disk_paths, store, _context) = real_get_resume_test_context().await; - let resolved_version = Uuid::new_v4(); - let info = ObjectInfo { - version_id: Some(resolved_version), - ..Default::default() - }; - - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &HeaderMap::new(), - &info, - 0, - -1, - ); - assert_eq!( - ctx.opts.version_id, - Some(resolved_version.to_string()), - "latest GET resume must reopen the initially resolved version, not the moving latest" - ); - - let explicit_version = Uuid::new_v4().to_string(); - let explicit_opts = ObjectOptions { - version_id: Some(explicit_version.clone()), - ..Default::default() - }; - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - explicit_opts, - &HeaderMap::new(), - &info, - 0, - -1, - ); - assert_eq!( - ctx.opts.version_id.as_deref(), - Some(explicit_version.as_str()), - "an explicit request version must stay authoritative" - ); - - let unversioned_info = ObjectInfo::default(); - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &HeaderMap::new(), - &unversioned_info, - 0, - -1, - ); - assert_eq!(ctx.opts.version_id, None, "unversioned reads have no version to pin"); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_resume_context_redacts_ssec_headers_and_flags_range_dependent_size() { - let (_disk_paths, store, _context) = real_get_resume_test_context().await; - - let mut request_headers = HeaderMap::new(); - request_headers.insert(SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256")); - request_headers.insert(SSEC_KEY_HEADER, HeaderValue::from_static("dGVzdC1rZXk=")); - request_headers.insert(SSEC_KEY_MD5_HEADER, HeaderValue::from_static("bWQ1")); - request_headers.insert(http::header::AUTHORIZATION, HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test")); - request_headers.insert("x-amz-security-token", HeaderValue::from_static("session-token")); - let store_headers = project_ssec_transport_headers(&request_headers); - assert_eq!(store_headers.len(), 3, "only store-consumed SSE-C headers are forwarded"); - assert!(store_headers.values().all(HeaderValue::is_sensitive)); - assert!(store_headers.get(http::header::AUTHORIZATION).is_none()); - assert!(store_headers.get("x-amz-security-token").is_none()); - assert!(!format!("{store_headers:?}").contains("dGVzdC1rZXk=")); - let plain_info = ObjectInfo { - size: 11, - ..Default::default() - }; - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &request_headers, - &plain_info, - 0, - -1, - ); - for name in [SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER] { - let value = ctx.ssec_headers.get(name).expect("the SSE-C trio is retained"); - assert!(value.is_sensitive(), "store spans record headers at debug; {name} must be redacted there"); - } - assert_eq!( - ctx.ssec_headers.len(), - 3, - "only the SSE-C trio may be retained; credential headers must never be replayed into store spans" - ); - assert!(!ctx.identity.range_dependent_size, "plain reads report the range-invariant oi.size"); - - let encrypted_info = ObjectInfo { - size: 11, - user_defined: Arc::new( - [("x-amz-server-side-encryption".to_string(), "aws:kms".to_string())] - .into_iter() - .collect(), - ), - ..Default::default() - }; - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &HeaderMap::new(), - &encrypted_info, - 0, - -1, - ); - assert!(ctx.identity.range_dependent_size, "encrypted reads report the per-read delivered length"); - - let compressed_info = ObjectInfo { - size: 11, - user_defined: Arc::new( - [("x-rustfs-internal-compression".to_string(), "snappy".to_string())] - .into_iter() - .collect(), - ), - ..Default::default() - }; - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &HeaderMap::new(), - &compressed_info, - 0, - -1, - ); - assert!(ctx.identity.range_dependent_size, "compressed reads report the per-read delivered length"); - } - - #[tokio::test] - #[serial_test::serial] - async fn memory_tracked_bytes_stream_releases_request_guard_after_emit() { - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let mut stream = MemoryTrackedBytesStream::new( - Bytes::from_static(b"hello"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - None, - GetObjectBodyLifecycle::tracked(guard), - ); - let chunk = stream - .next() - .await - .expect("memory body should emit one chunk") - .expect("memory body chunk should be readable"); - - assert_eq!(chunk.as_ref(), b"hello"); - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - #[test] - #[serial_test::serial] - fn memory_tracked_bytes_stream_releases_request_guard_for_zero_length_without_poll() { - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let stream = MemoryTrackedBytesStream::new( - Bytes::new(), - 0, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - None, - GetObjectBodyLifecycle::tracked(guard), - ); - drop(stream); - - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - #[tokio::test] - async fn disk_read_permit_reader_holds_permit_until_reader_is_dropped() { - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - let permit = semaphore - .clone() - .acquire_owned() - .await - .expect("test semaphore should grant owned permit"); - - let reader = DiskReadPermitReader::new(std::io::Cursor::new(Vec::::new()), permit.into()); - assert_eq!(semaphore.available_permits(), 0); - - drop(reader); - assert_eq!(semaphore.available_permits(), 1); - } - - #[tokio::test] - #[serial_test::serial(cold_fill_metrics_gate)] - async fn cold_fill_follower_disk_permit_metric_tracks_actual_permit_lifetime() { - COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.store(0, Ordering::Relaxed); - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { - let permit = semaphore - .clone() - .acquire_owned() - .await - .expect("follower test semaphore must grant an owned permit"); - let tracked = GetObjectDiskPermit::new(permit); - assert_eq!(semaphore.available_permits(), 0); - assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 1); - - drop(tracked); - assert_eq!(semaphore.available_permits(), 1); - assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 0); - }) - .await; - } - - #[test] - #[serial_test::serial(cold_fill_metrics_gate)] - fn cold_fill_disk_permit_metrics_obey_gate_and_return_to_zero() { - use metrics_util::debugging::{DebugValue, DebuggingRecorder}; - - let metrics_was_enabled = rustfs_io_metrics::metrics_enabled(); - let recorder = DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("metric test runtime must build"); - metrics::with_local_recorder(&recorder, || { - runtime.block_on(async { - rustfs_io_metrics::set_metrics_enabled(false); - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { - let permit = semaphore - .clone() - .acquire_owned() - .await - .expect("metric test permit must be available"); - let tracked = GetObjectDiskPermit::new(permit); - rustfs_io_metrics::set_metrics_enabled(true); - drop(tracked); - }) - .await; - assert!( - snapshotter.snapshot().into_vec().into_iter().all(|(composite, _, _, _)| { - !composite.key().name().starts_with("rustfs_object_data_cache_cold_fill_") - }), - "a permit acquired while metrics were disabled must not record an unmatched decrement" - ); - - scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Producer, async { - let permit = semaphore - .clone() - .acquire_owned() - .await - .expect("metric test permit must be available"); - let tracked = GetObjectDiskPermit::new(permit); - rustfs_io_metrics::set_metrics_enabled(false); - drop(tracked); - }) - .await; - rustfs_io_metrics::set_metrics_enabled(true); - scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { - let permit = semaphore.acquire_owned().await.expect("metric test permit must be available"); - let tracked = GetObjectDiskPermit::new(permit); - let _replacement = crate::app::object_data_cache::ColdFillCoordinator::default(); - drop(tracked); - }) - .await; - }); - }); - - let values = snapshotter - .snapshot() - .into_vec() - .into_iter() - .filter_map(|(composite, _unit, _description, value)| { - composite - .key() - .name() - .starts_with("rustfs_object_data_cache_cold_fill_") - .then_some((composite.key().name().to_string(), value)) - }) - .collect::>(); - assert_eq!(values.len(), 2); - for name in [ - "rustfs_object_data_cache_cold_fill_producer_disk_permits", - "rustfs_object_data_cache_cold_fill_follower_disk_permits", - ] { - let DebugValue::Gauge(value) = values.get(name).unwrap_or_else(|| panic!("missing {name} gauge")) else { - panic!("{name} must be a gauge"); - }; - assert_eq!(value.into_inner(), 0.0, "{name} must return to zero after permit drop"); - } - rustfs_io_metrics::set_metrics_enabled(metrics_was_enabled); - } - - #[tokio::test] - async fn build_get_object_body_keeps_large_objects_on_streaming_path_without_preread() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 18_i64 * 1024 * 1024 * 1024, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 18_i64 * 1024 * 1024 * 1024, - "req-large-object", - None, - 128 * 1024, - true, - 1, - None, - false, - false, - None, - "test-bucket", - "large-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("build_get_object_body should succeed for streaming path"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "large-object response construction should not pre-read object data" - ); - } - - #[tokio::test] - async fn build_get_object_body_keeps_large_encrypted_objects_on_streaming_path_without_preread() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 18_i64 * 1024 * 1024 * 1024, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 18_i64 * 1024 * 1024 * 1024, - "req-large-encrypted-object", - None, - 128 * 1024, - true, - 1, - None, - false, - true, - None, - "test-bucket", - "large-encrypted-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("build_get_object_body should succeed for encrypted streaming path"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "large encrypted object response construction should not pre-read object data" - ); - } - - #[tokio::test] - async fn build_get_object_body_uses_buffered_body_without_reader_preread() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 4, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 4, - "req-direct-memory-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"test")), - "test-bucket", - "direct-memory-object", - GetObjectBodyLifecycle::disabled(), - |_| panic!("a buffered body must not initialize streaming resume state"), - ) - .await - .expect("build_get_object_body should consume buffered body"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "buffered GetObject body must not be read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_uses_cached_body_without_reader_preread() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "test-bucket", - object: "cached-object", - version_id: None, - etag: "etag", - size: 5, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"hello")).await; - - assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted); - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-cached-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| panic!("a cache hit must not initialize streaming resume state"), - ) - .await - .expect("cache hit body handoff should succeed"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "cache hit body handoff must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_rejects_size_mismatch_fill() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "test-bucket", - object: "cached-object", - version_id: None, - etag: "etag", - size: 5, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"oops")).await; - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-rejects-size-mismatch-fill", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("size-mismatched direct fill should not create a cache hit"); - let lookup_after_mismatch = adapter.lookup_body(&plan).await; - - assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::SkippedSizeMismatch); - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "size-mismatched rejected fill should construct the fallback stream without pre-reading" - ); - assert!( - matches!(lookup_after_mismatch, rustfs_object_data_cache::ObjectDataCacheLookup::Miss), - "size-mismatched fill must not leave a reusable cache entry" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_fills_from_buffered_body_without_reader_preread() { - let first_reads = Arc::new(AtomicUsize::new(0)); - let first_reader = ReadProbeReader { - reads: Arc::clone(&first_reads), - }; - let second_reads = Arc::new(AtomicUsize::new(0)); - let second_reader = ReadProbeReader { - reads: Arc::clone(&second_reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - - let _first_body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - first_reader, - &info, - 5, - "req-cache-fill-first", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"hello")), - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("buffered-body handoff should succeed"); - - // ODC-15: the fill is detached from the response path, so wait for it to - // populate the cache before the follow-up GET to keep the hit deterministic. - wait_for_cache_hit(&adapter, "test-bucket", "cached-object", "etag", 5).await; - - let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - second_reader, - &info, - 5, - "req-cache-fill-second", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("follow-up cache hit should succeed"); - - assert_eq!( - first_reads.load(AtomicOrdering::Relaxed), - 0, - "buffered-body fill path must not read from the fallback reader" - ); - assert_eq!( - second_reads.load(AtomicOrdering::Relaxed), - 0, - "cache hit after buffered-body fill must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_skips_buffered_fill_on_size_mismatch() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "test-bucket", - object: "cached-object", - version_id: None, - etag: "etag", - size: 5, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-rejects-buffered-size-mismatch", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"oops")), - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("size-mismatched buffered-body handoff should still return a response body"); - let lookup = adapter.lookup_body(&plan).await; - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "buffered-body handoff must not read from the fallback reader" - ); - assert!( - matches!(lookup, rustfs_object_data_cache::ObjectDataCacheLookup::Miss), - "size-mismatched buffered body must not be filled into cache" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_hook_served_records_no_second_lookup() { - // ODC-16 (backlog#1121): a hook-served GET must record exactly one - // lookup — the ecstore hook's. The app layer, handed the cache body as - // buffered_body with cache_hook_served=true, must serve it directly - // without a second lookup (which would double the hits and hit_bytes). - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "test-bucket", - object: "hook-served", - version_id: None, - etag: "etag", - size: 5, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let hit_body = Bytes::from_static(b"hello"); - assert_eq!( - adapter.cache().fill_body(&plan, hit_body.clone()).await, - rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted - ); - - // Simulate the ecstore hook: it performs exactly one lookup after fresh - // metadata resolution, hits, and hands the body forward as buffered_body. - assert!(matches!( - adapter.lookup_body(&plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_) - )); - let lookups_after_hook = adapter.cache().stats().lookups; - assert_eq!(lookups_after_hook, 1, "the hook performs exactly one lookup"); - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-hook-served", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(hit_body), - /* cache_hook_served */ true, - /* cache_hook_probed */ true, - /* cache_fill_allowed */ true, - "test-bucket", - "hook-served", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("hook-served body handoff should succeed"); - - assert_eq!( - adapter.cache().stats().lookups, - lookups_after_hook, - "a hook-served GET must not record a second lookup in the app layer" - ); - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "hook-served body handoff must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_hook_miss_skips_app_lookup() { - // ODC-16: when the hook probed and missed, its miss is authoritative - // (it ran after fresh metadata resolution), so the app layer must not - // run a second lookup — it only fills from the buffered body. - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - - let lookups_before = adapter.cache().stats().lookups; - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-hook-missed", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"hello")), - /* cache_hook_served */ false, - /* cache_hook_probed */ true, - /* cache_fill_allowed */ true, - "test-bucket", - "hook-missed", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("hook-miss buffered-body handoff should succeed"); - - assert_eq!( - adapter.cache().stats().lookups, - lookups_before, - "a hook-probed miss must not trigger an app-layer lookup" - ); - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "buffered-body handoff must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_materializes_once_and_hits_later() { - let first_reads = Arc::new(AtomicUsize::new(0)); - let first_reader = DataProbeReader { - reads: Arc::clone(&first_reads), - data: std::io::Cursor::new(b"hello".to_vec()), - }; - let second_reads = Arc::new(AtomicUsize::new(0)); - let second_reader = ReadProbeReader { - reads: Arc::clone(&second_reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let _first_body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - first_reader, - &info, - 5, - "req-materialize-first", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "materialized-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("materialize-fill handoff should succeed"); - - // ODC-15: the fill is detached from the response path, so wait for it to - // populate the cache before the follow-up GET to keep the hit deterministic. - wait_for_cache_hit(&adapter, "test-bucket", "materialized-object", "etag", 5).await; - - let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - second_reader, - &info, - 5, - "req-materialize-second", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "materialized-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("follow-up cache hit should succeed"); - - assert_eq!( - first_reads.load(AtomicOrdering::Relaxed), - 2, - "materialize-fill path should read the source stream once to data and once for EOF" - ); - assert_eq!( - second_reads.load(AtomicOrdering::Relaxed), - 0, - "cache hit after materialize-fill must not read from the fallback reader" - ); - } - - // ODC-07: a materialize read that yields more than the declared content - // length must be a hard error, not a warn-and-serve, matching the - // direct-memory GET path. The bounded `take` reads one byte past capacity so - // the over-long stream is detected without buffering it unbounded. - #[tokio::test] - async fn build_get_object_body_with_cache_materialize_rejects_length_mismatch() { - let reads = Arc::new(AtomicUsize::new(0)); - // Declared content length is 5, but the stream yields 6 bytes. - let reader = DataProbeReader { - reads: Arc::clone(&reads), - data: std::io::Cursor::new(b"hello!".to_vec()), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let result = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-materialize-mismatch", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "mismatch-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await; - - assert!( - result.is_err(), - "an over-long materialize read must be a hard error, not a truncated served body" - ); - } - - // #1324: a materialize-fill read that ends short of the declared content - // length (clean EOF at N-1 for a declared N) must hard-fail, matching the - // over-long case above. Reverting to warn-and-serve would return Ok with a - // truncated body. - #[tokio::test] - async fn build_get_object_body_with_cache_materialize_rejects_short_read() { - let reads = Arc::new(AtomicUsize::new(0)); - // Declared content length is 5, but the stream only yields 4 bytes. - let reader = DataProbeReader { - reads: Arc::clone(&reads), - data: std::io::Cursor::new(b"hell".to_vec()), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let result = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-materialize-short", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "short-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await; - - assert!( - result.is_err(), - "a short materialize read must be a hard error, not a truncated served body" - ); - } - - // #1324: a materialize-fill read that fails after draining K bytes must - // propagate the read error and must NOT fall back to streaming the same - // (partially consumed) reader, which would ship a prefix-misaligned body. - #[tokio::test] - async fn build_get_object_body_with_cache_materialize_rejects_partial_read_error() { - let reader = ErrAfterReader { - data: std::io::Cursor::new(b"hello".to_vec()), - fail_after: 3, - emitted: 0, - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let result = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-materialize-partial", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "partial-read-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await; - - assert!( - result.is_err(), - "a partial-read error during materialization must fail the request, not stream a prefix-misaligned body" - ); - } - - // #1324: the buffered-body (direct-memory / cache-served) source must also - // enforce the exact-length contract. A buffered body shorter than the - // declared content length is a hard error before headers. - #[tokio::test] - async fn build_get_object_body_rejects_short_buffered_body() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - ..Default::default() - }; - - let result = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 5, - "req-short-buffered-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - // Declared length 5 but only 4 buffered bytes. - Some(Bytes::from_static(b"hell")), - "test-bucket", - "short-buffered-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await; - - assert!(result.is_err(), "a buffered body shorter than the declared content length must hard-fail"); - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "the mismatch must be caught without touching the fallback reader" - ); - } - - // #1324 compatibility boundary: a legacy/backfilled object whose decoded - // bytes exactly equal its declared content length must still serve cleanly. - // The strict contract keys off actual-vs-declared equality only, so it never - // flips a legitimate exact-length object into a hard failure — it only - // rejects genuine short/over-long/errored reads. - #[tokio::test] - async fn build_get_object_body_serves_exact_length_buffered_body() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 5, - "req-exact-buffered-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"hello")), - "test-bucket", - "exact-buffered-object", - GetObjectBodyLifecycle::disabled(), - |_| panic!("an exact-length buffered body must not initialize streaming resume state"), - ) - .await - .expect("an exact-length buffered body must serve without error"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "an exact-length buffered body must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_skips_materialize_when_too_large_for_cache() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = DataProbeReader { - reads: Arc::clone(&reads), - data: std::io::Cursor::new(b"hello".to_vec()), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - max_entry_bytes: 4, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-materialize-too-large", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "too-large-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("too-large cache candidate should use streaming fallback"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "too-large materialize-fill candidate must not pre-read the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_keeps_small_plain_objects_on_streaming_path_by_default() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 4, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 4, - "req-small-plain-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - "test-bucket", - "small-plain-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("build_get_object_body should keep small plain object on streaming path"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "default GetObject response construction should not pre-read small plain object data" - ); - } - - #[test] - fn select_stream_buffer_strategy_expands_large_sequential_gets() { - let (buffer_size, strategy) = - DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, false); - - assert_eq!(strategy, GetObjectStreamStrategy::LargeSequentialReadahead); - assert_eq!(buffer_size, 4 * MI_B); - } - - #[test] - fn select_stream_buffer_strategy_keeps_ranges_and_small_gets_standard() { - let (range_buffer_size, range_strategy) = - DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, true); - assert_eq!(range_strategy, GetObjectStreamStrategy::Standard); - assert_eq!(range_buffer_size, 2 * MI_B); - - let (small_buffer_size, small_strategy) = - DefaultObjectUsecase::select_stream_buffer_strategy(64 * 1024 * 1024, 512 * 1024, true, false); - assert_eq!(small_strategy, GetObjectStreamStrategy::Standard); - assert_eq!(small_buffer_size, 512 * 1024); - } - - #[test] - fn tune_reader_stream_buffer_size_raises_large_standard_streams_only() { - assert_eq!( - tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), - LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES - ); - assert_eq!( - tune_reader_stream_buffer_size(512 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), - LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES - ); - assert_eq!( - tune_reader_stream_buffer_size(2 * MI_B, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), - 2 * MI_B - ); - assert_eq!( - tune_reader_stream_buffer_size(128 * 1024, MI_B as i64, GetObjectStreamStrategy::Standard), - MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES - ); - assert_eq!( - tune_reader_stream_buffer_size(256 * 1024, 2 * MI_B as i64, GetObjectStreamStrategy::Standard), - MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES - ); - assert_eq!( - tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::LargeSequentialReadahead), - 128 * 1024 - ); - } - - #[test] - fn resolve_reader_stream_buffer_size_keeps_selected_default() { - let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, None); - - assert_eq!(buffer_size, 128 * 1024); - assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED); - } - - #[test] - fn resolve_reader_stream_buffer_size_applies_positive_override() { - let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(MI_B)); - - assert_eq!(buffer_size, MI_B); - assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE); - } - - #[test] - fn resolve_reader_stream_buffer_size_ignores_zero_override() { - let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(0)); - - assert_eq!(buffer_size, 128 * 1024); - assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED); - } - - #[test] - fn should_use_zero_copy_rejects_encrypted_requests_with_sse_customer_algorithm() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, HeaderValue::from_static("AES256")); - - assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_encrypted_requests_with_kms_key_id() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, HeaderValue::from_static("test-kms-key-id")); - - assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_compressible_content_types() { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json; charset=utf-8")); - - assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn should_use_small_eager_put_path_allows_up_to_1mb() { - let headers = HeaderMap::new(); - - assert!(should_use_small_eager_put_path(1024, &headers, false, false, false)); - assert!(should_use_small_eager_put_path(1024 * 1024, &headers, false, false, false)); - assert!(!should_use_small_eager_put_path(1024 * 1024 + 1, &headers, false, false, false)); - } - - #[test] - fn should_use_small_eager_put_path_rejects_sse_requests() { - let headers = HeaderMap::new(); - - assert!(!should_use_small_eager_put_path(1024, &headers, true, false, false)); - } - - #[test] - fn should_use_small_eager_put_path_rejects_compressible_objects() { - let headers = HeaderMap::new(); - - assert!(!should_use_small_eager_put_path(1024, &headers, false, true, false)); - } - - #[test] - fn should_use_small_eager_put_path_rejects_extract_requests() { - let headers = HeaderMap::new(); - - assert!(!should_use_small_eager_put_path(1024, &headers, false, false, true)); - } - - #[test] - fn should_use_small_eager_put_path_rejects_large_or_empty_objects() { - let headers = HeaderMap::new(); - - assert!(!should_use_small_eager_put_path(0, &headers, false, false, false)); - assert!(!should_use_small_eager_put_path(1024 * 1024 + 1, &headers, false, false, false)); - } - - #[test] - fn should_use_zero_copy_eager_put_path_allows_large_plain_objects_within_cap() { - let headers = HeaderMap::new(); - - assert!(should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, false, false)); - assert!(should_use_zero_copy_eager_put_path(16 * 1024 * 1024, &headers, false, false, false)); - assert!(!should_use_zero_copy_eager_put_path(16 * 1024 * 1024 + 1, &headers, false, false, false)); - assert_eq!( - zero_copy_eager_put_path_status(16 * 1024 * 1024, &headers, false, false, false), - PUT_EAGER_STATUS_ELIGIBLE - ); - assert_eq!( - zero_copy_eager_put_path_status(16 * 1024 * 1024 + 1, &headers, false, false, false), - PUT_EAGER_STATUS_ABOVE_EAGER_MAX - ); - } - - #[test] - fn zero_copy_eager_put_path_status_honors_configured_cap() { - let headers = HeaderMap::new(); - let max_size = 64 * 1024 * 1024; - - assert_eq!( - zero_copy_eager_put_path_status_with_max_size(33 * 1024 * 1024, &headers, false, false, false, max_size), - PUT_EAGER_STATUS_ELIGIBLE - ); - assert_eq!( - zero_copy_eager_put_path_status_with_max_size(65 * 1024 * 1024, &headers, false, false, false, max_size), - PUT_EAGER_STATUS_ABOVE_EAGER_MAX - ); - } - - #[test] - fn should_use_zero_copy_eager_put_path_rejects_compression_sse_and_extract() { - let headers = HeaderMap::new(); - - assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, true, false, false)); - assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, true, false)); - assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, false, true)); - assert_eq!( - zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, true, false, false), - PUT_EAGER_STATUS_ENCRYPTED - ); - assert_eq!( - zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, false, true, false), - PUT_EAGER_STATUS_COMPRESSED - ); - assert_eq!( - zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, false, false, true), - PUT_EAGER_STATUS_EXTRACT - ); - } - - #[test] - fn s3s_body_error_to_io_preserves_upload_stream_error_source() { - let error = s3s_body_error_to_io(Box::new(MockUploadStreamSha256Mismatch)); - - assert!(matches!( - error - .get_ref() - .and_then(|source| source.downcast_ref::()), - Some(MockUploadStreamSha256Mismatch) - )); - } - - #[tokio::test] - async fn read_small_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { - let body = StreamReader::new(futures::stream::iter(vec![Err::(s3s_body_error_to_io(Box::new( - MockUploadStreamSha256Mismatch, - )))])); - - let error = read_small_put_body_exact_direct(body, 1) - .await - .expect_err("SHA256 mismatch should reject the small PUT body"); - - assert_eq!(error.code(), &S3ErrorCode::BadDigest); - } - - #[tokio::test] - async fn read_zero_copy_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { - let body = futures::stream::iter(vec![Err::(MockUploadStreamSha256Mismatch)]); - - let error = match read_zero_copy_put_body_exact(body, 1).await { - Ok(_) => panic!("SHA256 mismatch should reject the zero-copy PUT body"), - Err(error) => error, - }; - - assert_eq!(error.code(), &S3ErrorCode::BadDigest); - } - - struct FragmentedBody { - data: std::io::Cursor>, - } - - impl AsyncRead for FragmentedBody { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let position = usize::try_from(self.data.position()).expect("test cursor position should fit usize"); - let remaining = &self.data.get_ref()[position..]; - let copied = remaining.len().min(buf.remaining()).min(2); - buf.put_slice(&remaining[..copied]); - self.data - .set_position(u64::try_from(position + copied).expect("test cursor position should fit u64")); - Poll::Ready(Ok(())) - } - } - - struct InitializedLengthProbe { - data: std::io::Cursor>, - initialized_lengths: Arc>>, - } - - impl AsyncRead for InitializedLengthProbe { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - self.initialized_lengths - .lock() - .expect("initialized-length probe lock should not poison") - .push(buf.initialized().len()); - let position = usize::try_from(self.data.position()).expect("test cursor position should fit usize"); - let remaining = &self.data.get_ref()[position..]; - let copied = remaining.len().min(buf.remaining()); - buf.put_slice(&remaining[..copied]); - self.data - .set_position(u64::try_from(position + copied).expect("test cursor position should fit u64")); - Poll::Ready(Ok(())) - } - } - - #[tokio::test] - async fn read_small_put_body_exact_pooled_reads_exact_bytes_without_prefill() { - let pool = get_concurrency_manager().bytes_pool(); - let initialized_lengths = Arc::new(Mutex::new(Vec::new())); - let body = InitializedLengthProbe { - data: std::io::Cursor::new(b"hello".to_vec()), - initialized_lengths: Arc::clone(&initialized_lengths), - }; - - let buffer = read_small_put_body_exact_pooled(body, 5, pool.as_ref()) - .await - .expect("pooled exact read should succeed"); - - assert_eq!(&buffer[..5], b"hello"); - assert_eq!(buffer.len(), 5); - assert_eq!( - initialized_lengths - .lock() - .expect("initialized-length probe lock should not poison")[0], - 0, - "the first pooled body read must use uninitialized spare capacity rather than a zero-filled slice" - ); - } - - #[tokio::test] - async fn read_small_put_body_exact_pooled_rejects_short_body() { - let pool = get_concurrency_manager().bytes_pool(); - let body = std::io::Cursor::new(b"hell".to_vec()); - - let err = match read_small_put_body_exact_pooled(body, 5, pool.as_ref()).await { - Ok(_) => panic!("short pooled body should fail"), - Err(err) => err, - }; - - assert_eq!(err.code(), &S3ErrorCode::IncompleteBody); - } - - #[tokio::test] - async fn read_small_put_body_exact_pooled_rejects_extra_body() { - let pool = get_concurrency_manager().bytes_pool(); - let body = std::io::Cursor::new(b"hello!".to_vec()); - - let err = match read_small_put_body_exact_pooled(body, 5, pool.as_ref()).await { - Ok(_) => panic!("extra pooled body should fail"), - Err(err) => err, - }; - - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_reads_exact_bytes_without_prefill() { - let body = std::io::Cursor::new(b"hello".to_vec()); - let reader = read_small_put_body_exact_direct(body, 5) - .await - .expect("direct exact read should succeed"); - - assert_eq!(reader.get_ref().as_slice(), b"hello"); - assert_eq!(reader.get_ref().len(), 5); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_rejects_short_and_extra_bodies() { - let short = read_small_put_body_exact_direct(std::io::Cursor::new(b"hell".to_vec()), 5) - .await - .expect_err("short direct body should fail"); - assert_eq!(short.code(), &S3ErrorCode::IncompleteBody); - - let extra = read_small_put_body_exact_direct(std::io::Cursor::new(b"hello!".to_vec()), 5) - .await - .expect_err("extra direct body should fail"); - assert_eq!(extra.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_handles_empty_body_boundary() { - let empty = read_small_put_body_exact_direct(std::io::Cursor::new(Vec::::new()), 0) - .await - .expect("empty direct body should succeed"); - assert!(empty.get_ref().is_empty()); - - let extra = read_small_put_body_exact_direct(std::io::Cursor::new(vec![1u8]), 0) - .await - .expect_err("non-empty body declared as empty should fail"); - assert_eq!(extra.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_rejects_error_after_partial_read() { - struct PartialThenError { - delivered_prefix: bool, - } - - impl AsyncRead for PartialThenError { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - if self.delivered_prefix { - return Poll::Ready(Err(std::io::Error::other("body read failed"))); - } - - self.delivered_prefix = true; - buf.put_slice(b"he"); - Poll::Ready(Ok(())) - } - } - - let err = read_small_put_body_exact_direct(PartialThenError { delivered_prefix: false }, 5) - .await - .expect_err("a partial body followed by an I/O error must fail"); - - assert_eq!(err.code(), &S3ErrorCode::InternalError); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_accepts_fragmented_body() { - let reader = read_small_put_body_exact_direct( - FragmentedBody { - data: std::io::Cursor::new(b"hello".to_vec()), - }, - 5, - ) - .await - .expect("a fragmented exact-length body should succeed"); - - assert_eq!(reader.get_ref().as_slice(), b"hello"); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_rejects_fragmented_extra_body() { - let err = read_small_put_body_exact_direct( - FragmentedBody { - data: std::io::Cursor::new(b"hello!".to_vec()), - }, - 5, - ) - .await - .expect_err("a fragmented body longer than declared must fail"); - - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_reads_into_uninitialized_spare_capacity() { - let initialized_lengths = Arc::new(Mutex::new(Vec::new())); - let body = InitializedLengthProbe { - data: std::io::Cursor::new(b"hello".to_vec()), - initialized_lengths: Arc::clone(&initialized_lengths), - }; - - let reader = read_small_put_body_exact_direct(body, 5) - .await - .expect("direct exact read should succeed"); - - assert_eq!(reader.get_ref().as_slice(), b"hello"); - assert_eq!( - initialized_lengths - .lock() - .expect("initialized-length probe lock should not poison")[0], - 0, - "the first body read must use uninitialized spare capacity rather than a zero-filled slice" - ); - } - - #[tokio::test] - async fn read_zero_copy_put_body_exact_reads_chunked_body() { - use tokio::io::AsyncReadExt; - - let body = futures::stream::iter(vec![ - Ok::(Bytes::from_static(b"hello ")), - Ok::(Bytes::from_static(b"world")), - ]); - - let mut reader = read_zero_copy_put_body_exact(body, 11) - .await - .expect("zero-copy eager body read should succeed"); - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect("chunked bytes reader should be readable"); - - assert_eq!(out, b"hello world"); - } - - #[tokio::test] - async fn read_zero_copy_put_body_exact_rejects_extra_bytes() { - let body = futures::stream::iter(vec![ - Ok::(Bytes::from_static(b"hello")), - Ok::(Bytes::from_static(b"!")), - ]); - - let err = match read_zero_copy_put_body_exact(body, 5).await { - Ok(_) => panic!("extra bytes should fail"), - Err(err) => err, - }; - - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn get_object_reader_stream_tracks_remaining_length() { - let mut stream = GetObjectReaderStream::new( - std::io::Cursor::new(b"hello".to_vec()), - 2, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - assert_eq!(stream.remaining_length().exact(), Some(5)); - - let first = stream - .next() - .await - .expect("reader stream should emit first chunk") - .expect("first chunk should read"); - - assert_eq!(first.as_ref(), b"he"); - assert_eq!(stream.remaining_length().exact(), Some(3)); - } - - #[tokio::test] - async fn get_object_reader_stream_truncates_to_expected_length() { - let stream = GetObjectReaderStream::new( - std::io::Cursor::new(b"hello!".to_vec()), - 64, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - let chunks = stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect("reader stream should read"); - let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| { - acc.extend_from_slice(&chunk); - acc - }); - - assert_eq!(body, b"hello"); - } - - #[tokio::test] - async fn get_object_reader_stream_bounds_read_buffer_to_remaining() { - struct RecordingReader { - data: &'static [u8], - pos: usize, - observed_remaining: Arc>>, - } - - impl AsyncRead for RecordingReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let requested = buf.remaining(); - self.observed_remaining - .lock() - .expect("observed buffer sizes should not poison") - .push(requested); - let available = self.data.len().saturating_sub(self.pos); - let to_copy = requested.min(available); - if to_copy > 0 { - let end = self.pos + to_copy; - buf.put_slice(&self.data[self.pos..end]); - self.pos = end; - } - Poll::Ready(Ok(())) - } - } - - let observed_remaining = Arc::new(Mutex::new(Vec::new())); - let stream = GetObjectReaderStream::new( - RecordingReader { - data: b"hello", - pos: 0, - observed_remaining: Arc::clone(&observed_remaining), - }, - 64, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - let chunks = stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect("reader stream should read exact payload"); - assert_eq!(chunks, vec![Bytes::from_static(b"hello")]); - assert_eq!( - *observed_remaining.lock().expect("observed buffer sizes should not poison"), - vec![5], - "stream should not ask the reader for more bytes than the response has left" - ); - } - - #[tokio::test] - async fn get_object_reader_stream_bounds_multi_chunk_final_read() { - let stream = GetObjectReaderStream::new( - std::io::Cursor::new(vec![b'a'; 66]), - 64, - 65, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - let chunks = stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect("reader stream should ignore bytes past declared length"); - let chunk_lengths = chunks.iter().map(Bytes::len).collect::>(); - let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| { - acc.extend_from_slice(&chunk); - acc - }); - - assert_eq!(chunk_lengths, vec![64, 1]); - assert_eq!(body, vec![b'a'; 65]); - } - - // Serial with the capture test below: both drive the same short-EOF log - // callsite, and `tracing` caches callsite interest process-wide. Running - // this one concurrently on a thread with no subscriber re-caches that - // callsite as "never interested" and blinds the capture. - #[tokio::test] - #[serial_test::serial] - async fn get_object_reader_stream_errors_on_short_eof() { - let stream = GetObjectReaderStream::new( - std::io::Cursor::new(b"he".to_vec()), - 64, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - let err = stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect_err("short reader should fail the streaming body"); - - assert_eq!( - err.downcast_ref::().map(std::io::Error::kind), - Some(std::io::ErrorKind::UnexpectedEof) - ); - } - - /// Collects the structured fields of every event emitted while installed, - /// so a test can assert what an operator would actually read in the log - /// rather than only that an error value was returned. - type CapturedFieldMap = std::collections::HashMap; - type CapturedEventLog = Arc>>; - - struct CapturedEvents(CapturedEventLog); - - struct CapturedFields(CapturedFieldMap); - - impl tracing::field::Visit for CapturedFields { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - self.0.insert(field.name().to_string(), format!("{value:?}")); - } - - fn record_str(&mut self, field: &tracing::field::Field, value: &str) { - self.0.insert(field.name().to_string(), value.to_string()); - } - } - - impl tracing_subscriber::Layer for CapturedEvents { - fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { - let mut fields = CapturedFields(CapturedFieldMap::new()); - event.record(&mut fields); - self.0.lock().expect("captured events should not poison").push(fields.0); - } - } - - fn capture_events() -> (CapturedEventLog, tracing::subscriber::DefaultGuard) { - use tracing_subscriber::{Registry, prelude::*}; - - let captured = Arc::new(Mutex::new(Vec::new())); - let subscriber = Registry::default().with(CapturedEvents(Arc::clone(&captured))); - let guard = tracing::subscriber::set_default(subscriber); - // `tracing` caches per-callsite interest process-wide, so a subscriber - // installed by a test running in parallel can leave the log sites below - // cached as "never interested" and this capture would silently see - // nothing. Force the callsites to re-ask the subscriber we just - // installed. - tracing::callsite::rebuild_interest_cache(); - (captured, guard) - } - - fn find_stream_body_event(captured: &CapturedEventLog, state: &str) -> CapturedFieldMap { - let events = captured.lock().expect("captured events should not poison"); - events - .iter() - .find(|fields| fields.get("state").is_some_and(|value| value == state)) - .unwrap_or_else(|| { - panic!( - "a `{state}` streaming body failure must be logged, not only counted in a metric. \ - Captured {} event(s): {:?}", - events.len(), - events - ) - }) - .clone() - } - - /// rustfs#4784: a GET body that ends short of its committed Content-Length - /// is the fault that breaks every downstream copier (replication, site - /// replication, `rclone sync`), yet this layer only fed a metric counter — - /// its log line was compiled out unless the `tracing-chunk-debug` feature - /// was on, so operators saw nothing on the source side. - #[tokio::test] - #[serial_test::serial] - async fn get_object_reader_stream_short_eof_names_the_object() { - let (captured, _guard) = capture_events(); - - let stream = GetObjectReaderStream::new( - std::io::Cursor::new(b"he".to_vec()), - 64, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ) - .with_diagnostics("restic-paperless", "index/41b5a4c2344edb90", "req-reader-stream-short-eof"); - - stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect_err("short reader should fail the streaming body"); - - let event = find_stream_body_event(&captured, "reader_stream_short_eof"); - assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless")); - assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90")); - assert_eq!(event.get("request_id").map(String::as_str), Some("req-reader-stream-short-eof")); - assert_eq!(event.get("expected").map(String::as_str), Some("5")); - assert_eq!(event.get("emitted").map(String::as_str), Some("2")); - assert_eq!(event.get("remaining").map(String::as_str), Some("3")); - } - - /// The inner reader already logged mid-stream failures, but only under a - /// request_id — which cannot be resolved back to an object once the request - /// is gone. Without the identity the report in #4784 was unactionable. - #[tokio::test] - #[serial_test::serial] - async fn get_object_streaming_reader_short_eof_names_the_object() { - use tokio::io::AsyncReadExt; - - let (captured, _guard) = capture_events(); - - let mut reader = GetObjectStreamingReader::new( - std::io::Cursor::new(b"short".to_vec()), - "restic-paperless", - "index/41b5a4c2344edb90", - "req-streaming-short-eof", - None, - 10, - Duration::ZERO, - GetObjectBodyLifecycle::tracked(GetObjectGuard::new()), - None, - ); - - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect_err("short body under a larger Content-Length must fail the stream"); - - let event = find_stream_body_event(&captured, "short_eof"); - assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless")); - assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90")); - assert_eq!(event.get("request_id").map(String::as_str), Some("req-streaming-short-eof")); - } - - #[test] - fn get_object_stream_failure_labels_are_low_cardinality() { - assert_eq!(get_object_stream_failure_reason("short_eof"), GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF); - assert_eq!( - get_object_stream_failure_reason("timeout"), - GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR - ); - assert_eq!( - get_object_stream_size_bucket(4 * 1024 * 1024), - rustfs_io_metrics::GET_OBJECT_SIZE_BUCKET_GT_1_MIB - ); - } - - #[tokio::test] - async fn disk_read_permit_reader_releases_permit_at_eof() { - use tokio::io::AsyncReadExt; - - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - let permit = semaphore.clone().acquire_owned().await.expect("acquire permit"); - assert_eq!(semaphore.available_permits(), 0); - - let mut reader = DiskReadPermitReader::new(std::io::Cursor::new(b"hello".to_vec()), permit.into()); - let mut body = Vec::new(); - reader.read_to_end(&mut body).await.expect("read body"); - assert_eq!(body, b"hello"); - - // The reader is still alive (client hasn't dropped the body), but EOF - // was observed, so the permit must already be back in the semaphore. - assert_eq!(semaphore.available_permits(), 1); - drop(reader); - assert_eq!(semaphore.available_permits(), 1); - } - - #[tokio::test] - async fn pooled_buffer_reader_keeps_buffer_alive_until_consumed() { - use tokio::io::AsyncReadExt; - - let pool = get_concurrency_manager().bytes_pool(); - let body = std::io::Cursor::new(b"hello".to_vec()); - let buffer = read_small_put_body_exact_pooled(body, 5, pool.as_ref()) - .await - .expect("pooled exact read should succeed"); - let mut reader = PooledBufferReader::new(buffer, 5); - let mut out = Vec::new(); - - reader.read_to_end(&mut out).await.expect("pooled reader should be readable"); - - assert_eq!(out, b"hello"); - } - - #[test] - fn should_use_zero_copy_allows_large_unencrypted_binary_objects() { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")); - - assert!(should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn resolve_put_object_extract_options_defaults_when_headers_missing() { - let headers = HeaderMap::new(); - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!( - options, - PutObjectExtractOptions { - prefix: None, - ignore_dirs: false, - ignore_errors: false - } - ); - } - - #[test] - fn resolve_put_object_extract_options_accepts_internal_headers() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_PREFIX_INTERNAL, HeaderValue::from_static("/internal/prefix/")); - headers.insert(AMZ_SNOWBALL_IGNORE_DIRS_INTERNAL, HeaderValue::from_static("true")); - headers.insert(AMZ_SNOWBALL_IGNORE_ERRORS_INTERNAL, HeaderValue::from_static("TRUE")); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!(options.prefix.as_deref(), Some("internal/prefix")); - assert!(options.ignore_dirs); - assert!(options.ignore_errors); - } - - #[test] - fn resolve_put_object_extract_options_accepts_standard_headers() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static(" /standard/prefix/ ")); - headers.insert(AMZ_SNOWBALL_IGNORE_DIRS, HeaderValue::from_static(" true ")); - headers.insert(AMZ_SNOWBALL_IGNORE_ERRORS, HeaderValue::from_static("TRUE")); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!(options.prefix.as_deref(), Some("standard/prefix")); - assert!(options.ignore_dirs); - assert!(options.ignore_errors); - } - - #[test] - fn resolve_put_object_extract_options_accepts_suffix_compatible_headers() { - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_static("x-amz-meta-acme-snowball-prefix"), - HeaderValue::from_static(" /partner/import "), - ); - headers.insert( - HeaderName::from_static("x-amz-meta-acme-snowball-ignore-dirs"), - HeaderValue::from_static(" true "), - ); - headers.insert( - HeaderName::from_static("x-amz-meta-acme-snowball-ignore-errors"), - HeaderValue::from_static("TRUE"), - ); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!(options.prefix.as_deref(), Some("partner/import")); - assert!(options.ignore_dirs); - assert!(options.ignore_errors); - } - - #[test] - fn resolve_put_object_extract_options_prefers_exact_headers_over_suffix_fallback() { - let mut headers = HeaderMap::new(); - headers.insert("x-amz-meta-acme-snowball-prefix", HeaderValue::from_static("/fallback/prefix/")); - headers.insert(AMZ_RUSTFS_SNOWBALL_PREFIX, HeaderValue::from_static("/internal/prefix/")); - headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static("/standard/prefix/")); - headers.insert(AMZ_MINIO_SNOWBALL_PREFIX, HeaderValue::from_static("/minio/prefix/")); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!(options.prefix.as_deref(), Some("minio/prefix")); - } - - #[test] - fn resolve_put_object_extract_options_exact_flags_override_suffix_fallback() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_IGNORE_DIRS, HeaderValue::from_static("false")); - headers.insert("x-amz-meta-acme-snowball-ignore-dirs", HeaderValue::from_static("true")); - headers.insert(AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, HeaderValue::from_static("false")); - headers.insert("x-amz-meta-acme-snowball-ignore-errors", HeaderValue::from_static("true")); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert!(!options.ignore_dirs); - assert!(!options.ignore_errors); - } - - #[test] - fn resolve_put_object_extract_options_rejects_unsafe_prefix_header() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static("../victim-bucket")); - - assert!(resolve_put_object_extract_options(&headers).is_err()); - } - - #[test] - fn validate_put_object_extract_entry_count_rejects_limit_overflow() { - let limits = ArchiveLimits { - max_entries: 1, - ..ArchiveLimits::default() - }; - - let err = validate_put_object_extract_entry_count(2, limits).unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn validate_put_object_extract_entry_size_rejects_oversized_entry() { - let limits = ArchiveLimits { - max_entry_size: 8, - ..ArchiveLimits::default() - }; - - let err = validate_put_object_extract_entry_size("payload.bin", 9, limits).unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn validate_put_object_extract_total_size_rejects_cumulative_overflow() { - let limits = ArchiveLimits { - max_total_unpacked_size: 16, - ..ArchiveLimits::default() - }; - - let err = validate_put_object_extract_total_size(17, limits).unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn validate_put_object_extract_entry_path_rejects_overlong_path() { - let limits = ArchiveLimits { - max_path_length: 8, - ..ArchiveLimits::default() - }; - - let err = validate_put_object_extract_entry_path("toolong-path", limits).unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_put_object_rejects_post_object_sse_kms_from_input() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .server_side_encryption(Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS))) - .build() - .unwrap(); - - let mut req = build_request(input, Method::POST); - req.extensions.insert(PostObjectRequestMarker); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_put_object_rejects_extract_sse_kms() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("archive.tar".to_string()) - .server_side_encryption(Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS))) - .build() - .unwrap(); - - let mut req = build_request(input, Method::PUT); - req.headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_put_object_extract_rejects_invalid_storage_class() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("archive.tar".to_string()) - .storage_class(Some(StorageClass::from_static("INVALID"))) - .build() - .unwrap(); - - let mut req = build_request(input, Method::PUT); - req.headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); - } - - #[tokio::test] - async fn execute_put_object_rejects_post_object_sse_kms_from_headers() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let mut req = build_request(input, Method::POST); - req.extensions.insert(PostObjectRequestMarker); - req.headers - .insert(AMZ_SERVER_SIDE_ENCRYPTION, HeaderValue::from_static("aws:kms")); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_put_object_rejects_post_object_sse_kms_key_id_header() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let mut req = build_request(input, Method::POST); - req.extensions.insert(PostObjectRequestMarker); - req.headers - .insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, HeaderValue::from_static("test-kms-key-id")); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_put_object_rejects_invalid_storage_class() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .storage_class(Some(StorageClass::from_static("INVALID-STORAGE-CLASS"))) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); - } - - #[test] - fn response_storage_class_reports_effective_layout_and_preserves_transition_tier() { - let metadata = HashMap::new(); - let standard_info = ObjectInfo { - storage_class: Some(storageclass::STANDARD.to_string()), - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - assert!(response_storage_class(&standard_info, &metadata).is_none()); - - let mut metadata = HashMap::new(); - metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD_IA.to_string()); - let label_only_info = ObjectInfo { - storage_class: Some(storageclass::STANDARD_IA.to_string()), - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - assert!( - response_storage_class(&label_only_info, &metadata).is_none(), - "historical STANDARD_IA labels must report the effective implicit STANDARD layout" - ); - - let rrs_info = ObjectInfo { - storage_class: Some(storageclass::RRS.to_string()), - ..Default::default() - }; - assert_eq!( - response_storage_class(&rrs_info, &HashMap::new()) - .as_ref() - .map(StorageClass::as_str), - Some(storageclass::RRS) - ); - - let mut transitioned_info = label_only_info; - transitioned_info.transitioned_object.tier = "WARM-TIER".to_string(); - assert!( - response_storage_class(&transitioned_info, &metadata).is_none(), - "a tier name without a completed transition must not override the effective local class" - ); - transitioned_info.transitioned_object.status = rustfs_filemeta::TRANSITION_COMPLETE.to_string(); - assert_eq!( - response_storage_class(&transitioned_info, &metadata) - .as_ref() - .map(StorageClass::as_str), - Some("WARM-TIER") - ); - - let mut metadata = HashMap::new(); - metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD.to_string()); - let standard_metadata_info = ObjectInfo { - storage_class: None, - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - assert!( - response_storage_class(&standard_metadata_info, &metadata).is_none(), - "STANDARD must be omitted even when it only arrives via metadata fallback" - ); - } - - #[test] - fn response_storage_class_for_object_attributes_defaults_to_standard_when_requested() { - let metadata = HashMap::new(); - let info = ObjectInfo { - storage_class: None, - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - - assert_eq!( - response_storage_class_for_object_attributes(&info, &metadata, true) - .as_ref() - .map(StorageClass::as_str), - Some(storageclass::STANDARD) - ); - - let legacy_info = ObjectInfo { - storage_class: Some(storageclass::STANDARD_IA.to_string()), - ..Default::default() - }; - assert_eq!( - response_storage_class_for_object_attributes(&legacy_info, &HashMap::new(), true) - .as_ref() - .map(StorageClass::as_str), - Some(storageclass::STANDARD) - ); - } - - #[test] - fn response_storage_class_for_object_attributes_skips_value_when_not_requested() { - let metadata = HashMap::new(); - let info = ObjectInfo { - storage_class: Some(storageclass::STANDARD_IA.to_string()), - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - - assert!( - response_storage_class_for_object_attributes(&info, &metadata, false).is_none(), - "StorageClass must only be returned when explicitly requested" - ); - } - - #[tokio::test] - async fn build_get_object_output_context_returns_standard_headers() { - let mut metadata = HashMap::new(); - metadata.insert("cache-control".to_string(), "public, max-age=259200".to_string()); - metadata.insert("content-disposition".to_string(), "attachment; filename=\"demo.png\"".to_string()); - - let info = ObjectInfo { - bucket: "test-bucket".to_string(), - name: "path/raw".to_string(), - user_defined: Arc::new(metadata), - ..Default::default() - }; - - let input = GetObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("path/raw".to_string()) - .build() - .unwrap(); - let req = build_request(input, Method::GET); - let usecase = DefaultObjectUsecase::without_context(); - let queue_status = concurrency::IoQueueStatus::default(); - - let context = usecase - .build_get_object_output_context( - &req, - get_concurrency_manager(), - "test-bucket", - "path/raw", - info.clone(), - Some(info), - wrap_reader(tokio::io::empty()), - Some(Bytes::new()), - false, - false, - true, - None, - None, - None, - 0, - None, - "req-output-content-disposition", - None, - None, - None, - None, - false, - Duration::ZERO, - 0.0, - &queue_status, - 1, - None, - false, - GetObjectBodyLifecycle::disabled(), - |_| panic!("a buffered output must not initialize streaming resume state"), - ) - .await - .expect("get object output context"); - - assert_eq!(context.output.cache_control.as_deref(), Some("public, max-age=259200")); - assert_eq!(context.output.content_disposition.as_deref(), Some("attachment; filename=\"demo.png\"")); - assert!( - !context - .output - .metadata - .as_ref() - .is_some_and(|metadata| metadata.contains_key("cache-control")) - ); - assert!( - !context - .output - .metadata - .as_ref() - .is_some_and(|metadata| metadata.contains_key("content-disposition")) - ); - } - - #[tokio::test] - async fn execute_get_object_rejects_zero_part_number() { - let input = GetObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .part_number(Some(0)) - .build() - .unwrap(); - - let req = build_request(input, Method::GET); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn parse_get_object_part_number_rejects_above_s3_max() { - let err = parse_part_number_i32_to_usize(Some(10001), "GET").expect_err("partNumber above S3 max must fail"); - - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - assert_eq!(err.message(), Some("GET: partNumber must be between 1 and 10000")); - } - - #[test] - fn validate_get_object_part_number_rejects_missing_part() { - let info = ObjectInfo { - parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo { - number: 1, - ..Default::default() - }]), - ..Default::default() - }; - - let err = - DefaultObjectUsecase::validate_get_object_part_number(Some(2), &info).expect_err("missing requested part must fail"); - - assert_eq!(err.code(), &S3ErrorCode::InvalidPart); - assert!(DefaultObjectUsecase::validate_get_object_part_number(Some(1), &info).is_ok()); - } - - #[test] - fn cold_fill_conditions_fail_before_phase_probe_advances() { - fn run_phase_probe(headers: &HeaderMap, info: &ObjectInfo) -> (S3Result<()>, [usize; 3]) { - let coordination = AtomicUsize::new(0); - let permit = AtomicUsize::new(0); - let reader = AtomicUsize::new(0); - let result = DefaultObjectUsecase::validate_get_object_before_cold_fill(headers, None, info); - if result.is_ok() { - coordination.fetch_add(1, AtomicOrdering::Relaxed); - permit.fetch_add(1, AtomicOrdering::Relaxed); - reader.fetch_add(1, AtomicOrdering::Relaxed); - } - ( - result, - [ - coordination.load(AtomicOrdering::Relaxed), - permit.load(AtomicOrdering::Relaxed), - reader.load(AtomicOrdering::Relaxed), - ], - ) - } - - let info = ObjectInfo { - etag: Some("phase-etag".to_string()), - parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo { - number: 1, - ..Default::default() - }]), - ..Default::default() - }; - - let mut not_modified = HeaderMap::new(); - not_modified.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static("\"phase-etag\"")); - let (result, phases) = run_phase_probe(¬_modified, &info); - assert_eq!(result.expect_err("matching If-None-Match must reject").code(), &S3ErrorCode::NotModified); - assert_eq!(phases, [0, 0, 0]); - - let mut precondition_failed = HeaderMap::new(); - precondition_failed.insert(http::header::IF_MATCH, HeaderValue::from_static("\"other-etag\"")); - let (result, phases) = run_phase_probe(&precondition_failed, &info); - assert_eq!( - result.expect_err("mismatched If-Match must reject").code(), - &S3ErrorCode::PreconditionFailed - ); - assert_eq!(phases, [0, 0, 0]); - } - - #[tokio::test] - async fn execute_get_object_rejects_range_with_part_number() { - let input = GetObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .part_number(Some(1)) - .range(Some(Range::Int { first: 0, last: Some(1) })) - .build() - .unwrap(); - - let req = build_request(input, Method::GET); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_copy_object_rejects_self_copy_without_replace_directive() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: None, - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[tokio::test] - async fn execute_copy_object_rejects_invalid_storage_class() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "src-bucket".into(), - key: "src-key".into(), - version_id: None, - }) - .bucket("dst-bucket".to_string()) - .key("dst-key".to_string()) - .storage_class(Some(StorageClass::from_static("INVALID"))) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); - } - - #[tokio::test] - async fn execute_copy_object_allows_self_copy_with_storage_class_change() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: None, - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .storage_class(Some(StorageClass::from_static(storageclass::RRS))) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - // Self-copy with explicit storage class change must pass the self-copy guard. - assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[tokio::test] - #[serial_test::serial] - async fn execute_self_copy_when_object_name_equals_bucket_observes_lock_order() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; - use s3s::access::S3Access as _; - - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let ambient = current_app_context().expect("self-copy lock-order test requires an AppContext"); - let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); - let server_ctx = crate::app::runtime_sources::ServerContextSlot::new(); - assert!(server_ctx.install(Arc::clone(&context))); - let fs = FS::with_server_ctx(server_ctx); - - let bucket = format!("self-copy-lock-order-{}", Uuid::new_v4()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create self-copy test bucket"); - let payload = b"object whose key equals its bucket".to_vec(); - let mut reader = PutObjReader::from_vec(payload.clone()); - let setup_opts = ObjectOptions { - no_lock: true, - ..Default::default() - }; - store - .put_object(&bucket, &bucket, &mut reader, &setup_opts) - .await - .expect("put object whose key equals its bucket"); - - let policy_json = format!( - r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"# - ); - let mut bucket_metadata = (*crate::storage::get_bucket_metadata(&bucket) - .await - .expect("self-copy bucket metadata should be cached")) - .clone(); - bucket_metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("self-copy policy should parse")); - bucket_metadata.policy_config_json = policy_json.into_bytes(); - crate::storage::storage_api::set_bucket_metadata(bucket.clone(), bucket_metadata) - .await - .expect("publish self-copy test policy"); - - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: bucket.clone().into(), - key: bucket.clone().into(), - version_id: None, - }) - .bucket(bucket.clone()) - .key(bucket.clone()) - .metadata_directive(Some(MetadataDirective::from_static(MetadataDirective::REPLACE))) - .metadata(Some(HashMap::from([("lock-order".to_string(), "verified".to_string())]))) - .build() - .expect("self-copy input should build"); - let mut req = build_request(input, Method::PUT); - req.extensions.insert(crate::storage::access::ReqInfo::default()); - fs.copy_object(&mut req) - .await - .expect("authorize self-copy whose object key equals its bucket"); - - let response = tokio::time::timeout( - std::time::Duration::from_secs(30), - DefaultObjectUsecase::with_context(Some(context)).execute_copy_object(req), - ) - .await - .expect("lifecycle, authority, and exact object locks must not deadlock") - .expect("self-copy whose object key equals its bucket should succeed"); - assert!(response.output.copy_object_result.is_some()); - let info = store - .get_object_info(&bucket, &bucket, &ObjectOptions::default()) - .await - .expect("self-copied object should remain readable"); - assert_eq!(info.size, payload.len() as i64); - - store - .delete_bucket( - &bucket, - &DeleteBucketOptions { - force: true, - ..Default::default() - }, - ) - .await - .expect("clean up self-copy test bucket"); - } - - #[tokio::test] - async fn execute_copy_object_allows_tiered_self_copy_with_storage_class_change() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: None, - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .storage_class(Some(StorageClass::from_static(storageclass::STANDARD))) - .metadata_directive(Some(MetadataDirective::from_static(MetadataDirective::REPLACE))) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - // Tiered self-copy with STANDARD storage class must pass all validation checks. - // The call fails at store init (no store in unit tests), not at validation. - assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); - assert_ne!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_copy_object_allows_self_copy_of_historical_version() { - // Restoring a specific historical version onto the current key (same bucket/key with a - // source versionId, default COPY directive) must pass the self-copy guard (issue #4238). - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: Some("11111111-1111-1111-1111-111111111111".into()), - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - // Must not be rejected by the self-copy guard; it fails later at store init instead. - assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[tokio::test] - async fn execute_copy_object_allows_self_copy_of_null_version() { - // A "null" source version id is a restore of the null version, not a no-op self-copy. - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: Some("null".into()), - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[tokio::test] - async fn execute_copy_object_rejects_malformed_copy_source_version_id() { - // A malformed (non-null, non-UUID) source version id is rejected up front. - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "src-bucket".into(), - key: "src-key".into(), - version_id: Some("not-a-uuid".into()), - }) - .bucket("dst-bucket".to_string()) - .key("dst-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_delete_object_rejects_invalid_object_key() { - let input = DeleteObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("bad\0key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::DELETE); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_delete_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn delete_not_found_completes_noop_event_with_version_context() { - temp_env::with_var(rustfs_config::ENV_NOTIFY_ENABLE, Some("true"), || { - crate::server::refresh_notify_module_enabled(); - for (version_id, expected_version) in [(None, ""), (Some("requested-version".to_string()), "requested-version")] { - let input = DeleteObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("missing-key".to_string()) - .version_id(version_id.clone()) - .build() - .expect("delete input should build"); - let mut req = build_request(input, Method::DELETE); - req.extensions.insert(crate::storage::access::ReqInfo { - bucket: Some("test-bucket".to_string()), - object: Some("missing-key".to_string()), - version_id: version_id.clone(), - ..Default::default() - }); - let helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObject); - - let (result, helper) = - complete_delete_noop(helper, "test-bucket".to_string(), "missing-key".to_string(), version_id); - let event = helper.event_args().expect("successful no-op delete should retain an event"); - - assert_eq!(result.expect("no-op delete should succeed").status, Some(StatusCode::NO_CONTENT)); - assert_eq!(event.event_name, EventName::ObjectRemovedNoOP); - assert_eq!(event.bucket_name, "test-bucket"); - assert_eq!(event.object.name, "missing-key"); - assert_eq!(event.version_id, expected_version); - } - }); - crate::server::refresh_notify_module_enabled(); - } - - #[test] - fn expected_current_version_header_normalizes_uuid_and_null() { - let version = Uuid::new_v4(); - let mut headers = HeaderMap::new(); - headers.insert( - RUSTFS_EXPECTED_CURRENT_VERSION_ID, - HeaderValue::from_str(&version.to_string().to_uppercase()).unwrap(), - ); - assert_eq!(expected_current_version_id(&headers).unwrap(), Some(version.to_string())); - - headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_static(" null ")); - assert_eq!(expected_current_version_id(&headers).unwrap(), Some(Uuid::nil().to_string())); - } - - #[test] - fn expected_current_version_header_rejects_empty_and_malformed_values() { - for value in ["", "not-a-version"] { - let mut headers = HeaderMap::new(); - headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(value).unwrap()); - assert_eq!(expected_current_version_id(&headers).unwrap_err().code(), &S3ErrorCode::InvalidArgument); - } - } - - #[tokio::test] - async fn execute_copy_object_rejects_expected_version_for_different_destination() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "source-key".into(), - version_id: Some(Uuid::new_v4().to_string().into()), - }) - .bucket("test-bucket".to_string()) - .key("destination-key".to_string()) - .build() - .unwrap(); - let mut req = build_request(input, Method::PUT); - req.headers.insert( - RUSTFS_EXPECTED_CURRENT_VERSION_ID, - HeaderValue::from_str(&Uuid::new_v4().to_string()).unwrap(), - ); - - let err = Box::pin(DefaultObjectUsecase::without_context().execute_copy_object(req)) - .await - .unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[test] - fn undo_delete_requires_version_id_to_match_expected_current() { - let expected = Uuid::new_v4().to_string(); - assert!(validate_undo_delete_version(Some(&expected), Some(&expected)).is_ok()); - assert_eq!( - validate_undo_delete_version(Some(&expected), Some(&Uuid::new_v4().to_string())) - .unwrap_err() - .code(), - &S3ErrorCode::PreconditionFailed - ); - assert_eq!( - validate_undo_delete_version(Some(&expected), None).unwrap_err().code(), - &S3ErrorCode::PreconditionFailed - ); - assert!(validate_undo_delete_version(None, None).is_ok()); - } - - #[tokio::test] - async fn execute_delete_objects_rejects_empty_object_list() { - let input = DeleteObjectsInput::builder() - .bucket("test-bucket".to_string()) - .delete(Delete { - objects: vec![], - quiet: None, - }) - .build() - .unwrap(); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_delete_objects(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_delete_objects_rejects_more_than_one_thousand_objects_before_store_lookup() { - let objects = (0..1001) - .map(|idx| ObjectIdentifier { - key: format!("test-key-{idx}"), - version_id: None, - ..Default::default() - }) - .collect(); - let input = DeleteObjectsInput::builder() - .bucket("test-bucket".to_string()) - .delete(Delete { objects, quiet: None }) - .build() - .expect("delete objects input should build"); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_delete_objects(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_delete_objects_returns_internal_error_when_store_uninitialized() { - let input = DeleteObjectsInput::builder() - .bucket("test-bucket".to_string()) - .delete(Delete { - objects: vec![ObjectIdentifier { - key: "test-key".to_string(), - version_id: None, - ..Default::default() - }], - quiet: None, - }) - .build() - .unwrap(); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_delete_objects(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InternalError); - assert_eq!(err.message(), Some("Not init")); - } - - #[tokio::test] - #[serial_test::serial] - async fn execute_delete_objects_rejects_bucket_recreated_after_authorization() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; - - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let context = current_app_context().expect("delete objects generation test requires an AppContext"); - let bucket = format!("delete-objects-generation-{}", Uuid::new_v4()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create authorized bucket generation"); - let mut reader = PutObjReader::from_vec(b"old generation".to_vec()); - store - .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) - .await - .expect("put old-generation object"); - - let policy_json = format!( - r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:DeleteObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"# - ); - let mut metadata = (*crate::storage::get_bucket_metadata(&bucket) - .await - .expect("authorized bucket metadata should be cached")) - .clone(); - metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse")); - metadata.policy_config_json = policy_json.into_bytes(); - crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata) - .await - .expect("publish test bucket policy"); - - let input = DeleteObjectsInput::builder() - .bucket(bucket.clone()) - .delete(Delete { - objects: vec![ObjectIdentifier { - key: "object".to_string(), - version_id: None, - ..Default::default() - }], - quiet: None, - }) - .build() - .expect("delete objects input should build"); - let mut req = build_request(input, Method::POST); - req.extensions.insert(crate::storage::access::ReqInfo::default()); - let loaded = Arc::new(tokio::sync::Barrier::new(2)); - let resume = Arc::new(tokio::sync::Barrier::new(2)); - install_delete_objects_auth_test_hook(bucket.clone(), Arc::clone(&loaded), Arc::clone(&resume)); - - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let delete = tokio::spawn(async move { usecase.execute_delete_objects(req).await }); - loaded.wait().await; - - store - .delete_bucket( - &bucket, - &DeleteBucketOptions { - force: true, - ..Default::default() - }, - ) - .await - .expect("delete authorized bucket generation"); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("recreate same bucket name"); - let mut reader = PutObjReader::from_vec(b"new generation".to_vec()); - store - .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) - .await - .expect("put new-generation object"); - resume.wait().await; - - let err = delete - .await - .expect("delete objects task should join") - .expect_err("old authorization must not delete from the recreated bucket"); - assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket); - store - .get_object_info(&bucket, "object", &ObjectOptions::default()) - .await - .expect("new-generation object must survive the stale batch request"); - } - - #[tokio::test] - async fn execute_delete_object_allows_non_force_request_without_req_info_until_store_lookup() { - let input = DeleteObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let err = DefaultObjectUsecase::without_context() - .execute_delete_object(build_request(input, Method::DELETE)) - .await - .expect_err("an uninitialized store should be reported after non-force admission"); - assert_eq!(err.code(), &S3ErrorCode::InternalError); - assert_eq!(err.message(), Some("Not init")); - } - - #[test] - fn delete_objects_audit_details_include_only_successful_request_entries() { - let requested = vec![ - ObjectIdentifier { - key: "first-key".to_string(), - version_id: None, - ..Default::default() - }, - ObjectIdentifier { - key: "denied-key".to_string(), - version_id: Some(Uuid::new_v4().to_string()), - ..Default::default() - }, - ObjectIdentifier { - key: "versioned-key".to_string(), - version_id: Some("requested-version".to_string()), - ..Default::default() - }, - ]; - - let objects = successful_delete_audit_objects( - &Delete { - objects: requested, - quiet: Some(true), - }, - [true, false, true], - ); - - assert_eq!( - objects, - vec![ - AuditObjectVersion::new("first-key".to_string(), None), - AuditObjectVersion::new("versioned-key".to_string(), Some("requested-version".to_string())), - ] - ); - } - - #[test] - fn delete_objects_audit_details_are_empty_when_every_entry_fails() { - let requested = vec![ObjectIdentifier { - key: "failed-key".to_string(), - version_id: None, - ..Default::default() - }]; - - assert!( - successful_delete_audit_objects( - &Delete { - objects: requested, - quiet: None, - }, - [false] - ) - .is_empty() - ); - } - - #[test] - fn normalize_delete_objects_version_id_preserves_explicit_null_marker() { - let (wire_version_id, internal_version_id) = - normalize_delete_objects_version_id(Some("null".to_string())).expect("null version marker should parse"); - - assert_eq!(wire_version_id.as_deref(), Some("null")); - assert_eq!(internal_version_id, Some(Uuid::nil())); - - let (wire_version_id, internal_version_id) = - normalize_delete_objects_version_id(Some(" \t ".to_string())).expect("empty version marker should normalize"); - assert_eq!(wire_version_id, None); - assert_eq!(internal_version_id, None); - } - - #[test] - fn delete_objects_treats_raw_io_not_found_as_idempotent() { - assert!(is_delete_objects_not_found(&StorageError::FileNotFound)); - assert!(is_delete_objects_not_found(&StorageError::Io(std::io::Error::from( - std::io::ErrorKind::NotFound, - )))); - assert!(!is_delete_objects_not_found(&StorageError::Io(std::io::Error::from( - std::io::ErrorKind::PermissionDenied, - )))); - assert!(!is_delete_objects_not_found(&StorageError::DiskNotFound)); - } - - #[test] - fn delete_objects_result_reducer_reports_raw_not_found_as_deleted() { - let object = ObjectToDelete { - object_name: "missing-key".to_string(), - ..Default::default() - }; - let deleted = StorageDeletedObject { - object_name: object.object_name.clone(), - ..Default::default() - }; - let error = StorageError::Io(std::io::Error::from(std::io::ErrorKind::NotFound)); - - let deleted = reduce_delete_objects_result(&object, &deleted, Some(&error), false) - .expect("raw not-found must produce a deleted result"); - assert_eq!(deleted.object_name, "missing-key"); - } - - #[test] - fn recursive_force_delete_requires_administrative_or_replica_context() { - let mut headers = HeaderMap::new(); - headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); - - assert!(!recursive_force_delete_is_authorized(&headers, false, false)); - assert!(recursive_force_delete_is_authorized(&headers, true, false)); - assert!(recursive_force_delete_is_authorized(&headers, false, true)); - assert!(recursive_force_delete_is_authorized(&HeaderMap::new(), false, false)); - } - - #[tokio::test] - async fn execute_delete_object_rejects_untrusted_force_delete_before_store_access() { - let input = DeleteObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("prefix/object".to_string()) - .build() - .unwrap(); - let mut req = build_request(input, Method::DELETE); - req.headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); - req.extensions.insert(crate::storage::access::ReqInfo::default()); - - let err = DefaultObjectUsecase::without_context() - .execute_delete_object(req) - .await - .expect_err("untrusted force-delete must be rejected before storage lookup"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - #[tokio::test] - async fn execute_delete_objects_rejects_untrusted_force_delete_before_store_access() { - let input = DeleteObjectsInput::builder() - .bucket("test-bucket".to_string()) - .delete(Delete { - objects: vec![ObjectIdentifier { - key: "prefix/object".to_string(), - version_id: None, - ..Default::default() - }], - quiet: None, - }) - .build() - .unwrap(); - let mut req = build_request(input, Method::POST); - req.headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); - req.extensions.insert(crate::storage::access::ReqInfo::default()); - - let err = DefaultObjectUsecase::without_context() - .execute_delete_objects(req) - .await - .expect_err("untrusted force-delete must be rejected before storage lookup"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - // backlog#929 (HP-8): the pre-delete stat may only be skipped when every - // consumer of its result is provably idle. Each guard flips one condition - // to prove the skip is fenced on all four data dependencies. - fn delete_marker_creating_opts() -> ObjectOptions { - ObjectOptions { - version_id: None, - versioned: true, - version_suspended: false, - ..Default::default() - } - } - - #[test] - fn delete_objects_pre_stat_skippable_for_delete_marker_on_plain_bucket() { - assert!(can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), true)); - } - - #[test] - fn delete_objects_pre_stat_kept_for_object_lock_buckets() { - assert!(!can_skip_delete_objects_pre_stat(true, &delete_marker_creating_opts(), true)); - } - - #[test] - fn delete_objects_pre_stat_kept_for_explicit_version_deletes() { - let opts = ObjectOptions { - version_id: Some(Uuid::new_v4().to_string()), - versioned: true, - version_suspended: false, - ..Default::default() - }; - assert!(!can_skip_delete_objects_pre_stat(false, &opts, true)); - } - - #[test] - fn delete_objects_pre_stat_kept_for_unversioned_buckets() { - // Unversioned deletes remove the current object: usage accounting needs - // the object size and ILM tier cleanup needs the transition metadata. - let opts = ObjectOptions { - version_id: None, - versioned: false, - version_suspended: false, - ..Default::default() - }; - assert!(!can_skip_delete_objects_pre_stat(false, &opts, false)); - } - - #[test] - fn delete_objects_pre_stat_kept_for_suspended_versioning() { - let opts = ObjectOptions { - version_id: None, - versioned: true, - version_suspended: true, - ..Default::default() - }; - assert!(!can_skip_delete_objects_pre_stat(false, &opts, false)); - } - - #[test] - fn delete_objects_pre_stat_kept_when_accounting_snapshot_disagrees() { - // If the accounting-side versioning snapshot does not also classify the - // delete as a delete-marker creation, the stat must stay so usage - // accounting keeps its size input. - assert!(!can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), false)); - } - - #[test] - fn delete_accounting_recognizes_explicit_null_as_current_object() { - let opts = ObjectOptions { - version_id: Some(Uuid::nil().to_string()), - version_suspended: true, - ..Default::default() - }; - assert!(delete_removes_current_object(&opts)); - assert!(delete_request_targets_current(Some(Uuid::nil()))); - assert!(!delete_request_targets_current(Some(Uuid::new_v4()))); - assert!(!delete_removes_current_object(&ObjectOptions { - version_id: Some(Uuid::new_v4().to_string()), - ..Default::default() - })); - } - - #[test] - fn compressed_object_delete_restores_usage_baseline() { - let mut metadata = HashMap::new(); - insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string()); - let object = ObjectInfo { - size: 400, - actual_size: 1000, - user_defined: Arc::new(metadata), - ..Default::default() - }; - let accounting_size = quota_object_size(&object).expect("logical compressed size should be canonical"); - - assert_eq!( - delete_memory_update(false, false, true, Some(accounting_size), true), - Some(DeleteMemoryUpdate::Object { - size: 1000, - removed_current_object: true, - }) - ); - } - - #[test] - fn invalid_accounting_metadata_is_reconciled_without_overflow() { - assert_eq!(delete_memory_update(false, false, true, None, true), None); - assert_eq!( - delete_memory_update(false, true, true, None, true), - Some(DeleteMemoryUpdate::DeleteMarker) - ); - } - - #[tokio::test] - #[serial_test::serial] - async fn compressed_delete_requests_update_observed_usage_without_releasing_quota_floor() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; - use crate::app::storage_api::test::data_usage::apply_bucket_usage_memory_overlay; - - async fn observed_bucket_usage(bucket: &str) -> Option { - let mut usage = rustfs_data_usage::DataUsageInfo::default(); - apply_bucket_usage_memory_overlay(&mut usage).await; - usage.buckets_usage.get(bucket).map(|value| value.size) - } - - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let bucket = format!("compressed-delete-request-{}", Uuid::new_v4().simple()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create compressed delete request bucket"); - - // Seed the process-local usage with the canonical logical bytes. The - // direct storage PUT below intentionally does not apply an app-layer - // usage delta; the two real DELETE requests must remove exactly this - // amount through their request-layer wiring. - crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 2_000).await; - - for object in ["single", "batch"] { - let mut metadata = HashMap::new(); - insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string()); - insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, "1000".to_string()); - let reader = HashReader::from_stream(std::io::Cursor::new(vec![0x5a; 400]), 400, 1000, None, None, false) - .expect("compressed fixture reader should be valid"); - let mut reader = PutObjReader::new(reader); - store - .put_object( - &bucket, - object, - &mut reader, - &ObjectOptions { - user_defined: metadata, - ..Default::default() - }, - ) - .await - .expect("compressed fixture object should be written"); - } - - let mut single_req = build_request( - DeleteObjectInput::builder() - .bucket(bucket.clone()) - .key("single".to_string()) - .build() - .expect("single delete input should build"), - Method::DELETE, - ); - single_req.extensions.insert(crate::storage::access::ReqInfo { - cred: Some(rustfs_credentials::Credentials::default()), - is_owner: true, - ..Default::default() - }); - DefaultObjectUsecase::from_global() - .execute_delete_object(single_req) - .await - .expect("single compressed delete should succeed"); - assert_eq!( - observed_bucket_usage(&bucket).await, - Some(1_000), - "single delete must subtract the logical accounting size" - ); - assert_eq!( - crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await, - Some(2_000), - "quota must retain the pre-delete floor until scanner reconciliation" - ); - - let mut batch_req = build_request( - DeleteObjectsInput::builder() - .bucket(bucket.clone()) - .delete(Delete { - objects: vec![ObjectIdentifier { - key: "batch".to_string(), - ..Default::default() - }], - quiet: None, - }) - .build() - .expect("batch delete input should build"), - Method::POST, - ); - batch_req.extensions.insert(crate::storage::access::ReqInfo { - cred: Some(rustfs_credentials::Credentials::default()), - is_owner: true, - ..Default::default() - }); - DefaultObjectUsecase::from_global() - .execute_delete_objects(batch_req) - .await - .expect("batch compressed delete should succeed"); - assert_eq!( - observed_bucket_usage(&bucket).await, - Some(0), - "batch delete must subtract the committed logical accounting size" - ); - assert_eq!( - crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await, - Some(2_000), - "quota must retain both pending deletes until scanner reconciliation" - ); - - store - .delete_bucket( - &bucket, - &DeleteBucketOptions { - force: true, - ..Default::default() - }, - ) - .await - .expect("clean up compressed delete request bucket"); - } - - #[tokio::test] - async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() { - let input = GetObjectAttributesInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::GET); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_get_object_attributes(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InternalError); - } - - #[test] - fn object_attributes_requested_with_single_value() { - let object_attributes = vec![ObjectAttributes::from_static(ObjectAttributes::ETAG)]; - - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); - assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); - } - - #[test] - fn object_attributes_requested_with_comma_separated_values() { - let object_attributes = vec![ - ObjectAttributes::from_static("ObjectParts,etag"), - ObjectAttributes::from_static("StorageClass"), - ]; - - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS)); - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); - assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); - } - - #[test] - fn object_attributes_requested_with_quotes_and_spaces() { - let object_attributes = vec![ObjectAttributes::from_static("'ObjectSize', \"Checksum\" , \"Etag\"")]; - - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::CHECKSUM)); - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); - } - - #[test] - fn object_attributes_requested_returns_false_for_missing_name() { - let object_attributes = vec![ObjectAttributes::from_static("Checksum")]; - - assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); - } - - #[test] - fn build_put_object_expiration_header_returns_none_for_non_delete_events() { - let event = lifecycle::Event { - action: lifecycle::IlmAction::TransitionAction, - rule_id: "rule-1".to_string(), - due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: String::new(), - }; - - assert!(build_put_object_expiration_header(&event).is_none()); - } - - #[test] - fn build_put_object_expiration_header_formats_expected_value() { - let expire_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); - let event = lifecycle::Event { - action: lifecycle::IlmAction::DeleteAction, - rule_id: "rule-1".to_string(), - due: Some(expire_time), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: String::new(), - }; - - let expiry_date = expire_time.format(&Rfc3339).unwrap(); - let expected = format!("expiry-date=\"{}\", rule-id=\"rule-1\"", expiry_date); - assert_eq!(build_put_object_expiration_header(&event), Some(expected)); - } - - #[test] - fn build_put_object_expiration_header_requires_rule_id_and_due_time() { - let event = lifecycle::Event { - action: lifecycle::IlmAction::DeleteAction, - rule_id: String::new(), - due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: String::new(), - }; - - assert!(build_put_object_expiration_header(&event).is_none()); - - let event = lifecycle::Event { - action: lifecycle::IlmAction::DeleteAction, - rule_id: "rule-1".to_string(), - due: Some(OffsetDateTime::UNIX_EPOCH), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: String::new(), - }; - - assert!(build_put_object_expiration_header(&event).is_none()); - } - - #[tokio::test] - async fn execute_head_object_rejects_range_with_part_number() { - let input = HeadObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .part_number(Some(1)) - .range(Some(Range::Int { first: 0, last: Some(1) })) - .build() - .unwrap(); - - let req = build_request(input, Method::HEAD); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_head_object(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_restore_object_rejects_missing_restore_request() { - let input = RestoreObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_restore_object(req).await.unwrap_err(); - match err.code() { - S3ErrorCode::Custom(code) => assert_eq!(code, "ErrValidRestoreObject"), - code => panic!("unexpected error code: {:?}", code), - } - } - - #[tokio::test] - async fn execute_restore_object_returns_internal_error_when_store_uninitialized() { - let restore_request = RestoreRequest { - days: Some(1), - description: None, - glacier_job_parameters: None, - output_location: None, - select_parameters: None, - tier: None, - type_: None, - }; - let input = RestoreObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .restore_request(Some(restore_request)) - .build() - .unwrap(); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_restore_object(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InternalError); - } - - #[test] - fn delete_replication_state_from_config_tracks_downstream_delete_marker_targets() { - let arn = "arn:aws:s3:::target-bucket".to_string(); - let config = ReplicationConfiguration { - role: arn.clone(), - rules: vec![ReplicationRule { - delete_marker_replication: Some(DeleteMarkerReplication { - status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), - }), - delete_replication: None, - destination: Destination { - bucket: arn.clone(), - ..Default::default() - }, - existing_object_replication: Some(ExistingObjectReplication { - status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), - }), - filter: None, - id: Some("rule-1".to_string()), - prefix: Some("test/".to_string()), - priority: Some(1), - source_selection_criteria: Some(SourceSelectionCriteria { - replica_modifications: Some(ReplicaModifications { - status: ReplicaModificationsStatus::from_static(ReplicaModificationsStatus::ENABLED), - }), - sse_kms_encrypted_objects: None, - }), - status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), - }], - }; - let obj_info = ObjectInfo { - bucket: "bucket".to_string(), - name: "test/object.txt".to_string(), - delete_marker: true, - replication_status: ReplicationStatusType::Replica, - ..Default::default() - }; - - let state = delete_replication_state_from_config(&config, &obj_info, None, true) - .expect("replica delete marker should be forwarded to downstream targets"); - let pending = format!("{arn}=PENDING;"); - - assert_eq!(state.replication_status_internal.as_deref(), Some(pending.as_str())); - assert_eq!(state.replicate_decision_str, format!("{arn}=true;false;{arn};")); - assert!(state.targets.contains_key(&arn)); - } - - #[test] - fn delete_replication_state_from_config_skips_replica_delete_without_replica_modifications() { - let arn = "arn:aws:s3:::target-bucket".to_string(); - let config = ReplicationConfiguration { - role: arn.clone(), - rules: vec![ReplicationRule { - delete_marker_replication: Some(DeleteMarkerReplication { - status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), - }), - delete_replication: None, - destination: Destination { - bucket: arn, - ..Default::default() - }, - existing_object_replication: Some(ExistingObjectReplication { - status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), - }), - filter: None, - id: Some("rule-1".to_string()), - prefix: Some("test/".to_string()), - priority: Some(1), - source_selection_criteria: None, - status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), - }], - }; - let obj_info = ObjectInfo { - bucket: "bucket".to_string(), - name: "test/object.txt".to_string(), - delete_marker: true, - replication_status: ReplicationStatusType::Replica, - ..Default::default() - }; - - assert!( - delete_replication_state_from_config(&config, &obj_info, None, true).is_none(), - "replica deletes must only fan out when ReplicaModifications are enabled" - ); - } - - #[test] - fn delete_replication_state_from_config_requires_delete_switch_for_marker_version_purges() { - let arn = "arn:aws:s3:::target-bucket".to_string(); - let mut config = ReplicationConfiguration { - role: arn.clone(), - rules: vec![ReplicationRule { - delete_marker_replication: Some(DeleteMarkerReplication { - status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), - }), - delete_replication: None, - destination: Destination { - bucket: arn.clone(), - ..Default::default() - }, - existing_object_replication: Some(ExistingObjectReplication { - status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), - }), - filter: None, - id: Some("rule-1".to_string()), - prefix: Some("test/".to_string()), - priority: Some(1), - source_selection_criteria: None, - status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), - }], - }; - let obj_info = ObjectInfo { - bucket: "bucket".to_string(), - name: "test/object.txt".to_string(), - delete_marker: true, - replication_status: ReplicationStatusType::Completed, - ..Default::default() - }; - - let version_id = Some(Uuid::new_v4()); - assert!( - delete_replication_state_from_config(&config, &obj_info, version_id, false).is_none(), - "delete-marker version purge must not use DeleteMarkerReplication" - ); - - config.rules[0].delete_replication = Some(DeleteReplication { - status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED), - }); - let state = delete_replication_state_from_config(&config, &obj_info, version_id, false) - .expect("delete-marker version purge should honor DeleteReplication"); - let pending = format!("{arn}=PENDING;"); - - assert_eq!(state.version_purge_status_internal.as_deref(), Some(pending.as_str())); - assert_eq!(state.replicate_decision_str, format!("{arn}=true;false;{arn};")); - assert!(state.purge_targets.contains_key(&arn)); - } - - #[test] - fn replica_delete_enrichment_must_not_reuse_upstream_targets() { - let upstream_state = ReplicationState { - replicate_decision_str: "arn:aws:s3:::upstream=true;false;arn:aws:s3:::upstream;".to_string(), - replication_status_internal: Some("arn:aws:s3:::upstream=COMPLETED;".to_string()), - targets: replication_statuses_map("arn:aws:s3:::upstream=COMPLETED;"), - ..Default::default() - }; - let mut delete_object = StorageDeletedObject::default(); - set_deleted_object_replication_state(&mut delete_object, &upstream_state); - let obj_info = ObjectInfo { - replication_status: ReplicationStatusType::Replica, - ..Default::default() - }; - - let should_keep_existing = delete_object.replication_state.as_ref().is_some_and(|state| { - obj_info.replication_status != ReplicationStatusType::Replica - && !state.replicate_decision_str.is_empty() - && (!state.targets.is_empty() || !state.purge_targets.is_empty()) - }); - - assert!( - !should_keep_existing, - "replica fanout deletes must recompute targets from the local bucket config instead of reusing upstream replication state" - ); - } - - #[test] - fn delete_replication_version_id_uses_none_for_delete_marker_creation() { - let source = ObjectInfo { - delete_marker: true, - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - - assert_eq!( - delete_replication_version_id(&source, false), - None, - "delete-marker creation must stay on the delete-marker replication path" - ); - } - - #[test] - fn delete_replication_version_id_keeps_version_for_marker_purge() { - let version_id = Uuid::new_v4(); - let source = ObjectInfo { - delete_marker: true, - version_id: Some(version_id), - ..Default::default() - }; - - assert_eq!( - delete_replication_version_id(&source, true), - Some(version_id), - "delete-marker version purge must preserve the concrete version id for downstream purge replication" - ); - } - - #[test] - fn should_use_existing_delete_replication_info_ignores_replication_delete_marker_creation() { - let opts = ObjectOptions { - version_id: Some(Uuid::new_v4().to_string()), - delete_marker: true, - ..Default::default() - }; - - assert!( - !should_use_existing_delete_replication_info(&opts, true), - "replicated delete-marker creation carries a source version id header but must not be treated as a version purge" - ); - } - - #[test] - fn should_use_existing_delete_replication_info_keeps_version_delete_requests() { - let opts = ObjectOptions { - version_id: Some(Uuid::new_v4().to_string()), - ..Default::default() - }; - - assert!( - should_use_existing_delete_replication_info(&opts, true), - "true version-delete requests should keep using the pre-delete object info" - ); - } - - // -- Range: u64 -> i64 lossless conversion (issue rustfs/backlog#1322) -- - - const I64_MAX_AS_U64: u64 = i64::MAX as u64; - - /// The conversion itself: s3s `Range` (u64) -> internal `HTTPRangeSpec` - /// (i64). This directly guards the suffix truncation fix. Reverting to - /// `length as i64` regresses the zero-suffix, `i64::MAX + 1` and `u64::MAX` - /// rows below. - #[test] - fn range_to_http_range_spec_is_lossless() { - // Zero-length suffix (`bytes=-0`) is unsatisfiable -> InvalidRange (416), - // never a 0-length 206. - let zero_suffix = range_to_http_range_spec(Range::Suffix { length: 0 }); - assert_eq!( - zero_suffix.as_ref().err().map(|e| e.code()), - Some(&S3ErrorCode::InvalidRange), - "bytes=-0 must map to InvalidRange (416)" - ); - - // Suffix conversions: positive `start` holds the suffix length; values - // above i64::MAX clamp to i64::MAX (they always cover the whole object). - let suffix_cases = [ - (1_u64, 1_i64), - (I64_MAX_AS_U64, i64::MAX), - (I64_MAX_AS_U64 + 1, i64::MAX), // was i64::MIN under `as i64` -> checked_neg overflow - (u64::MAX, i64::MAX), // was -1 under `as i64` -> read as "last 1 byte" - ]; - for (length, expected_start) in suffix_cases { - let spec = range_to_http_range_spec(Range::Suffix { length }) - .unwrap_or_else(|_| panic!("suffix {length} must convert losslessly")); - assert!(spec.is_suffix_length, "suffix {length} must stay a suffix spec"); - assert_eq!(spec.start, expected_start, "suffix {length} start"); - assert_eq!(spec.end, -1, "suffix {length} end"); - } - - // Int ranges: s3s already rejects first/last > i64::MAX, so the checked - // cast never truncates. first-last and open-ended must not regress. - let int_first_last = range_to_http_range_spec(Range::Int { - first: 10, - last: Some(20), - }) - .expect("first-last converts"); - assert!(!int_first_last.is_suffix_length); - assert_eq!((int_first_last.start, int_first_last.end), (10, 20)); - - let int_open = range_to_http_range_spec(Range::Int { first: 5, last: None }).expect("open-ended converts"); - assert_eq!((int_open.start, int_open.end), (5, -1)); - - let int_max = range_to_http_range_spec(Range::Int { - first: I64_MAX_AS_U64, - last: Some(I64_MAX_AS_U64), - }) - .expect("i64::MAX int converts"); - assert_eq!((int_max.start, int_max.end), (i64::MAX, i64::MAX)); - } - - /// Observable end-to-end effect the GET/HEAD handlers derive from a range - /// spec: `HTTPRangeSpec::get_offset_length` yields the (offset, length) - /// that becomes `Content-Length` and `Content-Range`, or an error that - /// surfaces as 416. Covers empty / 1-byte / normal objects. - #[test] - fn range_suffix_offset_length_matches_s3_semantics() { - // Expected outcome for a satisfiable range, or `None` for 416. - #[derive(Debug, PartialEq)] - enum Outcome { - /// (offset, content_length, content_range) - Partial(usize, i64, String), - Unsatisfiable, - } - - fn derive(range: Range, size: i64) -> Outcome { - let spec = match range_to_http_range_spec(range) { - Ok(spec) => spec, - Err(_) => return Outcome::Unsatisfiable, - }; - match spec.get_offset_length(size) { - Ok((offset, len)) => { - let content_range = format!("bytes {}-{}/{}", offset, offset as i64 + len - 1, size); - Outcome::Partial(offset, len, content_range) - } - Err(_) => Outcome::Unsatisfiable, - } - } - - let suffix = |length: u64| Range::Suffix { length }; - - // size, range, expected - let normal = 100_i64; - let cases = [ - // Zero suffix is always 416, whatever the size. - (0_i64, suffix(0), Outcome::Unsatisfiable), - (1, suffix(0), Outcome::Unsatisfiable), - (normal, suffix(0), Outcome::Unsatisfiable), - // Suffix within the object returns the trailing bytes. - (normal, suffix(1), Outcome::Partial(99, 1, "bytes 99-99/100".into())), - (normal, suffix(normal as u64), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - // Suffix >= size returns the whole object (never a truncated tail). - (normal, suffix(normal as u64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - (normal, suffix(I64_MAX_AS_U64), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - (normal, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - (normal, suffix(u64::MAX), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - // 1-byte object: any non-zero suffix returns that single byte. - (1, suffix(1), Outcome::Partial(0, 1, "bytes 0-0/1".into())), - (1, suffix(2), Outcome::Partial(0, 1, "bytes 0-0/1".into())), - (1, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 1, "bytes 0-0/1".into())), - (1, suffix(u64::MAX), Outcome::Partial(0, 1, "bytes 0-0/1".into())), - // Normal first-last and open-ended int ranges must not regress. - ( - normal, - Range::Int { - first: 10, - last: Some(19), - }, - Outcome::Partial(10, 10, "bytes 10-19/100".into()), - ), - ( - normal, - Range::Int { first: 90, last: None }, - Outcome::Partial(90, 10, "bytes 90-99/100".into()), - ), - ]; - - for (size, range, expected) in cases { - let got = derive(range, size); - assert_eq!(got, expected, "size={size} range={range:?}"); - } - } - - // https://github.com/rustfs/backlog/issues/1311 — bucket-quota admission must run against the authoritative - // decoded/plain object length, never the aws-chunked wire Content-Length, and must reject negative/unknown lengths. - // https://github.com/rustfs/backlog/issues/1336 — but Content-Encoding: aws-chunked alone is only a declared - // encoding: without a STREAMING-* payload the body is unframed and the wire Content-Length is authoritative. - fn aws_chunked_headers(decoded_len: Option<&str>) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(http::header::CONTENT_ENCODING, HeaderValue::from_static("aws-chunked")); - if let Some(decoded) = decoded_len { - headers.insert( - HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), - HeaderValue::from_str(decoded).unwrap(), - ); - } - headers - } - - fn streaming_headers(decoded_len: Option<&str>) -> HeaderMap { - let mut headers = aws_chunked_headers(decoded_len); - headers.insert( - HeaderName::from_bytes(AMZ_CONTENT_SHA256.as_bytes()).unwrap(), - HeaderValue::from_static("STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), - ); - headers - } - - #[test] - fn authoritative_size_prefers_aws_chunked_decoded_over_wire_content_length() { - // Wire Content-Length (chunk framing) differs from the decoded object length; the decoded length wins. - let headers = streaming_headers(Some("1000")); - let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); - assert_eq!( - size, 1000, - "aws-chunked admission must use the decoded object length, not the framed wire length" - ); - - // A declared-only aws-chunked request that still carries a decoded length behaves the same. - let headers = aws_chunked_headers(Some("1000")); - let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); - assert_eq!(size, 1000); - } - - #[test] - fn authoritative_size_streaming_without_content_encoding_uses_decoded_length() { - // A streaming payload signals framing via x-amz-content-sha256 alone; Content-Encoding is optional. - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_bytes(AMZ_CONTENT_SHA256.as_bytes()).unwrap(), - HeaderValue::from_static("STREAMING-UNSIGNED-PAYLOAD-TRAILER"), - ); - headers.insert( - HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), - HeaderValue::from_static("1000"), - ); - let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); - assert_eq!( - size, 1000, - "a streaming payload without Content-Encoding must still use the decoded length" - ); - } - - #[test] - fn authoritative_size_rejects_framed_body_without_decoded_length() { - // A genuinely framed upload without x-amz-decoded-content-length has no authoritative size; - // the framed wire length must NOT be a fallback. - let headers = streaming_headers(None); - let err = resolve_put_object_authoritative_size(&headers, Some(1088)) - .expect_err("framed upload without decoded length must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - - // ... even when the wire Content-Length is also absent. - let err = - resolve_put_object_authoritative_size(&headers, None).expect_err("framed upload without any length must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[test] - fn authoritative_size_declared_aws_chunked_without_streaming_uses_wire_content_length() { - // backlog#1336: an SDK PUT that merely declares Content-Encoding: aws-chunked (issue #1857 - // clients) has an unframed body and no decoded length; the wire Content-Length is the real - // object size and the request must be admitted, not rejected with UnexpectedContent. - let headers = aws_chunked_headers(None); - let size = resolve_put_object_authoritative_size(&headers, Some(1088)) - .expect("declared-only aws-chunked must fall back to the wire Content-Length"); - assert_eq!(size, 1088); - - // Same for a combined declared encoding (aws-chunked,gzip). - let mut headers = HeaderMap::new(); - headers.insert(http::header::CONTENT_ENCODING, HeaderValue::from_static("aws-chunked,gzip")); - let size = resolve_put_object_authoritative_size(&headers, Some(2048)) - .expect("declared-only aws-chunked,gzip must fall back to the wire Content-Length"); - assert_eq!(size, 2048); - - // Without any length information it is still rejected. - let headers = aws_chunked_headers(None); - let err = resolve_put_object_authoritative_size(&headers, None) - .expect_err("declared-only aws-chunked with no length at all must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[test] - fn authoritative_size_plain_put_uses_content_length() { - let headers = HeaderMap::new(); - let size = resolve_put_object_authoritative_size(&headers, Some(4096)).expect("plain PUT uses Content-Length"); - assert_eq!(size, 4096); - } - - #[test] - fn authoritative_size_plain_put_falls_back_to_decoded_length() { - // Non-chunked request that only surfaced an explicit decoded length. - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), - HeaderValue::from_static("2048"), - ); - let size = resolve_put_object_authoritative_size(&headers, None).expect("decoded length is the fallback"); - assert_eq!(size, 2048); - } - - #[test] - fn authoritative_size_rejects_unknown_length() { - let headers = HeaderMap::new(); - let err = resolve_put_object_authoritative_size(&headers, None).expect_err("no length information must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[test] - fn authoritative_size_rejects_negative_length() { - // A negative decoded length would wrap to an enormous unsigned size for quota/buffer sizing; reject it. - let headers = aws_chunked_headers(Some("-1")); - let err = - resolve_put_object_authoritative_size(&headers, Some(64)).expect_err("negative decoded length must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - - let plain = HeaderMap::new(); - let err = - resolve_put_object_authoritative_size(&plain, Some(-100)).expect_err("negative Content-Length must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[test] - fn authoritative_size_accepts_exact_and_rejects_negative_boundary() { - // Exact zero-length object is admissible (the over-by-1/exact-limit boundary is enforced by the quota checker on this value). - let headers = aws_chunked_headers(Some("0")); - assert_eq!( - resolve_put_object_authoritative_size(&headers, Some(87)).expect("zero-length decoded is valid"), - 0 - ); - } - - fn quota_result(allowed: bool) -> QuotaCheckResult { - QuotaCheckResult { - allowed, - current_usage: Some(1024), - quota_limit: Some(2048), - operation_size: 512, - remaining: Some(512), - uses_durable_reservations: true, - } - } - - #[tokio::test] - #[serial_test::serial] - async fn quota_rejects_ciphertext_replication_before_polling_the_body() { - use std::sync::atomic::{AtomicBool, Ordering}; - - let (_store, bucket) = - crate::app::gating_test_env::durable_quota_test_bucket("ciphertext-replication-early-reject", 4096).await; - let body_polled = Arc::new(AtomicBool::new(false)); - let body_polled_in_stream = Arc::clone(&body_polled); - let body = StreamingBlob::wrap(futures::stream::once(async move { - body_polled_in_stream.store(true, Ordering::Release); - Ok::(Bytes::from_static(b"ciphertext")) - })); - let input = PutObjectInput::builder() - .bucket(bucket) - .key("object".to_string()) - .body(Some(body)) - .content_length(Some(10)) - .build() - .expect("ciphertext replication PUT input should build"); - let mut request = build_request(input, Method::PUT); - insert_header(&mut request.headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true"); - request - .headers - .insert(rustfs_utils::http::REPLICATION_SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256")); - request.extensions.insert(crate::storage::access::ReqInfo { - replication_request_authorized: true, - ..Default::default() - }); - - let err = DefaultObjectUsecase::from_global() - .execute_put_object(&FS::new(), request) - .await - .expect_err("quota-enabled ciphertext replication should fail at ingress"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert!(!body_polled.load(Ordering::Acquire), "rejected ciphertext body must not be consumed"); - } - - #[tokio::test] - #[serial_test::serial] - async fn legacy_quota_rejects_full_put_before_polling_the_body() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - use std::sync::atomic::{AtomicBool, Ordering}; - - const GI_B: u64 = 1024 * 1024 * 1024; - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - let bucket = format!("legacy-quota-{}", Uuid::new_v4().simple()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create legacy quota test bucket"); - crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 4 * GI_B).await; - let metadata_sys = DefaultObjectUsecase::from_global() - .bucket_metadata_sys() - .expect("test app context should expose bucket metadata"); - QuotaChecker::new(metadata_sys) - .set_quota_config( - &bucket, - BucketQuota { - quota: Some(5 * GI_B), - ..Default::default() - }, - ) - .await - .expect("configure legacy quota"); - - let body_polled = Arc::new(AtomicBool::new(false)); - let body_polled_in_stream = Arc::clone(&body_polled); - let body = StreamingBlob::wrap(futures::stream::once(async move { - body_polled_in_stream.store(true, Ordering::Release); - Ok::(Bytes::new()) - })); - let input = PutObjectInput::builder() - .bucket(bucket) - .key("object".to_string()) - .body(Some(body)) - .content_length(Some(i64::try_from(2 * GI_B).expect("test size should fit i64"))) - .build() - .expect("legacy quota PUT input should build"); - - let err = DefaultObjectUsecase::from_global() - .execute_put_object(&FS::new(), build_request(input, Method::PUT)) - .await - .expect_err("4 GiB used plus a 2 GiB PUT must exceed a 5 GiB legacy quota"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert!(!body_polled.load(Ordering::Acquire), "legacy quota rejection must not consume the body"); - } - - #[test] - fn quota_admission_allows_within_limit() { - let result = map_quota_check_outcome("bucket", Ok(quota_result(true))).expect("an allowed result admits the write"); - - assert_eq!(result.current_usage, Some(1024)); - assert_eq!(result.quota_limit, Some(2048)); - assert_eq!(result.operation_size, 512); - assert_eq!(result.remaining, Some(512)); - } - - #[tokio::test] - #[serial_test::serial] - async fn concurrent_puts_share_durable_bucket_quota_reservations() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("concurrent-put-quota", 6000).await; - - let first_opts = ObjectOptions::default(); - let second_opts = ObjectOptions::default(); - let first_store = Arc::clone(&store); - let first_bucket = bucket.clone(); - let first = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x73; 4096]); - first_store.put_object(&first_bucket, "first", &mut reader, &first_opts).await - }); - let second = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x74; 4096]); - store.put_object(&bucket, "second", &mut reader, &second_opts).await - }); - let (first, second) = tokio::join!(first, second); - let first = first.expect("first PUT task should not panic"); - let second = second.expect("second PUT task should not panic"); - - assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); - let denied = first.err().or_else(|| second.err()).expect("one PUT must be denied"); - assert!(matches!( - denied, - StorageError::QuotaExceeded { - current: 4096, - limit: 6000 - } - )); - } - - #[tokio::test] - #[serial_test::serial] - async fn concurrent_within_limit_puts_keep_independent_mutation_fences() { - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("concurrent-fence-quota", 8192).await; - let first_barrier = PutObjectCommitBarrier::install(&bucket, "first", PutObjectCommitPause::BeforeQuotaRename); - let second_barrier = PutObjectCommitBarrier::install(&bucket, "second", PutObjectCommitPause::BeforeQuotaRename); - - let first_store = Arc::clone(&store); - let first_bucket = bucket.clone(); - let first = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x75; 4096]); - first_store - .put_object(&first_bucket, "first", &mut reader, &ObjectOptions::default()) - .await - }); - let second_store = Arc::clone(&store); - let second_bucket = bucket.clone(); - let second = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x76; 4096]); - second_store - .put_object(&second_bucket, "second", &mut reader, &ObjectOptions::default()) - .await - }); - - first_barrier.wait_until_paused().await; - second_barrier.wait_until_paused().await; - first_barrier.release(); - second_barrier.release(); - - first - .await - .expect("first PUT task should not panic") - .expect("first within-limit PUT should commit"); - second - .await - .expect("second PUT task should not panic") - .expect("second within-limit PUT should commit"); - } - - #[tokio::test] - #[serial_test::serial] - async fn put_rejects_rotated_quota_capability_before_rename() { - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("rotated-proof-put-quota", 4096).await; - let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); - let put_store = Arc::clone(&store); - let put_bucket = bucket.clone(); - let put = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x77; 4096]); - put_store - .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) - .await - }); - barrier.wait_until_paused().await; - assert!( - crate::storage::storage_api::ecstore_notification::rotate_cross_pool_fence_fleet_proof_for_test(), - "the gating environment must have a current fleet proof" - ); - barrier.release(); - - let err = put - .await - .expect("PUT task should not panic") - .expect_err("a replaced fleet proof must fence the authoritative rename"); - assert!(matches!( - err, - StorageError::NamespaceLockQuorumUnavailable { - mode: "quota_reservation", - .. - } - )); - store - .get_object_info(&bucket, "object", &ObjectOptions::default()) - .await - .expect_err("proof rotation before rename must leave no committed object"); - } - - #[tokio::test] - #[serial_test::serial] - async fn durable_quota_reclaims_overwrites_and_deleted_bytes() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("quota-delta-reconcile", 4096).await; - - for byte in [0x41, 0x42] { - let mut reader = PutObjReader::from_vec(vec![byte; 4096]); - store - .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) - .await - .expect("same-size overwrite must consume no additional quota"); - } - - store - .delete_object(&bucket, "object", ObjectOptions::default()) - .await - .expect("delete quota-tracked object"); - let mut replacement = PutObjReader::from_vec(vec![0x43; 4096]); - store - .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) - .await - .expect("deleted bytes must be reclaimed before rejecting a replacement"); - - let mut excess = PutObjReader::from_vec(vec![0x44]); - let err = store - .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) - .await - .expect_err("one byte beyond the reclaimed exact quota must be denied"); - assert!(matches!( - err, - StorageError::QuotaExceeded { - current: 4096, - limit: 4096 - } - )); - } - - #[tokio::test] - #[serial_test::serial] - async fn data_movement_put_has_zero_quota_growth() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("data-movement-put-quota", 0).await; - let mut reader = PutObjReader::from_vec(vec![0x79; 4096]); - let stored = store - .put_object( - &bucket, - "object", - &mut reader, - &ObjectOptions { - data_movement: true, - ..Default::default() - }, - ) - .await - .expect("moving an already-accounted object between pools must have zero quota growth"); - assert_eq!(stored.size, 4096); - } - - #[tokio::test] - #[serial_test::serial] - async fn cancelled_put_releases_durable_quota_reservation() { - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("cancelled-put-quota", 4096).await; - - let barrier = PutObjectCommitBarrier::install(&bucket, "cancelled", PutObjectCommitPause::AfterQuotaReservation); - let cancelled_store = Arc::clone(&store); - let cancelled_bucket = bucket.clone(); - let cancelled = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x51; 4096]); - cancelled_store - .put_object(&cancelled_bucket, "cancelled", &mut reader, &ObjectOptions::default()) - .await - }); - barrier.wait_until_paused().await; - cancelled.abort(); - let cancelled_result = cancelled.await; - assert!(cancelled_result.is_err(), "the paused request must be cancelled"); - drop(barrier); - - let mut replacement = PutObjReader::from_vec(vec![0x52; 4096]); - store - .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) - .await - .expect("cancelling before commit must release the complete reservation"); - } - - #[tokio::test] - #[serial_test::serial] - async fn cancelled_put_after_commit_marker_is_reconciled() { - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("cancelled-spawned-put-quota", 4096).await; - let commit_barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); - let first_store = Arc::clone(&store); - let first_bucket = bucket.clone(); - let first = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x53; 4096]); - first_store - .put_object(&first_bucket, "object", &mut reader, &ObjectOptions::default()) - .await - }); - commit_barrier.wait_until_paused().await; - first.abort(); - assert!(first.await.is_err(), "the outer request task must be cancelled"); - drop(commit_barrier); - - store - .get_object_info(&bucket, "object", &ObjectOptions::default()) - .await - .expect_err("cancelling before rename must not commit the object"); - let mut replacement = PutObjReader::from_vec(vec![0x54; 4096]); - store - .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) - .await - .expect("the next admission must reap the abandoned commit marker"); - } - - #[tokio::test] - #[serial_test::serial] - async fn committed_put_survives_quota_ledger_settlement_failure() { - use crate::app::storage_api::test::set_disk::{ - PutObjectCommitBarrier, PutObjectCommitPause, fail_next_quota_ledger_save_for_test, - }; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("settlement-failure-quota", 4096).await; - let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); - let put_store = Arc::clone(&store); - let put_bucket = bucket.clone(); - let put = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x59; 4096]); - put_store - .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) - .await - }); - barrier.wait_until_paused().await; - fail_next_quota_ledger_save_for_test(); - barrier.release(); - put.await - .expect("PUT task should not panic") - .expect("a post-commit ledger failure must not change the successful write result"); - let stored = store - .get_object_info(&bucket, "object", &ObjectOptions::default()) - .await - .expect("the committed object must remain visible"); - assert_eq!(stored.size, 4096); - } - - #[tokio::test] - #[serial_test::serial] - async fn suspended_null_version_overwrite_uses_exact_quota_delta() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("suspended-version-quota", 6200).await; - let mut versioned_reader = PutObjReader::from_vec(vec![0x61; 4096]); - store - .put_object( - &bucket, - "object", - &mut versioned_reader, - &ObjectOptions { - versioned: true, - ..Default::default() - }, - ) - .await - .expect("write UUID version"); - - for (size, byte) in [(1024, 0x62), (2048, 0x63)] { - let mut reader = PutObjReader::from_vec(vec![byte; size]); - store - .put_object( - &bucket, - "object", - &mut reader, - &ObjectOptions { - version_suspended: true, - ..Default::default() - }, - ) - .await - .expect("suspended write should replace only the exact null version"); - } - - let mut excess = PutObjReader::from_vec(vec![0x64; 57]); - let err = store - .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) - .await - .expect_err("UUID plus replacement null version must consume 6144 bytes"); - assert!(matches!( - err, - StorageError::QuotaExceeded { - current: 6144, - limit: 6200 - } - )); - } - - #[tokio::test] - #[serial_test::serial] - async fn durable_quota_reservation_observes_lowered_config_revision() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("lowered-quota-revision", 8192).await; - let mut initial = PutObjReader::from_vec(vec![0x71; 4096]); - store - .put_object(&bucket, "initial", &mut initial, &ObjectOptions::default()) - .await - .expect("write under original quota"); - - let metadata_sys = DefaultObjectUsecase::from_global() - .bucket_metadata_sys() - .expect("test app context should expose bucket metadata"); - QuotaChecker::new(metadata_sys) - .set_quota_config(&bucket, BucketQuota::new(Some(4096))) - .await - .expect("lower bucket quota"); - let mut excess = PutObjReader::from_vec(vec![0x72]); - let err = store - .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) - .await - .expect_err("reservation must not use the stale larger quota revision"); - assert!(matches!( - err, - StorageError::QuotaExceeded { - current: 4096, - limit: 4096 - } - )); - } - - #[tokio::test] - #[serial_test::serial] - async fn quota_enable_waits_for_unlimited_commit() { - use crate::app::storage_api::test::metadata_sys::ConfigWriteLockProbe; - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("quota-config-fence", 8192).await; - let metadata_sys = DefaultObjectUsecase::from_global() - .bucket_metadata_sys() - .expect("test app context should expose bucket metadata"); - QuotaChecker::new(Arc::clone(&metadata_sys)) - .set_quota_config(&bucket, BucketQuota::new(None)) - .await - .expect("clear quota before the fenced write"); - let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::AfterQuotaReservation); - let put_store = Arc::clone(&store); - let put_bucket = bucket.clone(); - let put = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x73; 4096]); - put_store - .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) - .await - }); - barrier.wait_until_paused().await; - - let update_probe = ConfigWriteLockProbe::install(&bucket); - let update_bucket = bucket.clone(); - let update = tokio::spawn(async move { - QuotaChecker::new(metadata_sys) - .set_quota_config(&update_bucket, BucketQuota::new(Some(0))) - .await - }); - update_probe.wait_until_attempted().await; - assert!( - !update.is_finished(), - "quota mutation must wait for the reservation's metadata transaction guard" - ); - - barrier.release(); - put.await - .expect("PUT task should not panic") - .expect("the write linearized before the quota update must commit"); - update - .await - .expect("quota update task should not panic") - .expect("quota update should proceed after commit"); - - let mut excess = PutObjReader::from_vec(vec![0x74]); - let err = store - .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) - .await - .expect_err("writes after the zero-byte quota update must be denied"); - assert!(matches!(err, StorageError::QuotaExceeded { current: 4096, limit: 0 })); - } - - #[test] - fn quota_admission_rejects_over_limit() { - let err = map_quota_check_outcome("bucket", Ok(quota_result(false))).expect_err("an over-limit result rejects the write"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[test] - fn legacy_quota_admission_rejects_already_over_limit() { - let result = QuotaCheckResult { - allowed: true, - current_usage: Some(6), - quota_limit: Some(5), - operation_size: 0, - remaining: Some(0), - uses_durable_reservations: false, - }; - let mut opts = ObjectOptions::default(); - let err = - apply_quota_admission(&mut opts, &result).expect_err("legacy completion must not bypass an already exceeded quota"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[test] - fn quota_admission_fails_closed_on_checker_error() { - // A configured hard quota must never be bypassed by an internal fault: a checker error becomes a retryable ServiceUnavailable, not a silent allow. - let err = map_quota_check_outcome( - "bucket", - Err(QuotaError::InvalidConfig { - reason: "corrupt quota config".to_string(), - }), - ) - .expect_err("a checker fault must fail closed"); - assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable); - } - - #[test] - fn legacy_archive_quota_rejects_cumulative_size_and_overflow() { - let legacy = QuotaCheckResult { - allowed: true, - current_usage: Some(4), - quota_limit: Some(5), - operation_size: 0, - remaining: Some(1), - uses_durable_reservations: false, - }; - assert!(ensure_legacy_archive_size_within_quota(&legacy, 2).is_err()); - assert!(ensure_legacy_archive_size_within_quota(&legacy, 1).is_ok()); - - let maxed = QuotaCheckResult { - current_usage: Some(u64::MAX), - quota_limit: Some(u64::MAX), - ..legacy - }; - assert!(ensure_legacy_archive_size_within_quota(&maxed, 1).is_err()); - } - - #[test] - fn early_quota_filter_rejects_only_an_individually_impossible_object() { - let stale_full_usage = QuotaCheckResult { - allowed: true, - current_usage: Some(4096), - quota_limit: Some(4096), - operation_size: 0, - remaining: Some(0), - uses_durable_reservations: true, - }; - - ensure_object_size_within_quota(&stale_full_usage, 4096) - .expect("commit-time ledger must decide whether stale usage was reclaimed"); - let err = ensure_object_size_within_quota(&stale_full_usage, 4097) - .expect_err("an object larger than the whole quota can never fit"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[test] - fn quota_admission_fails_closed_on_unknown_authoritative_usage() { - let err = map_quota_check_outcome( - "bucket", - Err(QuotaError::UsageUnavailable { - bucket: "bucket".to_string(), - }), - ) - .expect_err("unknown authoritative usage must not admit a quota-controlled write"); - assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable); - } -} +pub use super::object::*; diff --git a/rustfs/src/table_catalog/tests.rs b/rustfs/src/table_catalog/tests.rs index d37d4187b..a4109dda0 100644 --- a/rustfs/src/table_catalog/tests.rs +++ b/rustfs/src/table_catalog/tests.rs @@ -16415,12 +16415,22 @@ fn table_metadata_pointer_json_round_trips() { #[test] fn object_mutation_entrypoints_call_reserved_prefix_guard() { - let source = include_str!("../app/object_usecase.rs"); - let delete_object = source + let source = [ + include_str!("../app/object/mod.rs"), + include_str!("../app/object/shared.rs"), + include_str!("../app/object/extract.rs"), + include_str!("../app/object/put.rs"), + include_str!("../app/object/copy.rs"), + include_str!("../app/object/delete.rs"), + include_str!("../app/object/head.rs"), + include_str!("../app/object/restore.rs"), + ] + .concat(); + let delete_module = include_str!("../app/object/delete.rs"); + let delete_object = delete_module .split_once("pub async fn execute_delete_object") - .and_then(|(_, remainder)| remainder.split_once("pub async fn execute_head_object")) - .map(|(delete_object, _)| delete_object) - .expect("delete object entrypoint should remain in the object usecase"); + .map(|(_, remainder)| remainder.split_once("\nmod tests").map_or(remainder, |(entrypoints, _)| entrypoints)) + .expect("delete object entrypoints should remain in app/object/delete.rs"); for expected in [ "validate_object_key(&key, request_method_name)?;\n validate_table_catalog_object_mutation(&bucket, &key).await?;", diff --git a/scripts/check_architecture_migration_rules.sh b/scripts/check_architecture_migration_rules.sh index bb6309d38..1f6490665 100755 --- a/scripts/check_architecture_migration_rules.sh +++ b/scripts/check_architecture_migration_rules.sh @@ -2166,7 +2166,7 @@ fi ( cd "$ROOT_DIR" - rg -n --with-filename 'crate::app::(?:bucket_usecase|multipart_usecase|object_usecase)|Default(?:Bucket|Multipart|Object)Usecase::from_global\(\)' \ + rg -n --with-filename 'crate::app::(?:bucket_usecase|multipart_usecase|object_usecase|object\b)|Default(?:Bucket|Multipart|Object)Usecase::from_global\(\)' \ rustfs/src/storage/ecfs.rs \ --glob '*.rs' || true ) >"$RUSTFS_STORAGE_ECFS_USECASE_BYPASS_HITS_FILE" @@ -2220,7 +2220,7 @@ fi ( cd "$ROOT_DIR" - rg -n --with-filename 'use crate::storage::\*;' rustfs/src/app --glob '*_usecase.rs' || true + rg -n --with-filename 'use crate::storage::\*;' rustfs/src/app --glob '*_usecase.rs' --glob 'object/*.rs' || true ) >"$RUSTFS_APP_USECASE_STORAGE_WILDCARD_HITS_FILE" if [[ -s "$RUSTFS_APP_USECASE_STORAGE_WILDCARD_HITS_FILE" ]]; then @@ -2238,7 +2238,7 @@ fi ( cd "$ROOT_DIR" - rg -n --with-filename 'crate::storage::s3_api::|use crate::storage::s3_api|super::s3_api::|use super::s3_api' rustfs/src/app --glob '*_usecase.rs' || true + rg -n --with-filename 'crate::storage::s3_api::|use crate::storage::s3_api|super::s3_api::|use super::s3_api' rustfs/src/app --glob '*_usecase.rs' --glob 'object/*.rs' || true ) >"$RUSTFS_APP_USECASE_S3_API_BYPASS_HITS_FILE" if [[ -s "$RUSTFS_APP_USECASE_S3_API_BYPASS_HITS_FILE" ]]; then @@ -2250,13 +2250,13 @@ fi { rg -n --with-filename \ '(use crate::storage::(access|helper|options|request_context|sse|timeout_wrapper|head_prefix|concurrency|ecfs)|crate::storage::sse::EncryptionKeyKind|use crate::storage::\{|use crate::storage::[A-Z])' \ - rustfs/src/app/select_object.rs rustfs/src/app/*_usecase.rs || true + rustfs/src/app/select_object.rs rustfs/src/app/*_usecase.rs rustfs/src/app/object || true rg -n --with-filename \ 'use super::(?:\{[^}]*\b(?:DynReader|HashReader|WriteEncryption|WritePlan|DecryptReader|EncryptReader|HardLimitReader|boxed_reader|wrap_reader|compression_metadata_value|is_disk_compressible|MIN_DISK_COMPRESSIBLE_SIZE|get_lock_acquire_timeout|is_valid_storage_class|StorageError|DiskError|is_all_buckets_not_found|is_err_bucket_not_found|is_err_object_not_found|is_err_version_not_found)\b|(?:object_api_utils::to_s3s_etag|storageclass|StorageError|DiskError|DynReader|HashReader|WriteEncryption|WritePlan|DecryptReader|EncryptReader|HardLimitReader|boxed_reader|wrap_reader|compression_metadata_value|is_disk_compressible|MIN_DISK_COMPRESSIBLE_SIZE|get_lock_acquire_timeout|is_valid_storage_class|is_all_buckets_not_found|is_err_bucket_not_found|is_err_object_not_found|is_err_version_not_found)\b)' \ - rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs || true + rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/object rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs || true rg -n --with-filename \ 'use super::(?:\{[^}]*\b(?:AppObjectLockConfigExt|AppReplicationConfigExt|AppVersioningConfigExt|predict_lifecycle_expiration|validate_restore_request|bucket_target_sys|lifecycle|metadata|metadata_sys|object_lock|policy_sys|quota|replication|tagging|target|utils|versioning_sys|transition_api|ObjectInfo|ObjectOptions)\b|(?:AppObjectLockConfigExt|AppReplicationConfigExt|AppVersioningConfigExt|predict_lifecycle_expiration|validate_restore_request|bucket_target_sys|lifecycle|metadata|metadata_sys|object_lock|policy_sys|quota|replication|tagging|target|utils|versioning_sys|transition_api|ObjectInfo|ObjectOptions)\b)|super::(?:lifecycle|metadata_sys|object_lock|quota|replication|tagging|target|utils|versioning_sys|transition_api)::|super::super::(?:metadata_sys|lifecycle|target)::' \ - rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs rustfs/src/app/capacity_dirty_scope_test.rs rustfs/src/app/context.rs rustfs/src/app/context/handles.rs rustfs/src/app/context/interfaces.rs rustfs/src/app/context/runtime_sources.rs || true + rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/object rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs rustfs/src/app/capacity_dirty_scope_test.rs rustfs/src/app/context.rs rustfs/src/app/context/handles.rs rustfs/src/app/context/interfaces.rs rustfs/src/app/context/runtime_sources.rs || true } ) >"$RUSTFS_APP_USECASE_STORAGE_API_BYPASS_HITS_FILE" diff --git a/scripts/check_logging_guardrails.sh b/scripts/check_logging_guardrails.sh index c506b1990..c41c9c864 100755 --- a/scripts/check_logging_guardrails.sh +++ b/scripts/check_logging_guardrails.sh @@ -1014,7 +1014,7 @@ if rg -n -U '(info|warn)!\(\s*target: "rustfs::heal::manager",[\s\S]{0,1000}"Hea exit 1 fi -if rg -n -U 'info!\([\s\S]{0,1000}"GetObject streaming body resumed from a reopened object read"' rustfs/src/app/object_usecase.rs >/dev/null; then +if rg -n -U 'info!\([\s\S]{0,1000}"GetObject streaming body resumed from a reopened object read"' rustfs/src/app/object >/dev/null; then echo "❌ logging guardrail violation: successful per-object GetObject resume events must stay below INFO" >&2 exit 1 fi @@ -1056,7 +1056,7 @@ trace_hot_spans=( "crates/ecstore/src/core/sets.rs:list_objects_v2" "crates/ecstore/src/set_disk/ops/list.rs:list_objects_v2" "rustfs/src/app/bucket_usecase.rs:execute_list_objects_v2" - "rustfs/src/app/object_usecase.rs:execute_get_object" + "rustfs/src/app/object:execute_get_object" ) for hot_span in "${trace_hot_spans[@]}"; do From 2ebf8bc138ceeb0347a48e53f8ad02a8031ea248 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 22:02:36 +0800 Subject: [PATCH 08/54] refactor(ecstore): drop the client shim, import rustfs-s3-client directly (#6668) * refactor(ecstore): drop the client shim, import rustfs-s3-client directly Completes the migration window opened by the rustfs-s3-client extraction (rustfs/backlog#1842 PR3): every consumer now imports the client crate directly and the crate::client shim is deleted. - All in-crate crate::client:: paths (tier warm backends, tier core, lifecycle tier_sweeper, replication storage boundary, set_disk) now import rustfs_s3_client::* directly; crates/ecstore/src/client/mod.rs and the lib.rs mod client declaration are gone. - The two server-side modules historically misfiled under client/ move to their real homes: object_api_utils.rs to crates/ecstore/src/object_api/ (it builds engine-side object readers/writers), and object_handlers_common.rs to crates/ecstore/src/bucket/lifecycle/ (it is the lifecycle noncurrent-version cleanup helper). The latter now routes its replication calls through the lifecycle replication_sink boundary (schedule_delete wrapper and the sink's ReplicationObjectBridge re-export), as the lifecycle guard requires. - The ecstore public facade drops api::client: object_api_utils is exposed as api::object_api_utils, and the rustfs crate takes admin_handler_utils (AdminError) from rustfs-s3-client directly (new dependency). - Guard updates: the migration guard no longer pins mod client in ecstore's lib.rs or the admin_handler_utils facade module (it pins the new api::object_api_utils facade instead), and the module-lint register follows object_api_utils.rs to its new path. Verification: cargo check -p rustfs-ecstore --all-targets and -p rustfs; cargo fmt --all; tier/transition/lifecycle-focused nextest (626 passed) and the decommission/rebalance/heal families in a filtered run (603 passed; the full-suite parallel run only fails on this machine's known decommission/rebalance baseline flakes, which pass in filtered reruns and fail identically on pristine origin/main); layer/migration/s3s/logging/error-format/doc-path guard scripts all pass. * docs(architecture): record the S3 client extraction and reword invariant 4 (#6669) Closes the documentation step of rustfs/backlog#1842. ARCHITECTURE.md invariant 4 now states the serving-vs-consuming distinction the adversarial ruling asked for: ecstore must not serve HTTP/S3 wire types, while consuming remote S3 endpoints is a legitimate engine capability that lives in the extracted rustfs-s3-client crate. The violation note is updated from the pre-extraction snapshot (58 files, embedded client) to the current ratcheted state (shrink-only S3S_ECSTORE_FILES_BASELINE in scripts/check_s3s_footprint.sh, object_lock converted first), and the crate map gains s3-client. ecstore-module-split-plan.md gets the client-directory entry the plan was missing: a Current Shape row and a completed-extraction section describing the pure-move + shim + direct-import sequence and the re-homing of the two misfiled server-side modules. --- ARCHITECTURE.md | 24 ++++++++++------- Cargo.lock | 1 + crates/ecstore/src/api/mod.rs | 22 ++------------- .../bucket/lifecycle/bucket_lifecycle_ops.rs | 10 +++---- crates/ecstore/src/bucket/lifecycle/mod.rs | 1 + .../lifecycle}/object_handlers_common.rs | 4 +-- .../src/bucket/lifecycle/tier_sweeper.rs | 4 +-- .../replication_storage_boundary.rs | 2 +- crates/ecstore/src/client/mod.rs | 27 ------------------- crates/ecstore/src/lib.rs | 1 - crates/ecstore/src/object_api/mod.rs | 2 ++ .../object_api_utils.rs | 0 crates/ecstore/src/services/tier/test_util.rs | 2 +- crates/ecstore/src/services/tier/tier.rs | 10 +++---- .../src/services/tier/tier_handlers.rs | 2 +- .../src/services/tier/tier_mutation_peer.rs | 2 +- .../ecstore/src/services/tier/warm_backend.rs | 10 +++---- .../src/services/tier/warm_backend_aliyun.rs | 12 ++++----- .../src/services/tier/warm_backend_azure.rs | 12 ++++----- .../src/services/tier/warm_backend_gcs.rs | 10 +++---- .../services/tier/warm_backend_huaweicloud.rs | 12 ++++----- .../src/services/tier/warm_backend_minio.rs | 12 ++++----- .../src/services/tier/warm_backend_r2.rs | 12 ++++----- .../src/services/tier/warm_backend_rustfs.rs | 12 ++++----- .../src/services/tier/warm_backend_s3.rs | 20 +++++++------- .../src/services/tier/warm_backend_tencent.rs | 12 ++++----- .../src/services/tier/warm_backend_wasabi.rs | 18 ++++++------- crates/ecstore/src/set_disk/mod.rs | 3 ++- .../architecture/ecstore-module-split-plan.md | 7 +++++ rustfs/Cargo.toml | 1 + rustfs/src/storage/storage_api.rs | 3 ++- scripts/check_architecture_migration_rules.sh | 7 +++-- scripts/ecstore-module-lint-register.txt | 6 ++--- 33 files changed, 126 insertions(+), 157 deletions(-) rename crates/ecstore/src/{client => bucket/lifecycle}/object_handlers_common.rs (95%) delete mode 100644 crates/ecstore/src/client/mod.rs rename crates/ecstore/src/{client => object_api}/object_api_utils.rs (100%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index db91990d4..ce6b1dad3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -93,7 +93,7 @@ refactors. | Domain | Current workspace crates | Responsibility | |--------|--------------------------|----------------| | Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. | -| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, and I/O pipelines. | +| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. | | Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. | | Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. | | Operations and integration | `audit`, `notify`, `obs`, `targets`, `zip` | Auditing, observability, event delivery, notification targets, and archive support. | @@ -138,15 +138,19 @@ default build (lifecycle: `BackpressureSettings` copy that lingered in io-metrics was removed (rustfs/backlog#1833). -4. **ecstore does not know about HTTP or S3 protocol details.** It operates on - storage-level abstractions (objects, buckets, disks, pools). - - ⚠️ VIOLATED: 58 files under `crates/ecstore/src` reference `s3s` - (`rg -l 's3s' crates/ecstore/src | wc -l`), `crates/ecstore/src/client/` - is a ~9.4K-line embedded S3 HTTP client, and `crates/ecstore/Cargo.toml` - depends on `s3s`, `http`, `hyper`/`hyper-util`/`hyper-rustls`, and - `reqwest`. Target state: the engine's need to act as an S3 client - (tiering, replication targets) is served by an extracted client crate, - and ecstore holds no wire or DTO types. +4. **ecstore does not *serve* HTTP or the S3 wire protocol.** It operates on + storage-level abstractions (objects, buckets, disks, pools) and holds no + wire or DTO types of the serving surface. *Consuming* remote S3-compatible + endpoints (ILM tier warm backends, transition targets) is a legitimate + engine capability, but it lives in the dedicated `rustfs-s3-client` crate + (`crates/s3-client`, extracted from the formerly embedded + `crates/ecstore/src/client/` by rustfs/backlog#1842), not inside ecstore. + - ⚠️ PARTIALLY VIOLATED: serving-side `s3s` references remain in ecstore + (bucket metadata/replication/lifecycle DTOs and error mapping). The + count is ratcheted shrink-only by `scripts/check_s3s_footprint.sh` + (`S3S_ECSTORE_FILES_BASELINE`; the `object_lock` module was converted to + storage-level types as the first ratchet step). Target state: the + baseline reaches zero and ecstore's `Cargo.toml` drops `s3s`. 5. **The `rustfs` binary crate is the only place that wires everything together.** Individual crates should be testable in isolation. diff --git a/Cargo.lock b/Cargo.lock index b2db988dd..63e94b893 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9389,6 +9389,7 @@ dependencies = [ "rustfs-protocols", "rustfs-protos", "rustfs-rio", + "rustfs-s3-client", "rustfs-s3-ops", "rustfs-s3-types", "rustfs-s3select-api", diff --git a/crates/ecstore/src/api/mod.rs b/crates/ecstore/src/api/mod.rs index f8d9ac9d4..558a3a984 100644 --- a/crates/ecstore/src/api/mod.rs +++ b/crates/ecstore/src/api/mod.rs @@ -255,26 +255,8 @@ pub mod capacity { pub use crate::store::utils::is_reserved_or_invalid_bucket; } -pub mod client { - pub mod admin_handler_utils { - pub use crate::client::admin_handler_utils::AdminError; - } - - pub mod api_put_object { - pub use crate::client::api_put_object::{AdvancedPutOptions, PutObjectOptions}; - } - - pub mod object_api_utils { - pub use crate::client::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag}; - } - - pub mod transition_api { - pub use crate::client::transition_api::{ - BucketLookupType, CreateBucketConfiguration, LocationConstraint, ObjectInfo, ObjectMultipartInfo, Options, - PutObjectPartOptions, ReadCloser, ReaderImpl, RequestMetadata, RestoreInfo, SendRequest, TransitionClient, - TransitionCore, UploadInfo, to_object_info, - }; - } +pub mod object_api_utils { + pub use crate::object_api::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag}; } pub mod cluster { diff --git a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs index c3c1f67f0..d0e5cec70 100644 --- a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs +++ b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs @@ -939,7 +939,7 @@ impl ExpiryState { let version_count = u64::try_from(v.versions.len()).unwrap_or(u64::MAX); let trace = LifecycleExpiryTrace::for_batch(&v.bucket, &v.event, &v.src, version_count); trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None); - crate::client::object_handlers_common::delete_object_versions( + crate::bucket::lifecycle::object_handlers_common::delete_object_versions( &api, &v.bucket, &v.versions, @@ -5431,8 +5431,6 @@ mod tests { use crate::bucket::lifecycle::tier_sweeper::Jentry; use crate::bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG}; use crate::bucket::metadata_sys; - #[cfg(feature = "test-util")] - use crate::client::transition_api::ReaderImpl; use crate::disk::endpoint::Endpoint; use crate::disk::{RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE}; use crate::error::{Error, is_err_invalid_upload_id}; @@ -5464,6 +5462,8 @@ mod tests { use rustfs_config::ENV_TRANSITION_WORKERS_ABSOLUTE_MAX; use rustfs_data_usage::TierStats; use rustfs_filemeta::{FileInfo, FileMeta}; + #[cfg(feature = "test-util")] + use rustfs_s3_client::transition_api::ReaderImpl; use rustfs_scanner_contracts::metrics::{IlmAction, global_metrics}; use s3s::dto::{ BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry, @@ -6352,7 +6352,7 @@ mod tests { assert_eq!(err.kind(), std::io::ErrorKind::Other); let admin_err = err .get_ref() - .and_then(|source| source.downcast_ref::()) + .and_then(|source| source.downcast_ref::()) .expect("identity mismatch should retain the typed tier error"); assert_eq!(admin_err.code, crate::services::tier::tier::ERR_TIER_INVALID_CONFIG.code); assert_eq!(new_backend.get_count().await, 0); @@ -11563,7 +11563,7 @@ mod tests { lease .put( "remote/object", - crate::client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")), + rustfs_s3_client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")), 9, ) .await diff --git a/crates/ecstore/src/bucket/lifecycle/mod.rs b/crates/ecstore/src/bucket/lifecycle/mod.rs index 20956ca70..5006dce1b 100644 --- a/crates/ecstore/src/bucket/lifecycle/mod.rs +++ b/crates/ecstore/src/bucket/lifecycle/mod.rs @@ -21,6 +21,7 @@ pub mod evaluator; pub mod manual_transition_job; mod metadata_boundary; pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs}; +mod object_handlers_common; mod object_lock_boundary; pub use self::core as lifecycle; mod replication_sink; diff --git a/crates/ecstore/src/client/object_handlers_common.rs b/crates/ecstore/src/bucket/lifecycle/object_handlers_common.rs similarity index 95% rename from crates/ecstore/src/client/object_handlers_common.rs rename to crates/ecstore/src/bucket/lifecycle/object_handlers_common.rs index fa4739252..95f332e64 100644 --- a/crates/ecstore/src/client/object_handlers_common.rs +++ b/crates/ecstore/src/bucket/lifecycle/object_handlers_common.rs @@ -21,7 +21,7 @@ const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped"; const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed"; use crate::bucket::lifecycle::lifecycle; -use crate::bucket::replication::{ReplicationLifecycleBridge, ReplicationObjectBridge}; +use crate::bucket::lifecycle::replication_sink::{self, ReplicationObjectBridge}; use crate::object_api::ObjectOptions; use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete}; use crate::store::ECStore; @@ -84,7 +84,7 @@ pub async fn delete_object_versions( if deleted_obj.replication_state.is_none() { continue; } - ReplicationLifecycleBridge::schedule_delete(bucket.to_string(), deleted_obj.clone()).await; + replication_sink::schedule_delete(bucket.to_string(), deleted_obj.clone()).await; } for (i, err) in errors.iter().enumerate() { diff --git a/crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs b/crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs index b2786fb18..0d622576d 100644 --- a/crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs +++ b/crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs @@ -22,11 +22,11 @@ use super::runtime_boundary as runtime_sources; use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp; use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts}; use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry; -use crate::client::signer_error::error_chain_contains_signer_header_marker; use crate::object_api::ObjectInfo; use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease}; use crate::storage_api_contracts::lifecycle::TransitionedObject; use crate::store::ECStore; +use rustfs_s3_client::signer_error::error_chain_contains_signer_header_marker; use rustfs_utils::get_env_usize; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -670,7 +670,7 @@ pub(crate) fn transitioned_delete_journal_entry_for_source( #[cfg(test)] mod test { - use crate::client::signer_error::invalid_utf8_header_error; + use rustfs_s3_client::signer_error::invalid_utf8_header_error; use super::{ CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, diff --git a/crates/ecstore/src/bucket/replication/replication_storage_boundary.rs b/crates/ecstore/src/bucket/replication/replication_storage_boundary.rs index 9126f3563..1fa113f9c 100644 --- a/crates/ecstore/src/bucket/replication/replication_storage_boundary.rs +++ b/crates/ecstore/src/bucket/replication/replication_storage_boundary.rs @@ -18,7 +18,6 @@ use tokio_util::sync::CancellationToken; use super::replication_error_boundary::Error; use super::replication_filemeta_boundary::{replication_state_from_filemeta, version_purge_status_from_filemeta}; pub(crate) type ReplicationObjectStore = crate::store::ECStore; -pub(crate) use crate::client::api_get_options::{AdvancedGetOptions, StatObjectOptions}; pub(crate) use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader}; pub(crate) use crate::storage_api_contracts::list::{ ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions, @@ -29,6 +28,7 @@ pub(crate) use crate::storage_api_contracts::object::{ }; pub(crate) use crate::storage_api_contracts::range::HTTPRangeSpec; pub(crate) use rustfs_replication::{DeletedObject as ReplicationDeletedObject, ObjectToDelete as ReplicationObjectToDelete}; +pub(crate) use rustfs_s3_client::api_get_options::{AdvancedGetOptions, StatObjectOptions}; type ListObjectsV2Info = StorageListObjectsV2Info; type ListObjectVersionsInfo = StorageListObjectVersionsInfo; diff --git a/crates/ecstore/src/client/mod.rs b/crates/ecstore/src/client/mod.rs deleted file mode 100644 index de0c2f7a5..000000000 --- a/crates/ecstore/src/client/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -// 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. - -// The S3-consuming client moved to the `rustfs-s3-client` crate -// (rustfs/backlog#1842). This shim keeps `crate::client::*` paths working for -// in-crate consumers during the migration window; it is deleted once every -// consumer imports `rustfs_s3_client` directly. Only the two server-side -// modules below (misfiled here historically) remain as real ecstore code. - -pub use rustfs_s3_client::{ - admin_handler_utils, api_get_options, api_list, api_put_object, api_remove, api_s3_datatypes, credentials, provider_versions, - signer_error, transition_api, -}; - -pub mod object_api_utils; -pub mod object_handlers_common; diff --git a/crates/ecstore/src/lib.rs b/crates/ecstore/src/lib.rs index da4abbd39..bd2f1fa58 100644 --- a/crates/ecstore/src/lib.rs +++ b/crates/ecstore/src/lib.rs @@ -56,7 +56,6 @@ mod storage_api_contracts; mod store; // pub mod checksum; -mod client; mod event; use rustfs_concurrency::WorkloadAdmissionSnapshotProvider; diff --git a/crates/ecstore/src/object_api/mod.rs b/crates/ecstore/src/object_api/mod.rs index 0099047c1..769ade684 100644 --- a/crates/ecstore/src/object_api/mod.rs +++ b/crates/ecstore/src/object_api/mod.rs @@ -14,6 +14,8 @@ // #730: object API readers keep staged compatibility paths during facade migration. +pub mod object_api_utils; + use crate::bucket::metadata_sys::get_versioning_config; use crate::bucket::replication::{ DeleteReplicationConfigSnapshot, ReplicateDecision, ReplicationState, ReplicationStatusType, VersionPurgeStatusType, diff --git a/crates/ecstore/src/client/object_api_utils.rs b/crates/ecstore/src/object_api/object_api_utils.rs similarity index 100% rename from crates/ecstore/src/client/object_api_utils.rs rename to crates/ecstore/src/object_api/object_api_utils.rs diff --git a/crates/ecstore/src/services/tier/test_util.rs b/crates/ecstore/src/services/tier/test_util.rs index d9623684d..3dc112057 100644 --- a/crates/ecstore/src/services/tier/test_util.rs +++ b/crates/ecstore/src/services/tier/test_util.rs @@ -68,7 +68,6 @@ use tokio::io::AsyncReadExt; use tokio::sync::{Mutex, Notify, RwLock}; use uuid::Uuid; -use crate::client::transition_api::{ReadCloser, ReaderImpl}; use crate::disk::endpoint::Endpoint; use crate::disk::format::FormatV3; use crate::disk::{DiskAPI, DiskOption, FORMAT_CONFIG_FILE, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE, new_disk}; @@ -78,6 +77,7 @@ use crate::services::tier::warm_backend::{ TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options, }; use rustfs_filemeta::FileMeta; +use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl}; use rustfs_utils::path::path_join_buf; /// One-shot barrier before rejected transition cleanup resolves its ECStore. diff --git a/crates/ecstore/src/services/tier/tier.rs b/crates/ecstore/src/services/tier/tier.rs index ab5e02823..bac56bd0b 100644 --- a/crates/ecstore/src/services/tier/tier.rs +++ b/crates/ecstore/src/services/tier/tier.rs @@ -47,7 +47,6 @@ use tokio::{ }; use tracing::{debug, error, info, warn}; -use crate::client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities}; use crate::error::{Error, Result, StorageError}; use crate::services::tier::{ tier_admin::TierCreds, @@ -80,6 +79,7 @@ use crate::{ }; use rustfs_filemeta::FileInfo; use rustfs_rio::HashReader; +use rustfs_s3_client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities}; use rustfs_utils::path::{SLASH_SEPARATOR, path_join}; use s3s::S3ErrorCode; @@ -1529,14 +1529,14 @@ impl WarmBackend for SharedWarmBackendProxy { self.0.validate_remote_version_id(remote_version_id) } - async fn put(&self, object: &str, r: crate::client::transition_api::ReaderImpl, length: i64) -> io::Result { + async fn put(&self, object: &str, r: rustfs_s3_client::transition_api::ReaderImpl, length: i64) -> io::Result { self.0.put(object, r, length).await } async fn put_with_meta( &self, object: &str, - r: crate::client::transition_api::ReaderImpl, + r: rustfs_s3_client::transition_api::ReaderImpl, length: i64, meta: HashMap, ) -> io::Result { @@ -1548,7 +1548,7 @@ impl WarmBackend for SharedWarmBackendProxy { object: &str, rv: &str, opts: crate::services::tier::warm_backend::WarmBackendGetOpts, - ) -> io::Result { + ) -> io::Result { self.0.get(object, rv, opts).await } @@ -5778,8 +5778,8 @@ mod tests { // lets us drive `remove`/`verify` through every branch. // --------------------------------------------------------------------- - use crate::client::transition_api::{ReadCloser, ReaderImpl}; use crate::services::tier::warm_backend::{WarmBackend, WarmBackendGetOpts}; + use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl}; fn empty_mgr() -> TierConfigMgr { TierConfigMgr { diff --git a/crates/ecstore/src/services/tier/tier_handlers.rs b/crates/ecstore/src/services/tier/tier_handlers.rs index 07768c7ae..b68d19886 100644 --- a/crates/ecstore/src/services/tier/tier_handlers.rs +++ b/crates/ecstore/src/services/tier/tier_handlers.rs @@ -12,9 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::client::admin_handler_utils::AdminError; use http::status::StatusCode; use lazy_static::lazy_static; +use rustfs_s3_client::admin_handler_utils::AdminError; lazy_static! { pub static ref ERR_TIER_ALREADY_EXISTS: AdminError = AdminError { diff --git a/crates/ecstore/src/services/tier/tier_mutation_peer.rs b/crates/ecstore/src/services/tier/tier_mutation_peer.rs index e0207f070..a7f261e2f 100644 --- a/crates/ecstore/src/services/tier/tier_mutation_peer.rs +++ b/crates/ecstore/src/services/tier/tier_mutation_peer.rs @@ -22,9 +22,9 @@ use super::tier_mutation_intent::{ MAX_TIER_MUTATION_INTENT_SIZE, TierMutationIntent, TierMutationIntentState, advance_tier_mutation_intent_record_idempotent, load_tier_mutation_intent_record, save_tier_mutation_intent_record_if_absent, }; -use crate::client::admin_handler_utils::AdminError; use crate::error::{Error, StorageError}; use crate::store::ECStore; +use rustfs_s3_client::admin_handler_utils::AdminError; pub const MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE: usize = rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE; diff --git a/crates/ecstore/src/services/tier/warm_backend.rs b/crates/ecstore/src/services/tier/warm_backend.rs index 464dd41a9..8e0a59546 100644 --- a/crates/ecstore/src/services/tier/warm_backend.rs +++ b/crates/ecstore/src/services/tier/warm_backend.rs @@ -18,11 +18,6 @@ #![allow(unused_must_use)] #![allow(clippy::all)] -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::{AdvancedPutOptions, PutObjectOptions}, - transition_api::{ReadCloser, ReaderImpl}, -}; use crate::error::is_err_bucket_not_found; use crate::services::tier::{ tier::{ERR_TIER_INVALID_CONFIG, ERR_TIER_TYPE_UNSUPPORTED}, @@ -41,6 +36,11 @@ use crate::services::tier::{ }; use bytes::Bytes; use http::StatusCode; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::{AdvancedPutOptions, PutObjectOptions}, + transition_api::{ReadCloser, ReaderImpl}, +}; use rustfs_utils::http::headers::{ CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _, }; diff --git a/crates/ecstore/src/services/tier/warm_backend_aliyun.rs b/crates/ecstore/src/services/tier/warm_backend_aliyun.rs index ddf283742..410913c14 100644 --- a/crates/ecstore/src/services/tier/warm_backend_aliyun.rs +++ b/crates/ecstore/src/services/tier/warm_backend_aliyun.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierAliyun, warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_azure.rs b/crates/ecstore/src/services/tier/warm_backend_azure.rs index 5dd0b8c67..61b3d2e9e 100644 --- a/crates/ecstore/src/services/tier/warm_backend_azure.rs +++ b/crates/ecstore/src/services/tier/warm_backend_azure.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierAzure, warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_gcs.rs b/crates/ecstore/src/services/tier/warm_backend_gcs.rs index 35aecfb6d..adf6bf11b 100644 --- a/crates/ecstore/src/services/tier/warm_backend_gcs.rs +++ b/crates/ecstore/src/services/tier/warm_backend_gcs.rs @@ -30,15 +30,15 @@ use google_cloud_storage::client::Storage; use google_cloud_storage::client::StorageControl; use std::convert::TryFrom; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - transition_api::{Options, ReadCloser, ReaderImpl}, -}; use crate::services::tier::{ tier_config::TierGCS, warm_backend::{WarmBackend, WarmBackendGetOpts}, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + transition_api::{Options, ReadCloser, ReaderImpl}, +}; use tracing::warn; const _MAX_PART_SIZE: i64 = 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_huaweicloud.rs b/crates/ecstore/src/services/tier/warm_backend_huaweicloud.rs index e33736750..4e0900fd9 100644 --- a/crates/ecstore/src/services/tier/warm_backend_huaweicloud.rs +++ b/crates/ecstore/src/services/tier/warm_backend_huaweicloud.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierHuaweicloud, warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_minio.rs b/crates/ecstore/src/services/tier/warm_backend_minio.rs index a97a01baf..8205a3e56 100644 --- a/crates/ecstore/src/services/tier/warm_backend_minio.rs +++ b/crates/ecstore/src/services/tier/warm_backend_minio.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierMinIO, warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_r2.rs b/crates/ecstore/src/services/tier/warm_backend_r2.rs index cee1e1108..685c3338e 100644 --- a/crates/ecstore/src/services/tier/warm_backend_r2.rs +++ b/crates/ecstore/src/services/tier/warm_backend_r2.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierR2, warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_rustfs.rs b/crates/ecstore/src/services/tier/warm_backend_rustfs.rs index 503f1238e..dc1f4aec5 100644 --- a/crates/ecstore/src/services/tier/warm_backend_rustfs.rs +++ b/crates/ecstore/src/services/tier/warm_backend_rustfs.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierRustFS, warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; const MAX_PARTS_COUNT: i64 = 10000; diff --git a/crates/ecstore/src/services/tier/warm_backend_s3.rs b/crates/ecstore/src/services/tier/warm_backend_s3.rs index 814947987..55a29418c 100644 --- a/crates/ecstore/src/services/tier/warm_backend_s3.rs +++ b/crates/ecstore/src/services/tier/warm_backend_s3.rs @@ -22,7 +22,15 @@ use std::collections::HashMap; use std::sync::Arc; use url::Url; -use crate::client::{ +use crate::services::tier::{ + tier_config::TierS3, + warm_backend::{ + TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts, + build_transition_put_options, + }, +}; +use http::HeaderMap; +use rustfs_s3_client::{ api_get_options::GetObjectOptions, api_list::ListObjectsOptions, api_put_object::PutObjectOptions, @@ -33,14 +41,6 @@ use crate::client::{ transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore}, transition_api::{ReadCloser, ReaderImpl}, }; -use crate::services::tier::{ - tier_config::TierS3, - warm_backend::{ - TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts, - build_transition_put_options, - }, -}; -use http::HeaderMap; use rustfs_utils::egress::validate_outbound_url; use rustfs_utils::path::SLASH_SEPARATOR; use s3s::dto::BucketVersioningStatus; @@ -379,7 +379,7 @@ impl TransitionCandidateVersions { #[cfg(test)] mod tests { use super::*; - use crate::client::api_s3_datatypes::{ListVersionsResult, Version}; + use rustfs_s3_client::api_s3_datatypes::{ListVersionsResult, Version}; #[tokio::test] async fn new_rejects_loopback_endpoint_before_network_setup() { diff --git a/crates/ecstore/src/services/tier/warm_backend_tencent.rs b/crates/ecstore/src/services/tier/warm_backend_tencent.rs index 9b5fa33a9..3d7e856e4 100644 --- a/crates/ecstore/src/services/tier/warm_backend_tencent.rs +++ b/crates/ecstore/src/services/tier/warm_backend_tencent.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierTencent, warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_wasabi.rs b/crates/ecstore/src/services/tier/warm_backend_wasabi.rs index cb367bcdc..2311e6576 100644 --- a/crates/ecstore/src/services/tier/warm_backend_wasabi.rs +++ b/crates/ecstore/src/services/tier/warm_backend_wasabi.rs @@ -21,14 +21,12 @@ use std::{ use s3s::header::{X_AMZ_DELETE_MARKER, X_AMZ_VERSION_ID}; use uuid::Uuid; -use crate::{ - client::transition_api::{BucketLookupType, ReadCloser, ReaderImpl}, - services::tier::{ - tier_config::{TierS3, TierWasabi}, - warm_backend::{WarmBackend, WarmBackendGetOpts}, - warm_backend_s3::WarmBackendS3, - }, +use crate::services::tier::{ + tier_config::{TierS3, TierWasabi}, + warm_backend::{WarmBackend, WarmBackendGetOpts}, + warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::transition_api::{BucketLookupType, ReadCloser, ReaderImpl}; const WASABI_VERSIONING_DRIFT_ERROR: &str = "Wasabi tier bucket versioning changed after configuration"; @@ -184,7 +182,7 @@ impl WarmBackend for WarmBackendWasabi { #[cfg(test)] mod tests { use super::*; - use crate::client::{ + use rustfs_s3_client::{ credentials::{Credentials, SignatureType, Static, Value}, transition_api::{Options, TransitionCore}, }; @@ -221,7 +219,7 @@ mod tests { async fn backend_for_endpoint(endpoint: &str, max_retries: i64) -> WarmBackendWasabi { let client = Arc::new( - crate::client::transition_api::TransitionClient::new( + rustfs_s3_client::transition_api::TransitionClient::new( endpoint, Options { creds: Credentials::new(Static(Value { @@ -653,7 +651,7 @@ mod tests { assert_ne!(read, 0, "connection closed before request headers were received"); request.extend_from_slice(&buffer[..read]); } - let body = vec![b'x'; crate::client::transition_api::MAX_S3_ERROR_RESPONSE_SIZE + 1]; + let body = vec![b'x'; rustfs_s3_client::transition_api::MAX_S3_ERROR_RESPONSE_SIZE + 1]; let head = format!( "HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() diff --git a/crates/ecstore/src/set_disk/mod.rs b/crates/ecstore/src/set_disk/mod.rs index 71fbf5aee..0358ae613 100644 --- a/crates/ecstore/src/set_disk/mod.rs +++ b/crates/ecstore/src/set_disk/mod.rs @@ -55,7 +55,6 @@ use crate::bucket::replication::{ }; use crate::bucket::versioning::VersioningApi; use crate::bucket::versioning_sys::BucketVersioningSys; -use crate::client::{object_api_utils::get_raw_etag, transition_api::ObjectReader, transition_api::ReaderImpl}; use crate::cluster::rpc::heal_bucket_local_on_disks; use crate::data_usage::record_compression_total_memory; use crate::diagnostics::get::{ @@ -88,6 +87,7 @@ use crate::error::{GenericError, ObjectApiError, is_err_object_not_found}; use crate::io_support::bitrot::{create_bitrot_reader, create_bitrot_reader_from_bytes, create_bitrot_writer}; use crate::object_api::ObjectOptions; use crate::object_api::get_object_body_cache_hook; +use crate::object_api::object_api_utils::get_raw_etag; use crate::runtime::instance::{InstanceContext, bootstrap_ctx}; use crate::runtime::sources as runtime_sources; use crate::services::batch_processor::AsyncBatchProcessor; @@ -151,6 +151,7 @@ use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem, Infos}; use rustfs_object_capacity::capacity_scope::{ CapacityScope, CapacityScopeDisk, current_dirty_generation, record_capacity_scope, record_global_dirty_scope, }; +use rustfs_s3_client::transition_api::{ObjectReader, ReaderImpl}; use rustfs_s3_types::EventName; #[cfg(test)] use rustfs_utils::http::SSEC_ALGORITHM_HEADER; diff --git a/docs/architecture/ecstore-module-split-plan.md b/docs/architecture/ecstore-module-split-plan.md index 6d8d7f85a..ef5f12362 100644 --- a/docs/architecture/ecstore-module-split-plan.md +++ b/docs/architecture/ecstore-module-split-plan.md @@ -13,6 +13,7 @@ and rollback steps. | Bucket replication | `crates/ecstore/src/bucket/replication/` | 15,619 lines | Contracts extracted; runtime move pending | | Set disks | `crates/ecstore/src/set_disk/` | state carrier plus operation modules | Keep in ECStore | | Public ECStore facade | `crates/ecstore/src/api/mod.rs` | broad compatibility surface | Shrink only through guarded PRs | +| Embedded S3 client | `crates/s3-client/` (`rustfs-s3-client`) | ~8.4K lines | Extracted (rustfs/backlog#1842) | Measured 2026-08-12: the whole crate is 265 files / ~288K lines (roughly half is inline `#[cfg(test)]` code). The largest single files are `disk/local.rs` @@ -37,6 +38,12 @@ list, multipart, lock, heal, and replication code live in separate modules. The remaining large surface is the shared `SetDisks` state and cross-cutting contracts, not only file layout. +## Completed: S3 Client Extraction (rustfs/backlog#1842) + +`crates/ecstore/src/client/` was a ~8.4K-line hand-written S3 HTTP client the engine uses to *consume* remote S3-compatible endpoints (ILM tier warm backends, transition targets). It was a legitimate engine capability misfiled inside the engine: it pulled `s3s`/`hyper` wire types into ecstore against ARCHITECTURE.md invariant 4, which distinguishes serving the S3 wire protocol (forbidden in ecstore) from consuming it (allowed, but in a dedicated crate). + +The extraction landed as: pure move of the 21 client modules to `crates/s3-client` (`rustfs-s3-client`) with a temporary re-export shim, then direct `rustfs_s3_client::` imports and shim deletion. The two server-side modules historically misfiled under `client/` stayed in ecstore and moved to their real homes: `object_api_utils.rs` under `object_api/`, `object_handlers_common.rs` under `bucket/lifecycle/` (behind the `replication_sink` boundary). The remaining serving-side `s3s` references in ecstore are ratcheted shrink-only by the `S3S_ECSTORE_FILES_BASELINE` counter in `scripts/check_s3s_footprint.sh`; per-module conversions to storage-level types (first: `bucket/object_lock/`) lower the baseline in the same change. + ## Non-Negotiable Rules - Do not split crates in the same PR that moves runtime state or changes diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index 670c4b395..3d7dddf62 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -234,6 +234,7 @@ rustfs-policy = { workspace = true } rustfs-protocols = { workspace = true } rustfs-protos = { workspace = true } rustfs-rio = { workspace = true } +rustfs-s3-client = { workspace = true } rustfs-s3-types = { workspace = true } rustfs-s3-ops = { workspace = true } rustfs-security-governance = { workspace = true } diff --git a/rustfs/src/storage/storage_api.rs b/rustfs/src/storage/storage_api.rs index 3f623eaa6..acab79166 100644 --- a/rustfs/src/storage/storage_api.rs +++ b/rustfs/src/storage/storage_api.rs @@ -416,7 +416,8 @@ pub(crate) mod ecstore_capacity { } pub(crate) mod ecstore_client { - pub(crate) use rustfs_ecstore::api::client::{admin_handler_utils, object_api_utils}; + pub(crate) use rustfs_ecstore::api::object_api_utils; + pub(crate) use rustfs_s3_client::admin_handler_utils; } pub(crate) mod ecstore_compression { diff --git a/scripts/check_architecture_migration_rules.sh b/scripts/check_architecture_migration_rules.sh index 1f6490665..d36cf982f 100755 --- a/scripts/check_architecture_migration_rules.sh +++ b/scripts/check_architecture_migration_rules.sh @@ -595,8 +595,8 @@ require_source_line \ "ECStore bucket metadata public facade explicit module" require_source_line \ "crates/ecstore/src/api/mod.rs" \ - " pub mod admin_handler_utils {" \ - "ECStore client admin handler public facade explicit module" + "pub mod object_api_utils {" \ + "ECStore object-api utils public facade explicit module" require_source_line \ "crates/ecstore/src/api/mod.rs" \ " pub mod com {" \ @@ -609,7 +609,7 @@ require_source_line \ "crates/ecstore/src/api/mod.rs" \ " pub mod tier_config {" \ "ECStore tier config public facade explicit module" -for ecstore_explicit_facade in bucket client; do +for ecstore_explicit_facade in bucket; do if grep -qF "pub use crate::${ecstore_explicit_facade}::{" "${ROOT_DIR}/crates/ecstore/src/api/mod.rs"; then report_failure "ECStore ${ecstore_explicit_facade} public facade must expose explicit submodules instead of whole owner module passthroughs" fi @@ -626,7 +626,6 @@ fi for ecstore_private_module in \ bucket \ cache_value \ - client \ config \ data_usage \ diagnostics \ diff --git a/scripts/ecstore-module-lint-register.txt b/scripts/ecstore-module-lint-register.txt index 69dd097dc..1033461f3 100644 --- a/scripts/ecstore-module-lint-register.txt +++ b/scripts/ecstore-module-lint-register.txt @@ -67,9 +67,9 @@ crates/s3-client/src/constants.rs|unused_variables crates/s3-client/src/credentials.rs|clippy::all crates/s3-client/src/credentials.rs|unused_must_use crates/s3-client/src/credentials.rs|unused_variables -crates/ecstore/src/client/object_api_utils.rs|clippy::all -crates/ecstore/src/client/object_api_utils.rs|unused_must_use -crates/ecstore/src/client/object_api_utils.rs|unused_variables +crates/ecstore/src/object_api/object_api_utils.rs|clippy::all +crates/ecstore/src/object_api/object_api_utils.rs|unused_must_use +crates/ecstore/src/object_api/object_api_utils.rs|unused_variables crates/s3-client/src/transition_api.rs|clippy::all crates/s3-client/src/transition_api.rs|unused_must_use crates/s3-client/src/transition_api.rs|unused_variables From 57b0a136d537570c8dabe36a4bf7febeca624a00 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 22:04:44 +0800 Subject: [PATCH 09/54] test(ci): serialize rekey Vault e2e (#6673) --- .config/nextest.toml | 4 +-- scripts/check_test_wiring.py | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index e460509de..64b13ef85 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -138,7 +138,7 @@ test-group = 'e2e-inline-boundaries' # does not cross nextest process boundaries, so keep every Vault-backed test in # one group. [[profile.default.overrides]] -filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))' +filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))' test-group = 'e2e-vault' # --------------------------------------------------------------------------- @@ -484,5 +484,5 @@ filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)' test-group = 'e2e-inline-boundaries' [[profile.e2e-full.overrides]] -filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))' +filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))' test-group = 'e2e-vault' diff --git a/scripts/check_test_wiring.py b/scripts/check_test_wiring.py index fc7101045..9ebe537f7 100755 --- a/scripts/check_test_wiring.py +++ b/scripts/check_test_wiring.py @@ -152,6 +152,28 @@ def check_e2e_modules(root: Path) -> list[str]: return errors +def check_vault_test_groups(root: Path) -> list[str]: + src = root / "crates/e2e_test/src" + vault_modules = { + path.relative_to(src).with_suffix("").as_posix().replace("/", "::") + for path in src.rglob("*_test.rs") + if "VaultTestEnvironment" in rust_code_only(path.read_text()) + } + config = tomllib.loads((root / ".config/nextest.toml").read_text()) + errors: list[str] = [] + for profile in ("default", "e2e-full"): + overrides = config.get("profile", {}).get(profile, {}).get("overrides", []) + filters = "\n".join( + override.get("filter", "") + for override in overrides + if override.get("test-group") == "e2e-vault" + ) + for module in sorted(vault_modules): + if f"{module}::" not in filters: + errors.append(f".config/nextest.toml: profile.{profile} does not serialize Vault module {module}") + return errors + + def check_fuzz_targets(root: Path) -> list[str]: manifest = tomllib.loads((root / "fuzz/Cargo.toml").read_text()) expected = {item["name"] for item in manifest.get("bin", []) if "name" in item} @@ -715,6 +737,7 @@ def check_profile_listing(root: Path, profile: str, listing: Path) -> list[str]: def validate(root: Path) -> list[str]: errors: list[str] = [] errors.extend(check_e2e_modules(root)) + errors.extend(check_vault_test_groups(root)) errors.extend(check_fuzz_targets(root)) errors.extend(check_runner_selection(root)) errors.extend(check_s3_tests_runner(root)) @@ -725,6 +748,30 @@ def validate(root: Path) -> list[str]: class SelfTests(unittest.TestCase): + def test_vault_tests_require_cross_process_serialization(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "crates/e2e_test/src/kms" + src.mkdir(parents=True) + (src / "vault_test.rs").write_text("fn test(env: VaultTestEnvironment) {}\n") + config = root / ".config/nextest.toml" + config.parent.mkdir() + config.write_text( + "[profile.default]\n" + "[[profile.default.overrides]]\n" + "filter = 'test(/^kms::vault_test::/)'\n" + "test-group = 'e2e-vault'\n" + "[profile.e2e-full]\n" + ) + self.assertEqual(len(check_vault_test_groups(root)), 1) + config.write_text( + config.read_text() + + "[[profile.e2e-full.overrides]]\n" + + "filter = 'test(/^kms::vault_test::/)'\n" + + "test-group = 'e2e-vault'\n" + ) + self.assertEqual(check_vault_test_groups(root), []) + def test_runner_readiness_fails_closed(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -879,6 +926,7 @@ class SelfTests(unittest.TestCase): self.assertEqual(len(check_s3_tests_runner(root)), 1) with ( mock.patch(__name__ + ".check_e2e_modules", return_value=[]), + mock.patch(__name__ + ".check_vault_test_groups", return_value=[]), mock.patch(__name__ + ".check_fuzz_targets", return_value=[]), mock.patch(__name__ + ".check_runner_selection", return_value=[]), mock.patch(__name__ + ".check_workflow_readiness", return_value=[]), From 41d7db6d57517d1f4c1b86e266c608e68bf98f94 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 22:05:10 +0800 Subject: [PATCH 10/54] test(connect): match offline enrollment rejection (#6675) test(connect): match offline rejection message --- scripts/check_offline_enrollment_e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check_offline_enrollment_e2e.sh b/scripts/check_offline_enrollment_e2e.sh index 05d38aa5f..1cbde5f95 100755 --- a/scripts/check_offline_enrollment_e2e.sh +++ b/scripts/check_offline_enrollment_e2e.sh @@ -69,7 +69,7 @@ then echo 'offline enrollment E2E gate: production CLI accepted the E2E root' >&2 exit 1 fi -grep -Fq 'the offline enrollment trust chain is not issued by a root pinned in this build' \ +grep -Fq 'rustfs-cli: the trust chain is not issued by a root pinned in this build' \ "${task_root}/production.stderr" \ || { echo 'offline enrollment E2E gate: production CLI failed for the wrong reason' >&2; exit 1; } test ! -e "${task_root}/production-response.json" \ From 38e93f553ddb2308eee2ace45da76e7fd8f4c595 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 22:11:27 +0800 Subject: [PATCH 11/54] style(rustfs): format object split guard (#6676) --- rustfs/src/table_catalog/tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rustfs/src/table_catalog/tests.rs b/rustfs/src/table_catalog/tests.rs index a4109dda0..8c1363407 100644 --- a/rustfs/src/table_catalog/tests.rs +++ b/rustfs/src/table_catalog/tests.rs @@ -16429,7 +16429,11 @@ fn object_mutation_entrypoints_call_reserved_prefix_guard() { let delete_module = include_str!("../app/object/delete.rs"); let delete_object = delete_module .split_once("pub async fn execute_delete_object") - .map(|(_, remainder)| remainder.split_once("\nmod tests").map_or(remainder, |(entrypoints, _)| entrypoints)) + .map(|(_, remainder)| { + remainder + .split_once("\nmod tests") + .map_or(remainder, |(entrypoints, _)| entrypoints) + }) .expect("delete object entrypoints should remain in app/object/delete.rs"); for expected in [ From fda93adafeb49ec7229a5daf9e1ccb57e57e235b Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 22:26:02 +0800 Subject: [PATCH 12/54] fix(ecstore): migrate test-only client import (#6678) --- crates/ecstore/src/store/init.rs | 3 ++- scripts/check_architecture_migration_rules.sh | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ecstore/src/store/init.rs b/crates/ecstore/src/store/init.rs index 21a9bb454..257dd7bcb 100644 --- a/crates/ecstore/src/store/init.rs +++ b/crates/ecstore/src/store/init.rs @@ -700,7 +700,6 @@ mod tests { validate_durable_ilm_record, }, bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG}, - client::transition_api::ReaderImpl, config::com, core::pools::DecomBucketInfo, data_movement::SourceCleanupDeleteBarrier, @@ -750,6 +749,8 @@ mod tests { #[cfg(feature = "test-util")] use rustfs_protos::{TIER_MUTATION_RPC_PROTOCOL_VERSION, TierMutationRpcPhase}; use rustfs_rio::{Checksum, ChecksumType}; + #[cfg(feature = "test-util")] + use rustfs_s3_client::transition_api::ReaderImpl; use rustfs_utils::{ CompressionAlgorithm, http::{SUFFIX_COMPRESSION, insert_str}, diff --git a/scripts/check_architecture_migration_rules.sh b/scripts/check_architecture_migration_rules.sh index d36cf982f..b7af64cf7 100755 --- a/scripts/check_architecture_migration_rules.sh +++ b/scripts/check_architecture_migration_rules.sh @@ -641,6 +641,12 @@ for ecstore_private_module in \ "mod ${ecstore_private_module};" \ "ECStore legacy ${ecstore_private_module} root module crate-private visibility" done +ECSTORE_CLIENT_SHIM_IMPORT_HITS_FILE="${TMP_DIR}/ecstore_client_shim_import_hits.txt" +if rg -n --no-heading --glob '*.rs' 'crate::client::|^[[:space:]]+client::' \ + "${ROOT_DIR}/crates/ecstore/src" >"${ECSTORE_CLIENT_SHIM_IMPORT_HITS_FILE}"; then + report_failure "ECStore client shim imports must use rustfs_s3_client directly" + cat "${ECSTORE_CLIENT_SHIM_IMPORT_HITS_FILE}" >&2 +fi require_source_line \ "crates/storage-api/src/lib.rs" \ "pub use bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp};" \ From 78935c34c9481ec7c59e295e63b76f61bd9ad5cd Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 22:49:55 +0800 Subject: [PATCH 13/54] refactor(common): drop transitional heal/scanner contract shims (#6680) Every consumer now imports rustfs-heal-contracts / rustfs-scanner-contracts directly and rg 'rustfs_common::(metrics|heal_channel|last_minute)' reports zero hits, so the backlog#1843 re-export shims and the transitional rustfs-common -> contracts dependency edges can go. rustfs-common no longer recompiles on scanner/heal type changes. Doc references to the moved files follow the new paths. --- Cargo.lock | 2 -- crates/common/Cargo.toml | 4 ---- crates/common/src/globals.rs | 4 ---- crates/common/src/lib.rs | 7 ------- ...l-scanner-vs-minio-comprehensive-analysis-2026-08-16.md | 6 +++--- ...canner-vs-minio-comprehensive-analysis-2026-08-16_zh.md | 6 +++--- 6 files changed, 6 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 63e94b893..79a87afae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9486,8 +9486,6 @@ version = "1.0.0-rc.3" dependencies = [ "hotpath", "metrics", - "rustfs-heal-contracts", - "rustfs-scanner-contracts", "smallvec", "tokio", "tonic", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index b78526088..1d610aed2 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -39,10 +39,6 @@ tokio = { workspace = true, features = ["fs", "rt-multi-thread"] } tonic = { workspace = true, features = ["gzip", "deflate"] } uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] } metrics = { workspace = true } -# Transitional shim deps (backlog#1843): dropped with the re-export shims once -# every consumer imports the contracts crates directly. -rustfs-heal-contracts = { workspace = true } -rustfs-scanner-contracts = { workspace = true } smallvec = { workspace = true } tracing = { workspace = true } diff --git a/crates/common/src/globals.rs b/crates/common/src/globals.rs index d4fe349e2..e4cd89c88 100644 --- a/crates/common/src/globals.rs +++ b/crates/common/src/globals.rs @@ -27,10 +27,6 @@ pub static GLOBAL_ROOT_CERT: LazyLock>>> = LazyLock::new(| pub static GLOBAL_MTLS_IDENTITY: LazyLock>> = LazyLock::new(|| RwLock::new(None)); pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock = LazyLock::new(|| AtomicU64::new(0)); -// Transitional re-export shim (backlog#1843): the node init-time global moved -// to rustfs-scanner-contracts, whose metrics report reads it directly. -pub use rustfs_scanner_contracts::{GLOBAL_INIT_TIME, get_global_init_time, set_global_init_time_now}; - /// Log level to use when reporting cached gRPC connection eviction. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConnectionEvictionLogLevel { diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 9cfc5bfea..4c7d8cfe3 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -19,13 +19,6 @@ mod readiness; pub mod table_catalog; pub mod trace_bus; -// Transitional re-export shims (backlog#1843): these modules moved to the -// rustfs-heal-contracts / rustfs-scanner-contracts crates. Consumers migrate -// to the new paths crate by crate; the shims are deleted once -// `rg 'rustfs_common::(metrics|heal_channel|last_minute)'` reports zero hits. -pub use rustfs_heal_contracts::heal_channel; -pub use rustfs_scanner_contracts::{last_minute, metrics}; - pub use globals::*; pub use readiness::{GlobalReadiness, SystemStage}; diff --git a/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md b/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md index 931d020e8..da7572639 100644 --- a/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md +++ b/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md @@ -3,7 +3,7 @@ > English | [中文版](rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md) - Date: 2026-08-16 (based on that day's `main` code; audit HEAD ≈ `a118d7e4f`) -- Scope: `crates/heal` (src 19,560 lines + tests 2,274 lines), `crates/scanner` (src ~26,000 lines + tests), `crates/data-usage`, the heal/heal_walk/bitrot_self_verify and config parts of `crates/ecstore`, `crates/common/src/heal_channel.rs`, `crates/madmin` (heal/scanner wire types), `rustfs/src` (startup wiring, admin handlers, cluster RPC) +- Scope: `crates/heal` (src 19,560 lines + tests 2,274 lines), `crates/scanner` (src ~26,000 lines + tests), `crates/data-usage`, the heal/heal_walk/bitrot_self_verify and config parts of `crates/ecstore`, `crates/heal-contracts/src/heal_channel.rs`, `crates/madmin` (heal/scanner wire types), `rustfs/src` (startup wiring, admin handlers, cluster RPC) - Parity baseline: minio/minio master (HEAD `7aac2a2c5b`; the repo has entered maintenance mode with master frozen, i.e. its final state) - Method: four parallel audit tracks (heal crate / scanner crate / ecstore integration layer / MinIO source study), with key conclusions verified by hand one by one (points marked "verified first-hand" below were checked against the source directly) - This document supersedes `docs/rustfs-heal-scanner-vs-minio-parity-assessment.md` (2026-06-15, v1). Since v1 there have been more than 80 heal/scanner commits (the full automatic drive-replacement healing chain, the resume state machine, making usage convergence authoritative, cluster-level heal coordination, ILM restore semantics, etc.), so v1's feature inventory and gap judgments are comprehensively outdated; v1 conclusions such as "bloom filter missing" were verified this round to be **misjudgments** (see §5.4). @@ -31,7 +31,7 @@ RustFS splits the heal/scanner functionality that MinIO keeps inside the `cmd/` | Primitives layer | `crates/ecstore/src/set_disk/ops/heal.rs` (~3,240 lines), `ops/heal_walk.rs`, `ops/bitrot_self_verify.rs`; upper wrappers `store/heal.rs`, `store/heal_walk.rs`, `core/sets.rs` | Object/bucket/format/replacement-drive format repair, disk-walk union enumeration, write-path bitrot self-verification; the `rustfs_storage_api::HealOperations` contract is implemented by `SetDisks`/`Sets`/`ECStore` (`crates/storage-api/src/object.rs:503-519`) | | heal runtime | `crates/heal` | Process-level HealManager (priority queue/scheduler/auto disk scanner/resumable resume), HealChannelProcessor (consumes the global heal channel), drive-replacement recovery state machine | | scanner runtime | `crates/scanner` | Data usage scanning, ILM evaluation and enqueueing, heal candidate production, replication usage statistics, remote scanner RPC | -| Shared protocol | `crates/common/src/heal_channel.rs` (~776 lines) | Start/Query/Cancel command channel, `HealOpts`/`HealScanMode`/`HealRequestSource`/`HealAdmission*` shared types, `HealResultItem` (madmin) | +| Shared protocol | `crates/heal-contracts/src/heal_channel.rs` (~776 lines) | Start/Query/Cancel command channel, `HealOpts`/`HealScanMode`/`HealRequestSource`/`HealAdmission*` shared types, `HealResultItem` (madmin) | | Shared data | `crates/data-usage` | `DataUsageEntry/Info`, histograms, `hash_path`; produced by the scanner, consumed by ecstore/admin | Startup chain (wiring verified first-hand): @@ -214,7 +214,7 @@ Compared with MinIO master: MinIO's skip strategy is likewise hash-mod-16 cycles ### 3.5 ILM integration - Per object `ScannerItem::apply_actions` (`scanner_folder.rs:747-1032`): `Evaluator::new(lifecycle).with_lock_retention(...).with_replication_config(...).eval()` batch evaluation. -- Implemented actions (the full IlmAction set, `common/src/metrics.rs:34-45`): expiry deletes (Delete/DeleteRestored/DeleteRestoredVersion), all-versions deletes (DeleteAllVersions/DelMarkerDeleteAllVersions, stop further versions after handling), transition (Transition/TransitionVersion, tier list read at runtime), noncurrent batches (DeleteVersionAction → `enqueue_by_newer_noncurrent`), free-version cleanup (`enqueue_free_version`), object-lock retention constraints. **A one-to-one mapping onto MinIO's 9 ILM actions.** +- Implemented actions (the full IlmAction set, `scanner-contracts/src/metrics.rs:34-45`): expiry deletes (Delete/DeleteRestored/DeleteRestoredVersion), all-versions deletes (DeleteAllVersions/DelMarkerDeleteAllVersions, stop further versions after handling), transition (Transition/TransitionVersion, tier list read at runtime), noncurrent batches (DeleteVersionAction → `enqueue_by_newer_noncurrent`), free-version cleanup (`enqueue_free_version`), object-lock retention constraints. **A one-to-one mapping onto MinIO's 9 ILM actions.** - Execution model: the scanner is the "discover and enqueue" role (the expiry/transition queues live in ecstore `bucket_lifecycle_ops.rs`); actions are consumed by worker pools — the same shape as MinIO's globalExpiryState/globalTransitionState. - AbortIncompleteMultipartUpload is not executed inside scanner/ILM (MinIO likewise: `internal/bucket/lifecycle/rule.go` has a FIXME, and it is actually carried by the `erasureSets.cleanupStaleUploads` global routine); in RustFS it is an independent ecstore background task `init_background_stale_multipart_upload_cleanup` (`bucket_lifecycle_ops.rs:3289-3320`) + on-demand at bucket deletion. - Integration-test coverage: transition+restore, free-version, noncurrent, delete-marker, 0-day, background-scan expiry (`scanner/tests/lifecycle_integration_test.rs:1071-2095`). diff --git a/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md b/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md index a75971d1a..9d5cb9461 100644 --- a/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md +++ b/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md @@ -3,7 +3,7 @@ > English version: [rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md](rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md) - 日期:2026-08-16(基于 main 分支当日代码,审计时 HEAD ≈ `a118d7e4f`) -- 范围:`crates/heal`(src 19,560 行 + tests 2,274 行)、`crates/scanner`(src 约 26,000 行 + tests)、`crates/data-usage`、`crates/ecstore` 中 heal/heal_walk/bitrot_self_verify 与 config、`crates/common/src/heal_channel.rs`、`crates/madmin`(heal/scanner wire 类型)、`rustfs/src`(startup wiring、admin handlers、集群 RPC) +- 范围:`crates/heal`(src 19,560 行 + tests 2,274 行)、`crates/scanner`(src 约 26,000 行 + tests)、`crates/data-usage`、`crates/ecstore` 中 heal/heal_walk/bitrot_self_verify 与 config、`crates/heal-contracts/src/heal_channel.rs`、`crates/madmin`(heal/scanner wire 类型)、`rustfs/src`(startup wiring、admin handlers、集群 RPC) - 对标基线:minio/minio master(HEAD `7aac2a2c5b`,仓库已进入维护模式,master 冻结,即最终态) - 方法:四路并行审计(heal crate / scanner crate / ecstore 集成层 / MinIO 源码研究),关键结论逐条人工抽验(文内标注"已亲验"处为一手验证) - 本文档取代 `docs/rustfs-heal-scanner-vs-minio-parity-assessment.md`(2026-06-15 v1)。v1 之后 heal/scanner 相关提交超过 80 个(换盘自动修复全链路、resume 状态机、usage 收敛权威化、集群级 heal 协调、ILM restore 语义等),v1 的功能清单与差距判断已全面过时;v1 中"bloom filter 缺失"等结论经本次核实为**误判**(详见 §5.4)。 @@ -31,7 +31,7 @@ RustFS 把 MinIO 在 `cmd/` 内单体的 heal/scanner 拆成三层 + 两个独 | 原语层 | `crates/ecstore/src/set_disk/ops/heal.rs`(~3,240 行)、`ops/heal_walk.rs`、`ops/bitrot_self_verify.rs`;上层封装 `store/heal.rs`、`store/heal_walk.rs`、`core/sets.rs` | 对象/桶/format/替换盘格式修复、disk-walk 并集枚举、写入路径 bitrot 自校验;由 `SetDisks`/`Sets`/`ECStore` 实现 `rustfs_storage_api::HealOperations` 契约(`crates/storage-api/src/object.rs:503-519`) | | heal 运行时 | `crates/heal` | 进程级 HealManager(优先级队列/调度器/auto disk scanner/断点续传 resume)、HealChannelProcessor(消费全局 heal channel)、换盘替换恢复状态机 | | scanner 运行时 | `crates/scanner` | 数据使用扫描、ILM 评估与入队、heal 候选生产、复制用量统计、remote scanner RPC | -| 共享协议 | `crates/common/src/heal_channel.rs`(~776 行) | Start/Query/Cancel 命令通道、`HealOpts`/`HealScanMode`/`HealRequestSource`/`HealAdmission*` 共享类型、`HealResultItem`(madmin) | +| 共享协议 | `crates/heal-contracts/src/heal_channel.rs`(~776 行) | Start/Query/Cancel 命令通道、`HealOpts`/`HealScanMode`/`HealRequestSource`/`HealAdmission*` 共享类型、`HealResultItem`(madmin) | | 共享数据 | `crates/data-usage` | `DataUsageEntry/Info`、直方图、`hash_path`;scanner 产生、ecstore/admin 消费 | 启动链路(已亲验 wiring): @@ -214,7 +214,7 @@ heal crate 侧包装(`task.rs:855-1146`):存在性检查(瞬时错误转 ### 3.5 ILM 集成 - 每对象 `ScannerItem::apply_actions`(`scanner_folder.rs:747-1032`):`Evaluator::new(lifecycle).with_lock_retention(...).with_replication_config(...).eval()` 批量评估。 -- 已实现动作(IlmAction 全集,`common/src/metrics.rs:34-45`):expiry 删除(Delete/DeleteRestored/DeleteRestoredVersion)、全版本删除(DeleteAllVersions/DelMarkerDeleteAllVersions,处理后停止后续版本)、transition(Transition/TransitionVersion,tier 列表运行时读取)、noncurrent 批量(DeleteVersionAction → `enqueue_by_newer_noncurrent`)、free-version 清理(`enqueue_free_version`)、object-lock retention 约束。**与 MinIO 的 9 个 ILM 动作一一对应**。 +- 已实现动作(IlmAction 全集,`scanner-contracts/src/metrics.rs:34-45`):expiry 删除(Delete/DeleteRestored/DeleteRestoredVersion)、全版本删除(DeleteAllVersions/DelMarkerDeleteAllVersions,处理后停止后续版本)、transition(Transition/TransitionVersion,tier 列表运行时读取)、noncurrent 批量(DeleteVersionAction → `enqueue_by_newer_noncurrent`)、free-version 清理(`enqueue_free_version`)、object-lock retention 约束。**与 MinIO 的 9 个 ILM 动作一一对应**。 - 执行模型:scanner 是"发现与入队"角色(expiry 队列/transition 队列在 ecstore `bucket_lifecycle_ops.rs`),动作由 worker 池消费——与 MinIO globalExpiryState/globalTransitionState 同型。 - AbortIncompleteMultipartUpload 不在 scanner/ILM 内执行(MinIO 同样不在:`internal/bucket/lifecycle/rule.go` 有 FIXME,实际由 `erasureSets.cleanupStaleUploads` 全局例程承担);RustFS 由 ecstore 独立后台任务 `init_background_stale_multipart_upload_cleanup`(`bucket_lifecycle_ops.rs:3289-3320`)+ 桶删除时 on-demand。 - 集成测试覆盖:transition+restore、free-version、noncurrent、delete-marker、0-day、后台扫描过期(`scanner/tests/lifecycle_integration_test.rs:1071-2095`)。 From 8f7ccab4ed173130e76abe82b367fd8484a48389 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 22:50:15 +0800 Subject: [PATCH 14/54] fix(ci): make s3s footprint ratchet count the tree, not stdin (#6681) fix(ci): give the s3s footprint ratchet explicit rg paths so CI counts the tree, not stdin --- scripts/check_s3s_footprint.sh | 54 ++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/scripts/check_s3s_footprint.sh b/scripts/check_s3s_footprint.sh index 5f1b27061..b106498e5 100755 --- a/scripts/check_s3s_footprint.sh +++ b/scripts/check_s3s_footprint.sh @@ -4,10 +4,18 @@ # acceptance criteria recorded in rustfs/backlog#1733). # # The migration's goal is to shrink the direct s3s surface, so new code must -# not grow it. Two counters are ratcheted, baselines verified on 2026-08-05: +# not grow it. Two counters are ratcheted (verification dates live next to the +# baseline values below): # -# - files referencing s3s paths: rg -l "$S3S_PATH_PATTERN" --type rust (files) -# - s3_error! invocation lines: rg -c 's3_error!' --type rust (summed) +# - files referencing s3s paths: rg -l "$S3S_PATH_PATTERN" --type rust . (files) +# - s3_error! invocation lines: rg -c 's3_error!' --type rust . (summed) +# +# Every rg invocation MUST pass an explicit path ('.' for repo-wide): without +# one, rg searches stdin instead of the tree whenever stdin is a readable +# pipe — which is exactly what GitHub Actions attaches to run steps — and +# silently counts 0 (observed on run 32978746357, where both repo-wide +# counters read 0 and were waved through as "shrank"). The sanity assertions +# below fail hard if that ever regresses. # # Either count exceeding its baseline fails the check with the offending # delta. Baselines are LOWER-ONLY: when a PR shrinks the footprint, lower the @@ -22,10 +30,15 @@ set -euo pipefail cd "$(dirname "$0")/.." -# Baselines verified on 2026-08-11. Lower-only; see header. +# Baselines verified on 2026-08-26. Lower-only; see header. # Excludes crates/e2e_test/ — test infrastructure legitimately uses s3s # to verify S3 behavior and does not widen the production s3s surface. -S3S_IMPORT_FILES_BASELINE=208 +# 208 → 215 on 2026-08-26: PR #6670 mechanically split +# rustfs/src/app/object_usecase.rs into 8 per-operation modules (net +7 +# files, zero new s3s code — the same handler-layer surface redistributed). +# The file counter is split-sensitive; the s3_error! line counter confirms +# no growth (unchanged at 1620). +S3S_IMPORT_FILES_BASELINE=215 S3_ERROR_LINES_BASELINE=1620 # ecstore-scoped ratchet (rustfs/backlog#1842): the storage engine must not # know S3 wire/DTO types (ARCHITECTURE.md invariant 4). The S3-*consuming* @@ -51,8 +64,9 @@ run_rg_to() { fi } -run_rg_to "$TMP_DIR/import_files" -l "$S3S_PATH_PATTERN" --type rust $E2E_TEST_GLOB -run_rg_to "$TMP_DIR/error_lines" -c 's3_error!' --type rust $E2E_TEST_GLOB +# Explicit '.' path is load-bearing — see header. Never drop it. +run_rg_to "$TMP_DIR/import_files" -l "$S3S_PATH_PATTERN" --type rust "$E2E_TEST_GLOB" . +run_rg_to "$TMP_DIR/error_lines" -c 's3_error!' --type rust "$E2E_TEST_GLOB" . run_rg_to "$TMP_DIR/ecstore_files" -l "$S3S_PATH_PATTERN" --type rust crates/ecstore/src s3s_import_files="$(grep -c . "$TMP_DIR/import_files" || true)" @@ -66,6 +80,28 @@ for value in "$s3s_import_files" "$s3_error_lines" "$s3s_ecstore_files"; do fi done +# Sanity assertions: a counter reading 0 while its baseline is positive, or +# the repo-wide file count dropping below the ecstore-scoped one (a strict +# subset of it), means the counter itself broke — most likely rg searching +# stdin instead of the tree (see header) — not that the footprint shrank. +# Fail hard rather than waving the ratchet through. If the footprint ever +# genuinely reaches zero, lower the baseline to 0 in the same PR. +sanity_nonzero() { + local label="$1" count="$2" baseline="$3" + if ((count == 0 && baseline > 0)); then + echo "error: $label counted 0 with a baseline of $baseline — the counter is" >&2 + echo " broken (rg likely searched stdin; every rg call needs an explicit path)." >&2 + exit 1 + fi +} +sanity_nonzero "files importing s3s" "$s3s_import_files" "$S3S_IMPORT_FILES_BASELINE" +sanity_nonzero "s3_error! invocation lines" "$s3_error_lines" "$S3_ERROR_LINES_BASELINE" +if ((s3s_import_files < s3s_ecstore_files)); then + echo "error: repo-wide s3s file count ($s3s_import_files) is below the ecstore-scoped" >&2 + echo " count ($s3s_ecstore_files); the repo-wide counter is broken (see header)." >&2 + exit 1 +fi + status=0 check_ratchet() { @@ -87,9 +123,9 @@ check_ratchet() { } check_ratchet "files importing s3s" "$s3s_import_files" "$S3S_IMPORT_FILES_BASELINE" \ - "rg -l '$S3S_PATH_PATTERN' --type rust $E2E_TEST_GLOB" + "rg -l '$S3S_PATH_PATTERN' --type rust $E2E_TEST_GLOB ." check_ratchet "s3_error! invocation lines" "$s3_error_lines" "$S3_ERROR_LINES_BASELINE" \ - "rg -c 's3_error!' --type rust $E2E_TEST_GLOB" + "rg -c 's3_error!' --type rust $E2E_TEST_GLOB ." check_ratchet "ecstore files referencing s3s" "$s3s_ecstore_files" "$S3S_ECSTORE_FILES_BASELINE" \ "rg -l '$S3S_PATH_PATTERN' --type rust crates/ecstore/src" From 9cf276ed24ef22e6cf13a9acc686b9bd24c53925 Mon Sep 17 00:00:00 2001 From: houseme Date: Wed, 26 Aug 2026 22:51:27 +0800 Subject: [PATCH 15/54] chore(deps): clean up cargo shear findings (#6679) Remove unused direct dependency declarations found by cargo-shear and delete the unlinked ecstore mimalloc diagnostics file. Keep feature-forwarding dependencies explicit with package-local cargo-shear ignores so hotpath feature propagation remains intact. Co-authored-by: heihutu --- Cargo.lock | 5 ---- Cargo.toml | 1 - crates/ecstore/Cargo.toml | 10 ++++--- crates/ecstore/src/memory_observability.rs | 33 ---------------------- crates/lifecycle/Cargo.toml | 3 ++ rustfs/Cargo.toml | 3 -- 6 files changed, 9 insertions(+), 46 deletions(-) delete mode 100644 crates/ecstore/src/memory_observability.rs diff --git a/Cargo.lock b/Cargo.lock index 79a87afae..52ed828ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9380,7 +9380,6 @@ dependencies = [ "rustfs-log-analyzer", "rustfs-madmin", "rustfs-mimalloc", - "rustfs-mimalloc-sys", "rustfs-notify", "rustfs-object-capacity", "rustfs-object-data-cache", @@ -9581,7 +9580,6 @@ dependencies = [ "bytesize", "chrono", "criterion", - "enumset", "faster-hex", "flatbuffers", "futures", @@ -9615,7 +9613,6 @@ dependencies = [ "path-absolutize", "pin-project-lite", "proptest", - "quick-xml 0.42.0", "rand 0.10.2", "ratelimit", "rcgen", @@ -9661,11 +9658,9 @@ dependencies = [ "serde_json", "serde_urlencoded", "serial_test", - "sha1 0.11.0", "sha2 0.11.0", "shadow-rs", "smallvec", - "starshard", "temp-env", "tempfile", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index 35b61c7fc..c7eafa5ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -366,7 +366,6 @@ dav-server = "0.11.0" # Performance Analysis and Memory Profiling rustfs-mimalloc = { version = "0.5.1" } -rustfs-mimalloc-sys = { version = "0.5.1" } hotpath = { version = "0.24.0", default-features = false } # Snapshot testing for output format regression detection insta = { version = "1.48" } diff --git a/crates/ecstore/Cargo.toml b/crates/ecstore/Cargo.toml index fc488fd77..41d6af83b 100644 --- a/crates/ecstore/Cargo.toml +++ b/crates/ecstore/Cargo.toml @@ -25,6 +25,12 @@ keywords = ["erasure-coding", "storage", "rustfs", "Minio", "solomon"] categories = ["web-programming", "development-tools", "filesystem"] documentation = "https://docs.rs/rustfs-ecstore/latest/rustfs_ecstore/" +[package.metadata.cargo-shear] +ignored = [ + "rustfs-checksums", + "rustfs-signer", + "rustfs-tls-runtime", +] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lints] @@ -129,7 +135,6 @@ hotpath-cpu = [ test-util = [] [dependencies] -starshard = { workspace = true } hotpath.workspace = true rustfs-filemeta.workspace = true rustfs-utils = { workspace = true, features = ["full"] } @@ -171,7 +176,6 @@ serde = { workspace = true, features = ["derive"] } time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] } bytesize.workspace = true serde_json = { workspace = true, features = ["raw_value"] } -quick-xml = { workspace = true, features = ["serialize", "async-tokio"] } s3s = { workspace = true, features = ["minio"] } http.workspace = true opentelemetry.workspace = true @@ -193,7 +197,6 @@ tokio-util = { workspace = true, features = ["io", "compat"] } tokio-stream = { workspace = true, features = ["sync"] } base64 = { workspace = true } hmac = { workspace = true } -sha1 = { workspace = true } sha2 = { workspace = true } hex-simd = { workspace = true } tempfile.workspace = true @@ -208,7 +211,6 @@ tonic = { workspace = true, features = ["gzip", "deflate"] } xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] } tower = { workspace = true, features = ["timeout"] } async-channel.workspace = true -enumset = { workspace = true } num_cpus = { workspace = true } rand = { workspace = true, features = ["serde"] } pin-project-lite.workspace = true diff --git a/crates/ecstore/src/memory_observability.rs b/crates/ecstore/src/memory_observability.rs deleted file mode 100644 index bebf7fdce..000000000 --- a/crates/ecstore/src/memory_observability.rs +++ /dev/null @@ -1,33 +0,0 @@ - -/// Check mimalloc arena configuration and log diagnostics -pub fn log_mimalloc_diagnostics() { - #[cfg(feature = "mimalloc")] - { - use rustfs_mimalloc::MiMalloc; - - // Check arena_max_object_size - let arena_max_obj_size = MiMalloc::option_get_size( - rustfs_mimalloc_sys::mi_option_t::mi_option_arena_max_object_size - ); - tracing::info!( - arena_max_object_size_bytes = arena_max_obj_size, - "mimalloc arena_max_object_size" - ); - - // Check if pagemap is enabled - let pagemap_commit = MiMalloc::option_is_enabled( - rustfs_mimalloc_sys::mi_option_t::mi_option_pagemap_commit - ); - tracing::info!( - pagemap_commit = pagemap_commit, - "mimalloc pagemap_commit" - ); - - // Log version - let version = MiMalloc::version(); - tracing::info!( - mimalloc_version = version, - "mimalloc version" - ); - } -} diff --git a/crates/lifecycle/Cargo.toml b/crates/lifecycle/Cargo.toml index 76f9d198c..002bf7c82 100644 --- a/crates/lifecycle/Cargo.toml +++ b/crates/lifecycle/Cargo.toml @@ -25,6 +25,9 @@ keywords = ["lifecycle", "storage", "rustfs", "Minio"] categories = ["web-programming", "development-tools", "filesystem"] documentation = "https://docs.rs/rustfs-lifecycle/latest/rustfs_lifecycle/" +[package.metadata.cargo-shear] +ignored = ["rustfs-common"] + [features] default = [] hotpath = [ diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index 3d7dddf62..6fbedae51 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -353,9 +353,6 @@ rustfs-mimalloc = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] libsystemd.workspace = true -[target.'cfg(not(target_os = "windows"))'.dependencies] -rustfs-mimalloc-sys = { workspace = true } - [dev-dependencies] uuid = { workspace = true, features = ["v4", "v5", "fast-rng", "macro-diagnostics"] } serial_test = { workspace = true } From 3f9ec4275b8110a128a25784265f89a51d1482d7 Mon Sep 17 00:00:00 2001 From: houseme Date: Thu, 27 Aug 2026 03:14:46 +0800 Subject: [PATCH 16/54] chore(deps): finish cargo shear cleanup (#6682) --- Cargo.lock | 40 +++++++++++++++++-------------------- Cargo.toml | 2 +- crates/ecstore/Cargo.toml | 19 ------------------ crates/lifecycle/Cargo.toml | 7 ------- 4 files changed, 19 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52ed828ff..5bb08ede7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1616,9 +1616,9 @@ dependencies = [ [[package]] name = "blake2" -version = "0.11.0-rc.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ "digest 0.11.3", ] @@ -1629,7 +1629,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] @@ -1648,7 +1648,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] @@ -2003,7 +2003,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common 0.1.6", "inout 0.1.4", ] @@ -2463,7 +2463,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", "rand_core 0.6.4", "subtle", "zeroize", @@ -2488,11 +2488,11 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", "typenum", ] @@ -3758,7 +3758,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid 0.9.6", - "crypto-common 0.1.7", + "crypto-common 0.1.6", "subtle", ] @@ -4021,7 +4021,7 @@ dependencies = [ "crypto-bigint 0.5.5", "digest 0.10.7", "ff 0.13.1", - "generic-array 0.14.7", + "generic-array 0.14.9", "group 0.13.0", "hkdf 0.12.4", "pem-rfc7468 0.7.0", @@ -4466,9 +4466,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -4481,7 +4481,7 @@ version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", "rustversion", "typenum", ] @@ -5516,7 +5516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding 0.3.3", - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] @@ -6967,7 +6967,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.5.0", @@ -9621,7 +9621,6 @@ dependencies = [ "reqwest", "rmp", "rmp-serde", - "rustfs-checksums", "rustfs-common", "rustfs-concurrency", "rustfs-config", @@ -9645,9 +9644,7 @@ dependencies = [ "rustfs-s3-client", "rustfs-s3-types", "rustfs-scanner-contracts", - "rustfs-signer", "rustfs-storage-api", - "rustfs-tls-runtime", "rustfs-uring", "rustfs-utils", "rustix", @@ -9992,7 +9989,6 @@ dependencies = [ "metrics", "metrics-util", "proptest", - "rustfs-common", "rustfs-config", "rustfs-replication", "rustfs-scanner-contracts", @@ -11141,7 +11137,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct 0.2.0", "der 0.7.10", - "generic-array 0.14.7", + "generic-array 0.14.9", "pkcs8 0.10.2", "subtle", "zeroize", @@ -12119,7 +12115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index c7eafa5ba..7f0b31277 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -199,7 +199,7 @@ serde_urlencoded = "0.7.1" # releases. aes-gcm = { version = "=0.11.1" } argon2 = { version = "=0.6.0-rc.8" } -blake2 = "=0.11.0-rc.6" +blake2 = "=0.11.0" chacha20poly1305 = { version = "=0.11.0" } crc-fast = "1.10.0" hmac = { version = "0.13.0" } diff --git a/crates/ecstore/Cargo.toml b/crates/ecstore/Cargo.toml index 41d6af83b..cbfafd4a9 100644 --- a/crates/ecstore/Cargo.toml +++ b/crates/ecstore/Cargo.toml @@ -25,13 +25,6 @@ keywords = ["erasure-coding", "storage", "rustfs", "Minio", "solomon"] categories = ["web-programming", "development-tools", "filesystem"] documentation = "https://docs.rs/rustfs-ecstore/latest/rustfs_ecstore/" -[package.metadata.cargo-shear] -ignored = [ - "rustfs-checksums", - "rustfs-signer", - "rustfs-tls-runtime", -] - # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lints] workspace = true @@ -51,7 +44,6 @@ hotpath = [ "hotpath/async-channel", "hotpath/parking_lot", "hotpath/reqwest-0-13", - "rustfs-checksums/hotpath", "rustfs-common/hotpath", "rustfs-concurrency/hotpath", "rustfs-config/hotpath", @@ -69,16 +61,13 @@ hotpath = [ "rustfs-rio/hotpath", "rustfs-rio-v2?/hotpath", "rustfs-s3-types/hotpath", - "rustfs-signer/hotpath", "rustfs-storage-api/hotpath", - "rustfs-tls-runtime/hotpath", "rustfs-utils/hotpath", "rustfs-crypto/hotpath", ] hotpath-alloc = [ "hotpath", "hotpath/hotpath-alloc", - "rustfs-checksums/hotpath-alloc", "rustfs-common/hotpath-alloc", "rustfs-concurrency/hotpath-alloc", "rustfs-config/hotpath-alloc", @@ -96,16 +85,13 @@ hotpath-alloc = [ "rustfs-rio/hotpath-alloc", "rustfs-rio-v2?/hotpath-alloc", "rustfs-s3-types/hotpath-alloc", - "rustfs-signer/hotpath-alloc", "rustfs-storage-api/hotpath-alloc", - "rustfs-tls-runtime/hotpath-alloc", "rustfs-utils/hotpath-alloc", "rustfs-crypto/hotpath-alloc", ] hotpath-cpu = [ "hotpath", "hotpath/hotpath-cpu", - "rustfs-checksums/hotpath-cpu", "rustfs-common/hotpath-cpu", "rustfs-concurrency/hotpath-cpu", "rustfs-config/hotpath-cpu", @@ -123,9 +109,7 @@ hotpath-cpu = [ "rustfs-rio/hotpath-cpu", "rustfs-rio-v2?/hotpath-cpu", "rustfs-s3-types/hotpath-cpu", - "rustfs-signer/hotpath-cpu", "rustfs-storage-api/hotpath-cpu", - "rustfs-tls-runtime/hotpath-cpu", "rustfs-utils/hotpath-cpu", "rustfs-crypto/hotpath-cpu", ] @@ -140,10 +124,7 @@ rustfs-filemeta.workspace = true rustfs-utils = { workspace = true, features = ["full"] } rustfs-rio.workspace = true rustfs-rio-v2 = { workspace = true, optional = true } -rustfs-signer.workspace = true rustfs-storage-api.workspace = true -rustfs-tls-runtime.workspace = true -rustfs-checksums.workspace = true rustfs-config = { workspace = true, features = ["notify", "audit", "server-config-model"] } rustfs-concurrency.workspace = true rustfs-credentials = { workspace = true } diff --git a/crates/lifecycle/Cargo.toml b/crates/lifecycle/Cargo.toml index 002bf7c82..df5fc1de4 100644 --- a/crates/lifecycle/Cargo.toml +++ b/crates/lifecycle/Cargo.toml @@ -25,15 +25,11 @@ keywords = ["lifecycle", "storage", "rustfs", "Minio"] categories = ["web-programming", "development-tools", "filesystem"] documentation = "https://docs.rs/rustfs-lifecycle/latest/rustfs_lifecycle/" -[package.metadata.cargo-shear] -ignored = ["rustfs-common"] - [features] default = [] hotpath = [ "hotpath/hotpath", "hotpath/tokio", - "rustfs-common/hotpath", "rustfs-config/hotpath", "rustfs-replication/hotpath", "rustfs-storage-api/hotpath", @@ -41,7 +37,6 @@ hotpath = [ hotpath-alloc = [ "hotpath", "hotpath/hotpath-alloc", - "rustfs-common/hotpath-alloc", "rustfs-config/hotpath-alloc", "rustfs-replication/hotpath-alloc", "rustfs-storage-api/hotpath-alloc", @@ -49,7 +44,6 @@ hotpath-alloc = [ hotpath-cpu = [ "hotpath", "hotpath/hotpath-cpu", - "rustfs-common/hotpath-cpu", "rustfs-config/hotpath-cpu", "rustfs-replication/hotpath-cpu", "rustfs-storage-api/hotpath-cpu", @@ -59,7 +53,6 @@ hotpath-cpu = [ hotpath.workspace = true async-trait.workspace = true metrics.workspace = true -rustfs-common.workspace = true rustfs-scanner-contracts.workspace = true rustfs-config = { workspace = true, features = ["constants"] } rustfs-replication.workspace = true From f4cc91940150adee6ac14cd5f30da0fd92acb737 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Thu, 27 Aug 2026 03:15:07 +0800 Subject: [PATCH 17/54] docs(architecture): adjudicate the io-metrics leaf dependency on the s3-ops contract crate (#6683) --- ARCHITECTURE.md | 9 +++++++-- docs/architecture/crate-boundaries.md | 13 +++++++++++++ scripts/check_architecture_migration_rules.sh | 10 ++++++---- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce6b1dad3..541e9d892 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -115,8 +115,13 @@ default build (lifecycle: 1. **Layers flow downward.** Server → Admin/App → Storage → ecstore → rio/io-core. No upward imports. -2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`, - `io-metrics`, and `madmin` should depend only on external crates. +2. **Leaf crates depend only on external crates, with one adjudicated exception.** + `config`, `credentials`, `crypto`, `io-metrics`, and `madmin` take no internal + dependency except `io-metrics → rustfs-s3-ops` (transitively `rustfs-s3-types`), + a pure contract crate with no I/O and no global state. Adjudicated in + rustfs/backlog#1834 and pinned by the leaf allowlist in + `scripts/check_architecture_migration_rules.sh`; any other internal dependency + fails the guard ([crate boundaries](docs/architecture/crate-boundaries.md)). - ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin` edges were removed; do not reintroduce them (see Known Structural Issues). diff --git a/docs/architecture/crate-boundaries.md b/docs/architecture/crate-boundaries.md index accf68b4e..a8851caa2 100644 --- a/docs/architecture/crate-boundaries.md +++ b/docs/architecture/crate-boundaries.md @@ -35,6 +35,19 @@ wire types still live in `rustfs-filemeta`. This keeps the temporary dependency centralized until those wire contracts can move without introducing a `rustfs-replication` / `rustfs-storage-api` cycle. +Leaf crates carry exactly one adjudicated allowed edge: +`io-metrics -> rustfs-s3-ops` (transitively `rustfs-s3-types`). Both are pure +contract crates — types and enums only, no I/O, no global state, no non-contract +internal dependencies — so `io-metrics` reuses the `S3Operation` vocabulary +instead of copying it. The allowance covers that edge and nothing else: the +leaf-crate allowlist in `scripts/check_architecture_migration_rules.sh` fails any +other `rustfs-*` dependency in `config`, `credentials`, `crypto`, `io-metrics`, +or `madmin`. Adjudicated in +[`rustfs/backlog#1834`](https://github.com/rustfs/backlog/issues/1834); a further +exception must meet the same criterion — pure contract crate, no I/O, no globals, +no non-contract internal dependencies — and land its guard allowlist entry +alongside the dependency. + Dependency direction also applies to compile-time source reads: `include_str!`/`include!` of a `.rs` file must not resolve outside the including crate's own directory (`scripts/check_layer_dependencies.sh` diff --git a/scripts/check_architecture_migration_rules.sh b/scripts/check_architecture_migration_rules.sh index b7af64cf7..99c1d87e1 100755 --- a/scripts/check_architecture_migration_rules.sh +++ b/scripts/check_architecture_migration_rules.sh @@ -5454,9 +5454,11 @@ require_source_contains \ # --- Leaf crates must stay free of internal dependencies (backlog#1834) --- # ARCHITECTURE.md invariant 2 names config, credentials, crypto, io-metrics, # and madmin as leaf crates that depend only on external crates. Allowlist: -# io-metrics -> rustfs-s3-ops (contract crate; leaf-allowance adjudication is -# tracked as backlog#1834 PR2). Adding any other rustfs-* dependency to a leaf -# crate needs a maintainer decision, not a quiet Cargo.toml edit. +# io-metrics -> rustfs-s3-ops. That edge is DECIDED in backlog#1834: allowed as a +# pure-contract-crate exception (types/enums only, no I/O, no globals, no +# non-contract internal deps), narrowed to exactly this edge. Adding any other +# rustfs-* dependency to a leaf crate needs its own adjudication, not a quiet +# Cargo.toml edit. LEAF_CRATE_DEP_HITS_FILE="${TMP_DIR}/leaf_crate_dep_hits.txt" : >"$LEAF_CRATE_DEP_HITS_FILE" ( @@ -5478,7 +5480,7 @@ LEAF_CRATE_DEP_HITS_FILE="${TMP_DIR}/leaf_crate_dep_hits.txt" ) if [[ -s "$LEAF_CRATE_DEP_HITS_FILE" ]]; then - report_failure "leaf crates (config/credentials/crypto/io-metrics/madmin) must not depend on internal rustfs-* crates (allowlist: io-metrics -> rustfs-s3-ops, backlog#1834): $(paste -sd '; ' "$LEAF_CRATE_DEP_HITS_FILE")" + report_failure "leaf crates (config/credentials/crypto/io-metrics/madmin) must not depend on internal rustfs-* crates (sole adjudicated exception, decided in backlog#1834: io-metrics -> rustfs-s3-ops, a pure contract crate; any new edge needs its own adjudication): $(paste -sd '; ' "$LEAF_CRATE_DEP_HITS_FILE")" fi # --- ecstore module-level lint blankets (backlog#1823 step 9) --- From a42046b79cbc34a8b1d08f1f5231d2c7a23f76a1 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Thu, 27 Aug 2026 03:15:32 +0800 Subject: [PATCH 18/54] feat(rpc): dual-write a typed not-initialized code on control-plane responses (#6684) --- .../src/cluster/rpc/peer_rest_client.rs | 245 ++++++++++++------ crates/ecstore/src/error/mod.rs | 19 +- .../src/generated/proto_gen/node_service.rs | 104 +++++++- crates/protos/src/node.proto | 31 +++ crates/storage-api/src/error.rs | 4 + docs/architecture/compat-cleanup-register.md | 1 + rustfs/src/storage/rpc/node_service.rs | 67 +++++ rustfs/src/storage/rpc/node_service/bucket.rs | 4 + rustfs/src/storage/rpc/node_service/health.rs | 3 + 9 files changed, 383 insertions(+), 95 deletions(-) diff --git a/crates/ecstore/src/cluster/rpc/peer_rest_client.rs b/crates/ecstore/src/cluster/rpc/peer_rest_client.rs index a3ebe48aa..64ceb292e 100644 --- a/crates/ecstore/src/cluster/rpc/peer_rest_client.rs +++ b/crates/ecstore/src/cluster/rpc/peer_rest_client.rs @@ -113,6 +113,21 @@ fn peer_failure_without_details(op: &str, bucket: Option<&str>) -> Error { } } +/// Decode a control-plane response failure. Peers at or above the typed +/// `ControlPlaneErrorCode` change (backlog#1845) carry a machine-readable +/// discriminant beside the legacy `error_info` string; prefer it, then fall +/// back to the string, then to the detail-free per-op failure. +/// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): string fallback for peers that predate the typed wire code. Remove after the minimum supported RustFS peer version always sends error_code. +fn control_plane_failure(op: &str, bucket: Option<&str>, error_code: Option, error_info: Option) -> Error { + if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32) { + return Error::RemoteNotInitialized; + } + match error_info { + Some(msg) => Error::other(msg), + None => peer_failure_without_details(op, bucket), + } +} + fn decode_bucket_stats_response(response: GetBucketStatsDataResponse) -> Result { if !response.success { return Err(Error::other( @@ -845,10 +860,12 @@ impl PeerRestClient { let response = client.local_storage_info(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("local_storage_info", None)); + return Err(control_plane_failure( + "local_storage_info", + None, + response.error_code, + response.error_info, + )); } let data = response.storage_info; @@ -1235,11 +1252,10 @@ impl PeerRestClient { Err(status) => return Err(status.into()), }; if !response.success { - return Err(Error::other( - response - .error_info - .unwrap_or_else(|| "peer background heal status failed without an error".to_string()), - )); + return Err(match (response.error_code, response.error_info) { + (None, None) => Error::other("peer background heal status failed without an error"), + (error_code, error_info) => control_plane_failure("background_heal_status", None, error_code, error_info), + }); } Ok(Some(response.bg_heal_state.to_vec())) } @@ -1267,11 +1283,12 @@ impl PeerRestClient { Err(status) => return Err(status.into()), }; if !response.success { - return Err(Error::other( - response - .error_info - .unwrap_or_else(|| "peer replacement recovery status failed without an error".to_string()), - )); + return Err(match (response.error_code, response.error_info) { + (None, None) => Error::other("peer replacement recovery status failed without an error"), + (error_code, error_info) => { + control_plane_failure("replacement_recovery_status", None, error_code, error_info) + } + }); } Ok(Some(response.recovery_status.to_vec())) } @@ -1489,10 +1506,12 @@ impl PeerRestClient { let response = client.load_bucket_metadata(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_bucket_metadata", Some(bucket))); + return Err(control_plane_failure( + "load_bucket_metadata", + Some(bucket), + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1531,10 +1550,7 @@ impl PeerRestClient { let response = client.delete_policy(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("delete_policy", None)); + return Err(control_plane_failure("delete_policy", None, response.error_code, response.error_info)); } Ok(()) } @@ -1554,10 +1570,7 @@ impl PeerRestClient { let response = client.load_policy(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_policy", None)); + return Err(control_plane_failure("load_policy", None, response.error_code, response.error_info)); } Ok(()) } @@ -1579,10 +1592,12 @@ impl PeerRestClient { let response = client.load_policy_mapping(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_policy_mapping", None)); + return Err(control_plane_failure( + "load_policy_mapping", + None, + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1602,10 +1617,7 @@ impl PeerRestClient { let response = client.delete_user(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("delete_user", None)); + return Err(control_plane_failure("delete_user", None, response.error_code, response.error_info)); } Ok(()) } @@ -1625,10 +1637,12 @@ impl PeerRestClient { let response = client.delete_service_account(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("delete_service_account", None)); + return Err(control_plane_failure( + "delete_service_account", + None, + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1649,10 +1663,7 @@ impl PeerRestClient { let response = client.load_user(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_user", None)); + return Err(control_plane_failure("load_user", None, response.error_code, response.error_info)); } Ok(()) } @@ -1672,10 +1683,12 @@ impl PeerRestClient { let response = client.load_service_account(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_service_account", None)); + return Err(control_plane_failure( + "load_service_account", + None, + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1695,10 +1708,7 @@ impl PeerRestClient { let response = client.load_group(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_group", None)); + return Err(control_plane_failure("load_group", None, response.error_code, response.error_info)); } Ok(()) } @@ -1716,10 +1726,12 @@ impl PeerRestClient { let response = client.reload_site_replication_config(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("reload_site_replication_config", None)); + return Err(control_plane_failure( + "reload_site_replication_config", + None, + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1987,10 +1999,7 @@ impl PeerRestClient { let response = client.reload_pool_meta(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("reload_pool_meta", None)); + return Err(control_plane_failure("reload_pool_meta", None, response.error_code, response.error_info)); } Ok(()) @@ -2011,10 +2020,7 @@ impl PeerRestClient { let response = client.stop_rebalance(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("stop_rebalance", None)); + return Err(control_plane_failure("stop_rebalance", None, response.error_code, response.error_info)); } Ok(()) @@ -2045,10 +2051,12 @@ impl PeerRestClient { "peer rebalance metadata response" ); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_rebalance_meta", None)); + return Err(control_plane_failure( + "load_rebalance_meta", + None, + response.error_code, + response.error_info, + )); } Ok(()) @@ -2073,10 +2081,12 @@ impl PeerRestClient { let response = client.start_decommission(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("start_decommission", None)); + return Err(control_plane_failure( + "start_decommission", + None, + response.error_code, + response.error_info, + )); } Ok(()) @@ -2097,10 +2107,12 @@ impl PeerRestClient { let response = client.cancel_decommission(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("decommission_cancel", None)); + return Err(control_plane_failure( + "decommission_cancel", + None, + response.error_code, + response.error_info, + )); } Ok(()) @@ -2121,10 +2133,12 @@ impl PeerRestClient { let response = client.clear_decommission(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("clear_decommission", None)); + return Err(control_plane_failure( + "clear_decommission", + None, + response.error_code, + response.error_info, + )); } Ok(()) @@ -2180,7 +2194,7 @@ impl PeerRestClient { Err(status) => return tier_config_reload_status_outcome(status), }; if !response.success { - return tier_config_reload_remote_failure(response.error_info); + return tier_config_reload_remote_failure(response.error_code, response.error_info); } TierConfigReloadOutcome::Success @@ -2239,7 +2253,13 @@ fn is_tier_config_reload_connection_failure(err: &Error) -> bool { /// reload every `TIER_CONFIG_RELOAD_RETRY_CAP`, and `Terminal` stays reachable /// for transport and gRPC status failures, which is where a genuinely /// unrecoverable peer surfaces. -fn tier_config_reload_remote_failure(error_info: Option) -> TierConfigReloadOutcome { +fn tier_config_reload_remote_failure(error_code: Option, error_info: Option) -> TierConfigReloadOutcome { + // Remote rejections are transient by design (see the doc comment above); + // the typed not-initialized code keeps the error typed for downstream + // classifiers instead of a bare string (backlog#1845). + if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32) { + return TierConfigReloadOutcome::TransientRetrySameChannel(Error::RemoteNotInitialized); + } TierConfigReloadOutcome::TransientRetrySameChannel(Error::other(error_info.unwrap_or_default())) } @@ -2278,6 +2298,63 @@ mod tests { use temp_env::async_with_vars; use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt}; + #[test] + fn control_plane_failure_prefers_typed_not_initialized_code() { + use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode; + let code = Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32); + + // Typed code wins even when the legacy string is present (dual-write). + let err = control_plane_failure("load_bucket_metadata", Some("b"), code, Some("errServerNotInitialized".to_string())); + assert!(matches!(err, Error::RemoteNotInitialized)); + assert!(crate::error::is_err_not_initialized(&err), "typed variant must satisfy the predicate"); + + // Legacy peers: no code, string only — the substring fallback still classifies. + let err = control_plane_failure("load_bucket_metadata", Some("b"), None, Some("errServerNotInitialized".to_string())); + assert!(crate::error::is_err_not_initialized(&err), "legacy string form must keep classifying"); + + // No code, no string: detail-free per-op failure, not misread as not-initialized. + let err = control_plane_failure("load_bucket_metadata", Some("b"), None, None); + assert!(!crate::error::is_err_not_initialized(&err)); + assert!(err.to_string().contains("load_bucket_metadata")); + + // Unspecified code behaves like no code. + let err = control_plane_failure( + "load_bucket_metadata", + None, + Some(ControlPlaneErrorCode::ControlPlaneErrorUnspecified as i32), + Some("boom".to_string()), + ); + assert!(!matches!(err, Error::RemoteNotInitialized)); + assert_eq!(err.to_string(), "Io error: boom"); + } + + #[test] + fn control_plane_not_initialized_wire_value_is_pinned() { + // The discriminant is wire contract: old peers ignore it, but a renumber + // would silently flip classification on mixed-version clusters. + use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode; + assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorUnspecified as i32, 0); + assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32, 1); + } + + #[test] + fn tier_config_reload_remote_failure_keeps_typed_not_initialized() { + use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode; + let outcome = tier_config_reload_remote_failure( + Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), + Some("errServerNotInitialized".to_string()), + ); + match outcome { + TierConfigReloadOutcome::TransientRetrySameChannel(err) => { + assert!(matches!(err, Error::RemoteNotInitialized)); + } + TierConfigReloadOutcome::TransientReconnect(err) | TierConfigReloadOutcome::Terminal(err) => { + panic!("not-initialized must stay retry-same-channel, got {err}") + } + TierConfigReloadOutcome::Success => panic!("a rejection cannot classify as success"), + } + } + #[test] fn scanner_publication_lease_response_rejects_stale_generation_and_session() { let token = Uuid::new_v4(); @@ -3031,11 +3108,11 @@ mod tests { // retired: the channel is healthy, so the rejection reflects remote state // that the next attempt can find healed. assert!(matches!( - tier_config_reload_remote_failure(Some("backend unavailable".to_string())), + tier_config_reload_remote_failure(None, Some("backend unavailable".to_string())), TierConfigReloadOutcome::TransientRetrySameChannel(_) )); assert!(matches!( - tier_config_reload_remote_failure(Some("errServerNotInitialized".to_string())), + tier_config_reload_remote_failure(None, Some("errServerNotInitialized".to_string())), TierConfigReloadOutcome::TransientRetrySameChannel(_) )); assert!(matches!( @@ -3080,7 +3157,7 @@ mod tests { ] { assert!( matches!( - tier_config_reload_remote_failure(Some(error_info.to_string())), + tier_config_reload_remote_failure(None, Some(error_info.to_string())), TierConfigReloadOutcome::TransientRetrySameChannel(_) ), "a peer that rejected the apply must stay retryable so it converges: {error_info}" @@ -3089,7 +3166,7 @@ mod tests { // An absent error message is still a rejection, not a reason to stop. assert!(matches!( - tier_config_reload_remote_failure(None), + tier_config_reload_remote_failure(None, None), TierConfigReloadOutcome::TransientRetrySameChannel(_) )); diff --git a/crates/ecstore/src/error/mod.rs b/crates/ecstore/src/error/mod.rs index a7e78d972..e7e50deb1 100644 --- a/crates/ecstore/src/error/mod.rs +++ b/crates/ecstore/src/error/mod.rs @@ -228,6 +228,14 @@ pub enum StorageError { /// during quorum aggregation (backlog#1845). #[error("remote rpc client unavailable: {0}")] RemoteClientUnavailable(String), + + /// A peer answered a control-plane RPC but its storage/IAM layer is not + /// initialized yet. Typed form of the legacy "errServerNotInitialized" + /// error_info string (backlog#1845); the wire carries it as + /// `ControlPlaneErrorCode::ControlPlaneErrorNotInitialized` alongside the + /// legacy string for rolling-upgrade compatibility. + #[error("remote peer not initialized")] + RemoteNotInitialized, } impl From for StorageError { @@ -581,6 +589,7 @@ impl Clone for StorageError { limit: *limit, }, StorageError::RemoteClientUnavailable(detail) => StorageError::RemoteClientUnavailable(detail.clone()), + StorageError::RemoteNotInitialized => StorageError::RemoteNotInitialized, } } } @@ -670,6 +679,7 @@ impl StorageError { StorageError::NamespaceLockQuorumUnavailable { .. } => StorageErrorCode::NamespaceLockQuorumUnavailable, StorageError::QuotaExceeded { .. } => StorageErrorCode::QuotaExceeded, StorageError::RemoteClientUnavailable(_) => StorageErrorCode::RemoteClientUnavailable, + StorageError::RemoteNotInitialized => StorageErrorCode::RemoteNotInitialized, } } @@ -800,6 +810,7 @@ impl StorageError { limit: Default::default(), }), StorageErrorCode::RemoteClientUnavailable => Some(StorageError::RemoteClientUnavailable(Default::default())), + StorageErrorCode::RemoteNotInitialized => Some(StorageError::RemoteNotInitialized), } } } @@ -943,7 +954,13 @@ pub fn is_err_operation_canceled(err: &Error) -> bool { #[allow(dead_code, reason = "predicate asserted by this file's tests (backlog#1823)")] pub fn is_err_not_initialized(err: &Error) -> bool { - err.to_string().contains("errServerNotInitialized") || err.to_string().contains("ServerNotInitialized") + // Typed-first: peers at or above the ControlPlaneErrorCode change decode to + // the typed variant. The substring form only matches legacy peers' string + // responses. + // RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): substring fallback for peers that predate the typed wire code. Remove after the minimum supported RustFS peer version always sends error_code. + matches!(err, StorageError::RemoteNotInitialized) + || err.to_string().contains("errServerNotInitialized") + || err.to_string().contains("ServerNotInitialized") } /// Strict "not found" predicate that only matches genuine object/version/volume diff --git a/crates/protos/src/generated/proto_gen/node_service.rs b/crates/protos/src/generated/proto_gen/node_service.rs index cb5d4a582..ceaaa7a95 100644 --- a/crates/protos/src/generated/proto_gen/node_service.rs +++ b/crates/protos/src/generated/proto_gen/node_service.rs @@ -142,7 +142,8 @@ pub struct DeleteRequest { pub path: ::prost::alloc::string::String, #[prost(string, tag = "4")] pub options: ::prost::alloc::string::String, - /// Optional scanner publication lease token. + /// Optional scanner publication lease token. When present, the target binds + /// the complete delete operation to its movement read admission. #[prost(bytes = "bytes", tag = "5")] pub scanner_publication_lease_token: ::prost::bytes::Bytes, } @@ -396,6 +397,9 @@ pub struct RenameDataRequest { pub dst_path: ::prost::alloc::string::String, #[prost(bytes = "bytes", tag = "7")] pub file_info_bin: ::prost::bytes::Bytes, + /// Optional target-side scanner publication lease. Empty preserves the + /// legacy rename request body; a non-empty token is checked at the target's + /// rename linearization point. #[prost(bytes = "bytes", tag = "8")] pub scanner_publication_lease_token: ::prost::bytes::Bytes, } @@ -719,7 +723,7 @@ pub struct DeleteVersionsRequest { #[prost(bytes = "bytes", tag = "6")] pub opts_bin: ::prost::bytes::Bytes, } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct DeleteVersionsResponse { #[prost(bool, tag = "1")] pub success: bool, @@ -841,6 +845,8 @@ pub struct LocalStorageInfoResponse { pub storage_info: ::prost::bytes::Bytes, #[prost(string, optional, tag = "3")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "4")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ServerInfoRequest { @@ -1043,6 +1049,8 @@ pub struct LoadBucketMetadataResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct DeleteBucketMetadataRequest { @@ -1067,6 +1075,8 @@ pub struct DeletePolicyResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadPolicyRequest { @@ -1079,6 +1089,8 @@ pub struct LoadPolicyResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadPolicyMappingRequest { @@ -1095,6 +1107,8 @@ pub struct LoadPolicyMappingResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct DeleteUserRequest { @@ -1107,6 +1121,8 @@ pub struct DeleteUserResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct DeleteServiceAccountRequest { @@ -1119,6 +1135,8 @@ pub struct DeleteServiceAccountResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadUserRequest { @@ -1133,6 +1151,8 @@ pub struct LoadUserResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadServiceAccountRequest { @@ -1145,6 +1165,8 @@ pub struct LoadServiceAccountResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadGroupRequest { @@ -1157,6 +1179,8 @@ pub struct LoadGroupResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ReloadSiteReplicationConfigRequest {} @@ -1166,6 +1190,8 @@ pub struct ReloadSiteReplicationConfigResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct SignalServiceRequest { @@ -1218,11 +1244,17 @@ pub struct ScannerActivityResponse { pub dirty_usage_generation: u64, #[prost(bool, tag = "9")] pub dirty_usage_pending: bool, + /// v7 fields. They are optional so v6 peers can continue to decode the + /// response shape while newer readers fail closed when they are absent. #[prost(uint64, optional, tag = "10")] pub movement_generation: ::core::option::Option, #[prost(bool, optional, tag = "11")] pub publication_blocked: ::core::option::Option, } +/// A short-lived storage-owned read admission used only around a final +/// scanner metadata publication. It is intentionally separate from the +/// ScannerActivity observation wire so v6/v7 rolling compatibility remains +/// unchanged. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ScannerPublicationLeaseRequest { #[prost(bytes = "bytes", tag = "1")] @@ -1231,8 +1263,14 @@ pub struct ScannerPublicationLeaseRequest { pub expected_movement_generation: u64, #[prost(uint64, tag = "3")] pub ttl_ms: u64, + /// The activity instance is a process session nonce. It is intentionally + /// separate from the storage-owned deployment identity returned by the + /// lease response so a restart cannot reuse an old session token. #[prost(string, tag = "4")] pub expected_session_id: ::prost::alloc::string::String, + /// A non-empty token turns the acquire RPC into an in-place validation of an + /// existing lease. Keeping this on the existing RPC lets old peers reject + /// the proof without changing the v7 activity wire shape. #[prost(bytes = "bytes", tag = "5")] pub token: ::prost::bytes::Bytes, } @@ -1288,6 +1326,8 @@ pub struct BackgroundHealStatusResponse { pub bg_heal_state: ::prost::bytes::Bytes, #[prost(string, optional, tag = "3")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "4")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ReplacementRecoveryStatusRequest {} @@ -1299,6 +1339,8 @@ pub struct ReplacementRecoveryStatusResponse { pub recovery_status: ::prost::bytes::Bytes, #[prost(string, optional, tag = "3")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "4")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct HealControlRequest { @@ -1356,6 +1398,8 @@ pub struct ReloadPoolMetaResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct StopRebalanceRequest { @@ -1368,6 +1412,8 @@ pub struct StopRebalanceResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadRebalanceMetaRequest { @@ -1380,6 +1426,8 @@ pub struct LoadRebalanceMetaResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct StartDecommissionRequest { @@ -1392,6 +1440,8 @@ pub struct StartDecommissionResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct CancelDecommissionRequest { @@ -1404,6 +1454,8 @@ pub struct CancelDecommissionResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ClearDecommissionRequest { @@ -1416,6 +1468,8 @@ pub struct ClearDecommissionResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadTransitionTierConfigRequest {} @@ -1425,6 +1479,8 @@ pub struct LoadTransitionTierConfigResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct TierMutationPrepareRequest { @@ -1486,6 +1542,38 @@ pub struct GetLiveEventsResponse { #[prost(string, optional, tag = "5")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, } +/// Typed control-plane error discriminants carried alongside the legacy +/// error_info string on control-plane responses. Rolling-upgrade compat: +/// old peers ignore the field and keep reading error_info. +/// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): legacy string dual-write. Remove after the minimum supported RustFS peer version always sends error_code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ControlPlaneErrorCode { + ControlPlaneErrorUnspecified = 0, + /// The peer answered but its storage/IAM layer is not initialized yet + /// (legacy string form: "errServerNotInitialized"). + ControlPlaneErrorNotInitialized = 1, +} +impl ControlPlaneErrorCode { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::ControlPlaneErrorUnspecified => "CONTROL_PLANE_ERROR_UNSPECIFIED", + Self::ControlPlaneErrorNotInitialized => "CONTROL_PLANE_ERROR_NOT_INITIALIZED", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "CONTROL_PLANE_ERROR_UNSPECIFIED" => Some(Self::ControlPlaneErrorUnspecified), + "CONTROL_PLANE_ERROR_NOT_INITIALIZED" => Some(Self::ControlPlaneErrorNotInitialized), + _ => None, + } + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum TierMutationPeerState { @@ -3301,16 +3389,12 @@ pub mod node_service_server { ) -> std::result::Result, tonic::Status>; async fn acquire_scanner_publication_lease( &self, - _request: tonic::Request, - ) -> std::result::Result, tonic::Status> { - Err(tonic::Status::unimplemented("scanner publication leases are unsupported")) - } + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; async fn release_scanner_publication_lease( &self, - _request: tonic::Request, - ) -> std::result::Result, tonic::Status> { - Err(tonic::Status::unimplemented("scanner publication leases are unsupported")) - } + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; async fn background_heal_status( &self, request: tonic::Request, diff --git a/crates/protos/src/node.proto b/crates/protos/src/node.proto index 26cc3ffed..a51ab51dc 100644 --- a/crates/protos/src/node.proto +++ b/crates/protos/src/node.proto @@ -21,6 +21,17 @@ message Error { string error_info = 2; } +// Typed control-plane error discriminants carried alongside the legacy +// error_info string on control-plane responses. Rolling-upgrade compat: +// old peers ignore the field and keep reading error_info. +// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): legacy string dual-write. Remove after the minimum supported RustFS peer version always sends error_code. +enum ControlPlaneErrorCode { + CONTROL_PLANE_ERROR_UNSPECIFIED = 0; + // The peer answered but its storage/IAM layer is not initialized yet + // (legacy string form: "errServerNotInitialized"). + CONTROL_PLANE_ERROR_NOT_INITIALIZED = 1; +} + message PingRequest { uint64 version = 1; bytes body = 2; @@ -580,6 +591,7 @@ message LocalStorageInfoResponse { bool success = 1; bytes storage_info = 2; optional string error_info = 3; + optional ControlPlaneErrorCode error_code = 4; } message ServerInfoRequest { @@ -726,6 +738,7 @@ message LoadBucketMetadataRequest { message LoadBucketMetadataResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message DeleteBucketMetadataRequest { @@ -744,6 +757,7 @@ message DeletePolicyRequest { message DeletePolicyResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadPolicyRequest { @@ -753,6 +767,7 @@ message LoadPolicyRequest { message LoadPolicyResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadPolicyMappingRequest { @@ -764,6 +779,7 @@ message LoadPolicyMappingRequest { message LoadPolicyMappingResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message DeleteUserRequest { @@ -773,6 +789,7 @@ message DeleteUserRequest { message DeleteUserResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message DeleteServiceAccountRequest { @@ -782,6 +799,7 @@ message DeleteServiceAccountRequest { message DeleteServiceAccountResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadUserRequest { @@ -792,6 +810,7 @@ message LoadUserRequest { message LoadUserResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadServiceAccountRequest { @@ -801,6 +820,7 @@ message LoadServiceAccountRequest { message LoadServiceAccountResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadGroupRequest { @@ -810,6 +830,7 @@ message LoadGroupRequest { message LoadGroupResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message ReloadSiteReplicationConfigRequest {} @@ -817,6 +838,7 @@ message ReloadSiteReplicationConfigRequest {} message ReloadSiteReplicationConfigResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message SignalServiceRequest { @@ -907,6 +929,7 @@ message BackgroundHealStatusResponse { bool success = 1; bytes bg_heal_state = 2; optional string error_info = 3; + optional ControlPlaneErrorCode error_code = 4; } message ReplacementRecoveryStatusRequest {} @@ -915,6 +938,7 @@ message ReplacementRecoveryStatusResponse { bool success = 1; bytes recovery_status = 2; optional string error_info = 3; + optional ControlPlaneErrorCode error_code = 4; } message HealControlRequest { @@ -955,6 +979,7 @@ message ReloadPoolMetaRequest {} message ReloadPoolMetaResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message StopRebalanceRequest { @@ -964,6 +989,7 @@ message StopRebalanceRequest { message StopRebalanceResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadRebalanceMetaRequest { @@ -973,6 +999,7 @@ message LoadRebalanceMetaRequest { message LoadRebalanceMetaResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message StartDecommissionRequest { @@ -982,6 +1009,7 @@ message StartDecommissionRequest { message StartDecommissionResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message CancelDecommissionRequest { @@ -991,6 +1019,7 @@ message CancelDecommissionRequest { message CancelDecommissionResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message ClearDecommissionRequest { @@ -1000,6 +1029,7 @@ message ClearDecommissionRequest { message ClearDecommissionResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadTransitionTierConfigRequest {} @@ -1007,6 +1037,7 @@ message LoadTransitionTierConfigRequest {} message LoadTransitionTierConfigResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message TierMutationPrepareRequest { diff --git a/crates/storage-api/src/error.rs b/crates/storage-api/src/error.rs index 49844bf5a..85873ae8b 100644 --- a/crates/storage-api/src/error.rs +++ b/crates/storage-api/src/error.rs @@ -105,6 +105,7 @@ pub enum StorageErrorCode { InvalidPath, QuotaExceeded, RemoteClientUnavailable, + RemoteNotInitialized, } impl StorageErrorCode { @@ -192,6 +193,7 @@ impl StorageErrorCode { Self::InvalidPath => 0x52, Self::QuotaExceeded => 0x53, Self::RemoteClientUnavailable => 0x54, + Self::RemoteNotInitialized => 0x55, } } @@ -279,6 +281,7 @@ impl StorageErrorCode { 0x52 => Some(Self::InvalidPath), 0x53 => Some(Self::QuotaExceeded), 0x54 => Some(Self::RemoteClientUnavailable), + 0x55 => Some(Self::RemoteNotInitialized), _ => None, } } @@ -355,6 +358,7 @@ mod tests { (StorageErrorCode::NamespaceLockQuorumUnavailable, 0x42), (StorageErrorCode::QuotaExceeded, 0x53), (StorageErrorCode::RemoteClientUnavailable, 0x54), + (StorageErrorCode::RemoteNotInitialized, 0x55), ]; const DISK_PRESERVATION_ERROR_CODES: &[(StorageErrorCode, u32)] = &[ diff --git a/docs/architecture/compat-cleanup-register.md b/docs/architecture/compat-cleanup-register.md index 61caadf17..e40a8fade 100644 --- a/docs/architecture/compat-cleanup-register.md +++ b/docs/architecture/compat-cleanup-register.md @@ -34,6 +34,7 @@ for later deletion. - `tonic-013-status-render` peer RPC failure classification: internode failures that reach a node only as text (a peer's error_info payload, a status flattened through format!) are classified by matching the rendering of an Unavailable gRPC status. Releases up to 1.0.0-alpha.38 shipped tonic 0.13, which rendered that status as "status: Unavailable, message: ..."; tonic 0.14 renders it as "code: 'The service is currently unavailable', message: ...". Both forms are matched so an older peer's relayed text still marks an unreachable peer offline. Remove the tonic 0.13 form after the minimum supported RustFS peer version ships tonic 0.14 or later. - `rustfs-5063` pre-beta.9 Local KMS recovery: persisted Local KMS configs from beta.8 and earlier predate the explicit insecure-development flag, and encrypted key files use the legacy SHA-256 KDF. Remove the config fallback after supported upgrades have rewritten or explicitly resaved all pre-beta.9 configs with the development-default field, and remove the legacy KDF after supported upgrades have rewritten all pre-beta.9 Local KMS key files with explicit at-rest protection. - `sse-local-dek-json-v1` legacy local SSE DEK decoding: releases before the JSON envelope wrote wrapped DEKs as `base64(nonce):base64(ciphertext)`, so readers retain that decoder while all new writes use the versioned JSON envelope. Remove the colon decoder after the minimum supported direct-upgrade release writes JSON envelopes and migration tooling has rewritten every retained legacy object. +- `not-initialized-error-code-v1` typed control-plane not-initialized wire code: control-plane RPC responses historically signaled an uninitialized peer only through the literal error_info string "errServerNotInitialized" (one drift site says "storage layer not initialized"). Responses now dual-carry a typed ControlPlaneErrorCode beside the legacy string, and clients prefer the code; the string stays populated and the client substring fallback (is_err_not_initialized, control_plane_failure) stays in place so mixed-version clusters keep classifying older peers' responses. Remove the substring fallback (and stop populating error_info for this case) after the minimum supported RustFS peer version always sends error_code. - `multipart-compression-default-off-window` staged multipart disk-compression rollout: releases before the resumable legacy decompressor fail transient reads of compressed objects under mid-payload suspension, so multipart uploads advertise the compression marker only when RUSTFS_COMPRESSION_MULTIPART_ENABLED is set in addition to RUSTFS_COMPRESSION_ENABLED, keeping rolling upgrades from creating new compressed multipart objects while pre-fix nodes may still serve reads. Flip the default to enabled (and retire the extra switch) after the minimum supported direct-upgrade release ships the resumable decompressor. ## Review Checklist diff --git a/rustfs/src/storage/rpc/node_service.rs b/rustfs/src/storage/rpc/node_service.rs index 3f28c39ce..b9dfac111 100644 --- a/rustfs/src/storage/rpc/node_service.rs +++ b/rustfs/src/storage/rpc/node_service.rs @@ -385,10 +385,12 @@ fn background_rebalance_start_error_message(result: StorageResult<()>) -> Option fn stop_rebalance_response(result: StorageResult<()>) -> StopRebalanceResponse { match result { Ok(_) => StopRebalanceResponse { + error_code: None, success: true, error_info: None, }, Err(err) => StopRebalanceResponse { + error_code: None, success: false, error_info: Some(err.to_string()), }, @@ -1498,6 +1500,7 @@ impl Node for NodeService { let policy = request.policy_name; if policy.is_empty() { return Ok(Response::new(DeletePolicyResponse { + error_code: None, success: false, error_info: Some("policy name is missing".to_string()), })); @@ -1507,17 +1510,20 @@ impl Node for NodeService { return Ok(Response::new(DeletePolicyResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.delete_policy(&policy, false).await; if let Err(err) = resp { return Ok(Response::new(DeletePolicyResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(DeletePolicyResponse { + error_code: None, success: true, error_info: None, })) @@ -1529,6 +1535,7 @@ impl Node for NodeService { let policy = request.policy_name; if policy.is_empty() { return Ok(Response::new(LoadPolicyResponse { + error_code: None, success: false, error_info: Some("policy name is missing".to_string()), })); @@ -1537,17 +1544,20 @@ impl Node for NodeService { return Ok(Response::new(LoadPolicyResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.load_policy(&policy).await; if let Err(err) = resp { return Ok(Response::new(LoadPolicyResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadPolicyResponse { + error_code: None, success: true, error_info: None, })) @@ -1562,12 +1572,14 @@ impl Node for NodeService { let user_or_group = request.user_or_group; if user_or_group.is_empty() { return Ok(Response::new(LoadPolicyMappingResponse { + error_code: None, success: false, error_info: Some("user_or_group name is missing".to_string()), })); } let Some(user_type) = UserType::from_u64(request.user_type) else { return Ok(Response::new(LoadPolicyMappingResponse { + error_code: None, success: false, error_info: Some("invalid user type".to_string()), })); @@ -1577,16 +1589,19 @@ impl Node for NodeService { return Ok(Response::new(LoadPolicyMappingResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.load_policy_mapping(&user_or_group, user_type, is_group).await; if let Err(err) = resp { return Ok(Response::new(LoadPolicyMappingResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadPolicyMappingResponse { + error_code: None, success: true, error_info: None, })) @@ -1598,6 +1613,7 @@ impl Node for NodeService { let access_key = request.access_key; if access_key.is_empty() { return Ok(Response::new(DeleteUserResponse { + error_code: None, success: false, error_info: Some("access_key name is missing".to_string()), })); @@ -1606,17 +1622,20 @@ impl Node for NodeService { return Ok(Response::new(DeleteUserResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.delete_user(&access_key, false).await; if let Err(err) = resp { return Ok(Response::new(DeleteUserResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(DeleteUserResponse { + error_code: None, success: true, error_info: None, })) @@ -1631,6 +1650,7 @@ impl Node for NodeService { let access_key = request.access_key; if access_key.is_empty() { return Ok(Response::new(DeleteServiceAccountResponse { + error_code: None, success: false, error_info: Some("access_key name is missing".to_string()), })); @@ -1644,6 +1664,7 @@ impl Node for NodeService { return Ok(Response::new(DeleteServiceAccountResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; // This legacy RPC is a cache notification. Reloading shared state keeps a @@ -1651,11 +1672,13 @@ impl Node for NodeService { let resp = iam_sys.load_service_account(&access_key).await; if let Err(err) = resp { return Ok(Response::new(DeleteServiceAccountResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(DeleteServiceAccountResponse { + error_code: None, success: true, error_info: None, })) @@ -1668,6 +1691,7 @@ impl Node for NodeService { let temp = request.temp; if access_key.is_empty() { return Ok(Response::new(LoadUserResponse { + error_code: None, success: false, error_info: Some("access_key name is missing".to_string()), })); @@ -1677,6 +1701,7 @@ impl Node for NodeService { return Ok(Response::new(LoadUserResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -1685,12 +1710,14 @@ impl Node for NodeService { let resp = iam_sys.load_user(&access_key, user_type).await; if let Err(err) = resp { return Ok(Response::new(LoadUserResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadUserResponse { + error_code: None, success: true, error_info: None, })) @@ -1705,6 +1732,7 @@ impl Node for NodeService { let access_key = request.access_key; if access_key.is_empty() { return Ok(Response::new(LoadServiceAccountResponse { + error_code: None, success: false, error_info: Some("access_key name is missing".to_string()), })); @@ -1714,18 +1742,21 @@ impl Node for NodeService { return Ok(Response::new(LoadServiceAccountResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.load_service_account(&access_key).await; if let Err(err) = resp { return Ok(Response::new(LoadServiceAccountResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadServiceAccountResponse { + error_code: None, success: true, error_info: None, })) @@ -1737,6 +1768,7 @@ impl Node for NodeService { let group = request.group; if group.is_empty() { return Ok(Response::new(LoadGroupResponse { + error_code: None, success: false, error_info: Some("group name is missing".to_string()), })); @@ -1746,17 +1778,20 @@ impl Node for NodeService { return Ok(Response::new(LoadGroupResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.load_group(&group).await; if let Err(err) = resp { return Ok(Response::new(LoadGroupResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadGroupResponse { + error_code: None, success: true, error_info: None, })) @@ -1771,14 +1806,17 @@ impl Node for NodeService { return Ok(Response::new(ReloadSiteReplicationConfigResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; match reload_site_replication_runtime_state().await { Ok(()) => Ok(Response::new(ReloadSiteReplicationConfigResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(ReloadSiteReplicationConfigResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2098,6 +2136,7 @@ impl Node for NodeService { success: false, bg_heal_state: Bytes::new(), error_info: Some("storage layer not initialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); } let snapshot = heal::capture_node_heal_status(rustfs_scanner::scanner::BackgroundHealInfo::default()).await; @@ -2106,11 +2145,13 @@ impl Node for NodeService { success: true, bg_heal_state: bg_heal_state.into(), error_info: None, + error_code: None, })), Err(err) => Ok(Response::new(BackgroundHealStatusResponse { success: false, bg_heal_state: Bytes::new(), error_info: Some(err), + error_code: None, })), } } @@ -2124,16 +2165,19 @@ impl Node for NodeService { success: false, recovery_status: Bytes::new(), error_info: Some("storage layer not initialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); } let snapshot = heal::capture_node_replacement_recovery_status().await; match heal::encode_node_replacement_recovery_status(&snapshot) { Ok(recovery_status) => Ok(Response::new(ReplacementRecoveryStatusResponse { + error_code: None, success: true, recovery_status: recovery_status.into(), error_info: None, })), Err(err) => Ok(Response::new(ReplacementRecoveryStatusResponse { + error_code: None, success: false, recovery_status: Bytes::new(), error_info: Some(err), @@ -2164,6 +2208,7 @@ impl Node for NodeService { return Ok(Response::new(ReloadPoolMetaResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; // Recover missing workers only after the reload merged newer state; a @@ -2171,19 +2216,23 @@ impl Node for NodeService { match store.reload_pool_meta().await { Ok(true) => match store.spawn_missing_local_decommission_routines().await { Ok(_) => Ok(Response::new(ReloadPoolMetaResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(ReloadPoolMetaResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), }, Ok(false) => Ok(Response::new(ReloadPoolMetaResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(ReloadPoolMetaResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2196,6 +2245,7 @@ impl Node for NodeService { return Ok(Response::new(StopRebalanceResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2219,6 +2269,7 @@ impl Node for NodeService { return Ok(Response::new(LoadRebalanceMetaResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2242,6 +2293,7 @@ impl Node for NodeService { "node rpc background task failed" ); return Ok(Response::new(LoadRebalanceMetaResponse { + error_code: None, success: false, error_info: Some(message), })); @@ -2249,6 +2301,7 @@ impl Node for NodeService { } Ok(Response::new(LoadRebalanceMetaResponse { + error_code: None, success: true, error_info: None, })) @@ -2263,6 +2316,7 @@ impl Node for NodeService { return Ok(Response::new(StartDecommissionResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2276,10 +2330,12 @@ impl Node for NodeService { match store.decommission(CancellationToken::new(), indices).await { Ok(()) => Ok(Response::new(StartDecommissionResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(StartDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2295,6 +2351,7 @@ impl Node for NodeService { return Ok(Response::new(CancelDecommissionResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2302,6 +2359,7 @@ impl Node for NodeService { .map_err(|_| Status::invalid_argument("decommission pool index exceeds local range"))?; if let Err(err) = ensure_rpc_decommission_local_leader(&store, idx) { return Ok(Response::new(CancelDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); @@ -2309,10 +2367,12 @@ impl Node for NodeService { match store.decommission_cancel(idx).await { Ok(()) => Ok(Response::new(CancelDecommissionResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(CancelDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2328,6 +2388,7 @@ impl Node for NodeService { return Ok(Response::new(ClearDecommissionResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2335,6 +2396,7 @@ impl Node for NodeService { .map_err(|_| Status::invalid_argument("decommission pool index exceeds local range"))?; if let Err(err) = ensure_rpc_decommission_local_leader(&store, idx) { return Ok(Response::new(ClearDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); @@ -2342,10 +2404,12 @@ impl Node for NodeService { match store.clear_decommission(idx).await { Ok(()) => Ok(Response::new(ClearDecommissionResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(ClearDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2361,15 +2425,18 @@ impl Node for NodeService { return Ok(Response::new(LoadTransitionTierConfigResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; match reload_transition_tier_config(store).await { Ok(_) => Ok(Response::new(LoadTransitionTierConfigResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(LoadTransitionTierConfigResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), diff --git a/rustfs/src/storage/rpc/node_service/bucket.rs b/rustfs/src/storage/rpc/node_service/bucket.rs index 45e5ee248..60d69c119 100644 --- a/rustfs/src/storage/rpc/node_service/bucket.rs +++ b/rustfs/src/storage/rpc/node_service/bucket.rs @@ -60,6 +60,7 @@ impl NodeService { let bucket = request.bucket; if bucket.is_empty() { return Ok(Response::new(LoadBucketMetadataResponse { + error_code: None, success: false, error_info: Some("bucket name is missing".to_string()), })); @@ -69,6 +70,7 @@ impl NodeService { return Ok(Response::new(LoadBucketMetadataResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -78,11 +80,13 @@ impl NodeService { rustfs_scanner::record_scanner_maintenance_change(&bucket); } Ok(Response::new(LoadBucketMetadataResponse { + error_code: None, success: true, error_info: None, })) } Err(err) => Ok(Response::new(LoadBucketMetadataResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), diff --git a/rustfs/src/storage/rpc/node_service/health.rs b/rustfs/src/storage/rpc/node_service/health.rs index b6b10b0d1..6bd2e4430 100644 --- a/rustfs/src/storage/rpc/node_service/health.rs +++ b/rustfs/src/storage/rpc/node_service/health.rs @@ -228,17 +228,20 @@ impl NodeService { success: false, storage_info: Bytes::new(), error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let info = StorageAdminApi::local_storage_info(store.as_ref()).await; match encode_msgpack_map(&info) { Ok(buf) => Ok(Response::new(LocalStorageInfoResponse { + error_code: None, success: true, storage_info: buf.into(), error_info: None, })), Err(err) => Ok(Response::new(LocalStorageInfoResponse { + error_code: None, success: false, storage_info: Bytes::new(), error_info: Some(err.to_string()), From 4154a3b7caa7d94942731ccd4cb8bb4b174338e7 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Thu, 27 Aug 2026 03:15:50 +0800 Subject: [PATCH 19/54] refactor(admin): route IAM policy and group auth through the shared gate (#6685) --- rustfs/src/admin/handlers/group.rs | 171 ++++++++++-------- rustfs/src/admin/handlers/policies.rs | 241 ++++++++++++++------------ 2 files changed, 232 insertions(+), 180 deletions(-) diff --git a/rustfs/src/admin/handlers/group.rs b/rustfs/src/admin/handlers/group.rs index 93e440fbb..5c8d220b9 100644 --- a/rustfs/src/admin/handlers/group.rs +++ b/rustfs/src/admin/handlers/group.rs @@ -16,13 +16,13 @@ use super::iam_error::iam_error_to_s3_error; use crate::{ admin::runtime_sources::current_action_credentials, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, handlers::site_replication::site_replication_iam_change_hook, router::{AdminOperation, Operation, S3Router}, utils::has_space_be, }, - auth::{check_key_valid, constant_time_eq, get_session_token}, - server::{ADMIN_PREFIX, RemoteAddr}, + auth::constant_time_eq, + server::ADMIN_PREFIX, }; use http::{HeaderMap, StatusCode}; use hyper::Method; @@ -98,22 +98,11 @@ impl Operation for ListGroups { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListGroupsAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListGroupsAdminAction)]).await?; let Ok(iam_store) = crate::admin::runtime_sources::current_ready_iam_handle() else { return Err(s3_error!(InternalError, "iam is not initialized")); @@ -154,22 +143,11 @@ impl Operation for GetGroup { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::GetGroupAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::GetGroupAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -237,22 +215,11 @@ impl Operation for DeleteGroup { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::RemoveUserFromGroupAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::RemoveUserFromGroupAdminAction)]).await?; let group = decode_delete_group_name(¶ms)?; @@ -363,22 +330,11 @@ impl Operation for SetGroupStatus { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::EnableGroupAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::EnableGroupAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -488,22 +444,11 @@ impl Operation for UpdateGroupMembers { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::AddUserToGroupAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::AddUserToGroupAdminAction)]).await?; let mut input = req.input; let body = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await { @@ -673,8 +618,32 @@ impl Operation for UpdateGroupMembers { #[cfg(test)] mod tests { use super::*; + use http::Uri; use matchit::Router; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a group admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + fn with_delete_group_params(path: &str, f: impl FnOnce(&Params<'_, '_>) -> T) -> T { let mut router = Router::new(); router @@ -727,4 +696,60 @@ mod tests { assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); assert_eq!(err.message(), Some("group name contains invalid characters")); } + + #[tokio::test] + async fn group_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&ListGroups {}, Method::GET, "/rustfs/admin/v3/groups").await; + assert_missing_credentials(&GetGroup {}, Method::GET, "/rustfs/admin/v3/group").await; + assert_missing_credentials(&DeleteGroup {}, Method::DELETE, "/rustfs/admin/v3/group/dev").await; + assert_missing_credentials(&SetGroupStatus {}, Method::PUT, "/rustfs/admin/v3/set-group-status").await; + assert_missing_credentials(&UpdateGroupMembers {}, Method::PUT, "/rustfs/admin/v3/update-group-members").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nasync fn ", "\npub(crate) async fn ", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + #[test] + fn group_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("group.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action) in [ + ("ListGroups", "ListGroupsAdminAction"), + ("GetGroup", "GetGroupAdminAction"), + ("DeleteGroup", "RemoveUserFromGroupAdminAction"), + ("SetGroupStatus", "EnableGroupAdminAction"), + ("UpdateGroupMembers", "AddUserToGroupAdminAction"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{handler} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + 1, + "{handler} must request exactly one admin action" + ); + assert!( + block.contains(&format!("AdminAction::{action}")), + "{handler} must authorize with {action}" + ); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + } } diff --git a/rustfs/src/admin/handlers/policies.rs b/rustfs/src/admin/handlers/policies.rs index 19bbee516..fdc57baa6 100644 --- a/rustfs/src/admin/handlers/policies.rs +++ b/rustfs/src/admin/handlers/policies.rs @@ -16,13 +16,12 @@ use super::iam_error::iam_error_to_s3_error; use crate::{ admin::runtime_sources::current_action_credentials, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, handlers::site_replication::site_replication_iam_change_hook, router::{AdminOperation, Operation, S3Router}, utils::{encode_compatible_admin_payload, has_space_be, read_compatible_admin_body}, }, - auth::{check_key_valid, get_session_token}, - server::{ADMIN_PREFIX, RemoteAddr}, + server::ADMIN_PREFIX, }; use http::{HeaderMap, StatusCode}; use hyper::Method; @@ -117,22 +116,11 @@ pub struct ListCannedPolicies {} #[async_trait::async_trait] impl Operation for ListCannedPolicies { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListUserPoliciesAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListUserPoliciesAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -184,22 +172,11 @@ pub struct AddCannedPolicy {} #[async_trait::async_trait] impl Operation for AddCannedPolicy { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::CreatePolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::CreatePolicyAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -304,22 +281,11 @@ pub struct InfoCannedPolicy {} #[async_trait::async_trait] impl Operation for InfoCannedPolicy { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::GetPolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::GetPolicyAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -370,22 +336,11 @@ pub struct RemoveCannedPolicy {} #[async_trait::async_trait] impl Operation for RemoveCannedPolicy { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DeletePolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DeletePolicyAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -461,22 +416,11 @@ pub struct SetPolicyForUserOrGroup {} #[async_trait::async_trait] impl Operation for SetPolicyForUserOrGroup { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::AttachPolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::AttachPolicyAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -802,24 +746,13 @@ async fn collect_group_policy_mappings( } async fn handle_builtin_policy_entities(req: S3Request) -> S3Result> { - let Some(input_cred) = req.credentials else { - return Err(s3_error!(InvalidRequest, "get cred failed")); - }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, + let cred = authorize_admin_request( + &req, vec![ Action::AdminAction(AdminAction::ListGroupsAdminAction), Action::AdminAction(AdminAction::ListUsersAdminAction), Action::AdminAction(AdminAction::ListUserPoliciesAdminAction), ], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), ) .await?; @@ -941,22 +874,7 @@ pub(crate) async fn handle_builtin_policy_association( req: S3Request, is_attach: bool, ) -> S3Result> { - let Some(input_cred) = req.credentials else { - return Err(s3_error!(InvalidRequest, "get cred failed")); - }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::AttachPolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + let cred = authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::AttachPolicyAdminAction)]).await?; let req_path = req.uri.path().to_string(); let body = read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &req_path, &cred.secret_key).await?; @@ -1129,13 +1047,33 @@ impl Operation for ListPolicyEntitiesBuiltin { #[cfg(test)] mod tests { - use super::{ - GroupPolicyEntities, PolicyAssociationReq, SetPolicyForUserOrGroupQuery, UserPolicyEntities, attach_policy_names, - build_policy_mappings, detach_policy_names, direct_user_policy_names, parse_policy_entities_query, - validate_policy_association_req, - }; + use super::*; + use http::Uri; use rustfs_madmin::UserInfo; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("an IAM policy request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + #[test] fn set_policy_query_supports_external_parameter_names() { let query: SetPolicyForUserOrGroupQuery = @@ -1244,4 +1182,93 @@ mod tests { vec!["readonly".to_string(), "writeonly".to_string()] ); } + + #[tokio::test] + async fn policy_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&ListCannedPolicies {}, Method::GET, "/rustfs/admin/v3/list-canned-policies").await; + assert_missing_credentials(&AddCannedPolicy {}, Method::PUT, "/rustfs/admin/v3/add-canned-policy").await; + assert_missing_credentials(&InfoCannedPolicy {}, Method::GET, "/rustfs/admin/v3/info-canned-policy").await; + assert_missing_credentials(&RemoveCannedPolicy {}, Method::DELETE, "/rustfs/admin/v3/remove-canned-policy").await; + assert_missing_credentials(&SetPolicyForUserOrGroup {}, Method::PUT, "/rustfs/admin/v3/set-user-or-group-policy").await; + + for result in [ + handle_builtin_policy_entities(credential_less_request(Method::GET, "/rustfs/admin/v3/idp/builtin/policy-entities")) + .await, + handle_builtin_policy_association( + credential_less_request(Method::POST, "/rustfs/admin/v3/idp/builtin/policy-association"), + true, + ) + .await, + ] { + let err = result.expect_err("the shared gate must reject missing credentials"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("get cred failed")); + } + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nasync fn ", "\npub(crate) async fn ", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + + #[test] + fn policy_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("policies.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action) in [ + ("ListCannedPolicies", "ListUserPoliciesAdminAction"), + ("AddCannedPolicy", "CreatePolicyAdminAction"), + ("InfoCannedPolicy", "GetPolicyAdminAction"), + ("RemoveCannedPolicy", "DeletePolicyAdminAction"), + ("SetPolicyForUserOrGroup", "AttachPolicyAdminAction"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, &[action], false); + } + + let entities = source_block(production, "async fn handle_builtin_policy_entities"); + assert_shared_gate_wiring( + entities, + "handle_builtin_policy_entities", + &["ListGroupsAdminAction", "ListUsersAdminAction", "ListUserPoliciesAdminAction"], + true, + ); + + let association = source_block(production, "pub(crate) async fn handle_builtin_policy_association"); + assert_shared_gate_wiring(association, "handle_builtin_policy_association", &["AttachPolicyAdminAction"], true); + + assert!(!production.contains("check_key_valid(get_session_token")); + } } From 3699b6b88d35234cdec133ac21081f6fda1506b5 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Thu, 27 Aug 2026 03:16:06 +0800 Subject: [PATCH 20/54] refactor(admin): route user management authorization through the shared gate (#6686) --- rustfs/src/admin/handlers/user.rs | 199 ++++++++++++++++++++---------- 1 file changed, 137 insertions(+), 62 deletions(-) diff --git a/rustfs/src/admin/handlers/user.rs b/rustfs/src/admin/handlers/user.rs index f1e1ef099..a9d4ee4f0 100644 --- a/rustfs/src/admin/handlers/user.rs +++ b/rustfs/src/admin/handlers/user.rs @@ -17,7 +17,7 @@ use super::{account_info, group, service_account, user_iam, user_lifecycle, user use crate::{ admin::runtime_sources::current_action_credentials, admin::{ - auth::validate_admin_request, + auth::{authorize_admin_request, validate_admin_request}, handlers::site_replication::site_replication_iam_change_hook, router::{AdminOperation, Operation, S3Router}, utils::{encode_compatible_admin_payload, has_space_be, read_compatible_admin_body}, @@ -360,7 +360,7 @@ impl Operation for SetUserStatus { return Err(s3_error!(InvalidArgument, "access key is empty")); } - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; @@ -368,18 +368,7 @@ impl Operation for SetUserStatus { return Err(s3_error!(InvalidArgument, "cannot change the status of the current user")); } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::EnableUserAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::EnableUserAdminAction)]).await?; let status = AccountStatus::try_from(query.status.as_deref().unwrap_or_default()) .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidArgument, e))?; @@ -439,22 +428,11 @@ pub struct ListUsers {} #[async_trait::async_trait] impl Operation for ListUsers { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListUsersAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + let cred = authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListUsersAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -499,22 +477,11 @@ pub struct RemoveUser {} #[async_trait::async_trait] impl Operation for RemoveUser { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DeleteUserAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + let cred = authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DeleteUserAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -695,22 +662,11 @@ pub struct ExportIam {} #[async_trait::async_trait] impl Operation for ExportIam { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ExportIAMAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ExportIAMAction)]).await?; let Ok(iam_store) = crate::admin::runtime_sources::current_ready_iam_handle() else { return Err(s3_error!(InvalidRequest, "iam not init")); @@ -1373,19 +1329,78 @@ impl Operation for ImportIam { #[cfg(test)] mod tests { use super::{ - GROUP_POLICY_MAPPING_USER_TYPE, MAX_IAM_IMPORT_EXPANDED_SIZE, MAX_IAM_IMPORT_SIZE, - SERVICE_ACCOUNT_ACCESS_KEY_MISMATCH_ERROR, SERVICE_ACCOUNT_PARENT_SCOPE_ERROR, add_user_targets_requester_parent, - imported_service_account_access_key_failure, imported_service_account_parent_allowed, - imported_service_account_parent_scope_failure, imported_service_account_status, map_add_user_create_error, - read_import_member, should_check_deny_only, should_reject_group_import_name, should_restore_group_as_disabled, + ExportIam, GROUP_POLICY_MAPPING_USER_TYPE, HeaderMap, ListUsers, MAX_IAM_IMPORT_EXPANDED_SIZE, MAX_IAM_IMPORT_SIZE, + Operation, Params, RemoveUser, SERVICE_ACCOUNT_ACCESS_KEY_MISMATCH_ERROR, SERVICE_ACCOUNT_PARENT_SCOPE_ERROR, + SetUserStatus, add_user_targets_requester_parent, imported_service_account_access_key_failure, + imported_service_account_parent_allowed, imported_service_account_parent_scope_failure, imported_service_account_status, + map_add_user_create_error, read_import_member, should_check_deny_only, should_reject_group_import_name, + should_restore_group_as_disabled, }; + use http::{Extensions, Method, Uri}; use rustfs_credentials::{Credentials, IAM_POLICY_CLAIM_NAME_SA}; use rustfs_iam::error::Error as IamError; use rustfs_madmin::user::SRSvcAccCreate; - use s3s::S3ErrorCode; + use s3s::{Body, S3ErrorCode, S3Request}; use serde_json::Value; use std::collections::HashMap; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str, message: &str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a user admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(message)); + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nfn ", "\nconst ", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + #[test] fn add_user_maps_duplicate_access_keys_to_s3_errors() { let err = map_add_user_create_error(IamError::AccessKeyAlreadyExists); @@ -1777,4 +1792,64 @@ mod tests { "expanded budget must not be smaller than the compressed upload cap" ); } + + /// The shared admin gate answers a credential-less request with `InvalidRequest "get cred failed"`. + /// Each converted handler keeps a pre-check ahead of the gate so its own wire response survives + /// the refactor unchanged. + #[tokio::test] + async fn user_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials( + &SetUserStatus {}, + Method::PUT, + "/rustfs/admin/v3/set-user-status?accessKey=alice", + "authentication required", + ) + .await; + assert_missing_credentials(&ListUsers {}, Method::GET, "/rustfs/admin/v3/list-users", "authentication required").await; + assert_missing_credentials(&RemoveUser {}, Method::DELETE, "/rustfs/admin/v3/remove-user", "authentication required") + .await; + assert_missing_credentials(&ExportIam {}, Method::GET, "/rustfs/admin/v3/export-iam", "get cred failed").await; + } + + #[test] + fn user_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("user.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action, binds_credentials) in [ + ("SetUserStatus", "EnableUserAdminAction", false), + ("ListUsers", "ListUsersAdminAction", true), + ("RemoveUser", "DeleteUserAdminAction", true), + ("ExportIam", "ExportIAMAction", false), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, &[action], binds_credentials); + } + + // AddUser and GetUserInfo derive a per-request `deny_only` from `should_check_deny_only`, and + // ImportIam consumes the `owner` flag while restoring service accounts. The shared gate pins + // `deny_only = false` and hands back only the credentials, so these three keep the explicit + // `check_key_valid` + `validate_admin_request` preamble. + for (handler, marker) in [ + ("AddUser", "should_check_deny_only(ak, &cred)"), + ("GetUserInfo", "should_check_deny_only(ak, &cred)"), + ("ImportIam", "if !owner {"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert!( + block.contains(marker), + "{handler} must keep the context that rules out the shared gate ({marker})" + ); + assert!( + !block.contains("authorize_admin_request("), + "{handler} must not be routed through the shared gate" + ); + assert!( + block.contains("validate_admin_request("), + "{handler} must keep its explicit validate_admin_request call" + ); + } + } } From 0e92eac2c2afd7b200638f0412e41ddb13148e10 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Thu, 27 Aug 2026 03:16:31 +0800 Subject: [PATCH 21/54] refactor(admin): route pool, rebalance, and system authorization through the shared gate (#6687) --- rustfs/src/admin/handlers/pools.rs | 250 +++++++++++++++++-------- rustfs/src/admin/handlers/rebalance.rs | 144 +++++++++----- rustfs/src/admin/handlers/system.rs | 232 ++++++++++++++--------- 3 files changed, 419 insertions(+), 207 deletions(-) diff --git a/rustfs/src/admin/handlers/pools.rs b/rustfs/src/admin/handlers/pools.rs index 699b08ce9..c506f2e3d 100644 --- a/rustfs/src/admin/handlers/pools.rs +++ b/rustfs/src/admin/handlers/pools.rs @@ -30,11 +30,10 @@ use crate::{ AdminPoolStatus, QueryPoolStatusRequest, current_endpoints_handle, current_notification_system, default_admin_usecase, }, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, router::{AdminOperation, Operation, S3Router}, storage_api::runtime::{EndpointServerPools, PeerRestClient}, }, - auth::{check_key_valid, get_session_token}, error::ApiError, server::{ADMIN_PREFIX, RemoteAddr}, }; @@ -480,23 +479,16 @@ impl Operation for ListPools { // GET //pools/list #[tracing::instrument(skip_all)] async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(pool_admin_missing_credentials_error("list pools")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, + authorize_admin_request( + &req, vec![ Action::AdminAction(AdminAction::ServerInfoAdminAction), Action::AdminAction(AdminAction::DecommissionAdminAction), ], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), ) .await?; @@ -577,23 +569,16 @@ impl Operation for StatusPool { // GET //pools/status?pool=http://server{1...4}/disk{1...4} #[tracing::instrument(skip_all)] async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(pool_admin_missing_credentials_error("load pool status")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, + authorize_admin_request( + &req, vec![ Action::AdminAction(AdminAction::ServerInfoAdminAction), Action::AdminAction(AdminAction::DecommissionAdminAction), ], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), ) .await?; @@ -632,23 +617,16 @@ impl Operation for StatusDecommission { // GET //decommission/status[?pool=http://server{1...4}/disk{1...4}] #[tracing::instrument(skip_all)] async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(pool_admin_missing_credentials_error("load decommission status")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, + authorize_admin_request( + &req, vec![ Action::AdminAction(AdminAction::ServerInfoAdminAction), Action::AdminAction(AdminAction::DecommissionAdminAction), ], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), ) .await?; @@ -702,27 +680,16 @@ impl Operation for StartDecommission { "admin pool request state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(pool_admin_missing_credentials_error_with_request( "start decommission", &request_id, &remote_addr, )); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DecommissionAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DecommissionAdminAction)]).await?; let audit = PoolAuditContext::new(&request_id, &actor, &remote_addr); let Some(endpoints) = endpoints_from_context() else { @@ -866,27 +833,16 @@ impl Operation for CancelDecommission { "admin pool request state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(pool_admin_missing_credentials_error_with_request( "cancel decommission", &request_id, &remote_addr, )); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DecommissionAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DecommissionAdminAction)]).await?; let audit = PoolAuditContext::new(&request_id, &actor, &remote_addr); let Some(endpoints) = endpoints_from_context() else { @@ -979,27 +935,16 @@ impl Operation for ClearDecommission { "admin pool request state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(pool_admin_missing_credentials_error_with_request( "clear decommission", &request_id, &remote_addr, )); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DecommissionAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DecommissionAdminAction)]).await?; let audit = PoolAuditContext::new(&request_id, &actor, &remote_addr); let Some(endpoints) = endpoints_from_context() else { @@ -1073,15 +1018,40 @@ impl Operation for ClearDecommission { #[cfg(test)] mod pools_handler_tests { use super::{ - AdminPoolStatus, PoolAuditContext, contextualize_admin_pool_api_error, - decommission_admin_not_initialized_error_with_audit, decommission_peer_target, has_duplicate_indices, - parse_mutation_pool_query, parse_pool_idx_by_id, parse_status_pool_query, pool_admin_missing_credentials_error, - pool_admin_missing_credentials_error_with_request, pool_admin_pool_index_error_with_audit, - pool_admin_pool_not_found_error_with_audit, pool_admin_pool_parse_error_with_audit, pool_admin_query_parse_error, - pool_admin_query_parse_error_with_audit, validate_pool_mutation_leader, validate_start_decommission_guards, + AdminPoolStatus, Body, CancelDecommission, ClearDecommission, HeaderMap, ListPools, Method, Operation, Params, + PoolAuditContext, S3ErrorCode, S3Request, StartDecommission, StatusDecommission, StatusPool, Uri, + contextualize_admin_pool_api_error, decommission_admin_not_initialized_error_with_audit, decommission_peer_target, + has_duplicate_indices, parse_mutation_pool_query, parse_pool_idx_by_id, parse_status_pool_query, + pool_admin_missing_credentials_error, pool_admin_missing_credentials_error_with_request, + pool_admin_pool_index_error_with_audit, pool_admin_pool_not_found_error_with_audit, + pool_admin_pool_parse_error_with_audit, pool_admin_query_parse_error, pool_admin_query_parse_error_with_audit, + validate_pool_mutation_leader, validate_start_decommission_guards, }; use crate::admin::storage_api::runtime::{Endpoint, EndpointServerPools, Endpoints, PoolEndpoints}; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str, message: &str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a pool admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(message)); + } + fn test_pool_endpoints(is_local: bool) -> EndpointServerPools { let mut endpoint = Endpoint::try_from("http://127.0.0.1:9000/disk").expect("test endpoint should parse"); endpoint.is_local = is_local; @@ -1380,4 +1350,124 @@ mod pools_handler_tests { assert_eq!(value["admin_discovery"]["clusterSnapshot"], "/rustfs/admin/v4/cluster/snapshot"); assert_eq!(value["admin_discovery"]["extensionsCatalog"], "/rustfs/admin/v4/extensions/catalog"); } + + /// Routing the pool handlers through the shared admin gate must not change + /// the wire response a caller sees when it sends no credentials at all: each + /// handler keeps its own operation-scoped message (rustfs/backlog#1829). + #[tokio::test] + async fn pool_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials( + &ListPools {}, + Method::GET, + "/rustfs/admin/v3/pools/list", + "Failed to list pools: missing credentials", + ) + .await; + assert_missing_credentials( + &StatusPool {}, + Method::GET, + "/rustfs/admin/v3/pools/status", + "Failed to load pool status: missing credentials", + ) + .await; + assert_missing_credentials( + &StatusDecommission {}, + Method::GET, + "/rustfs/admin/v3/decommission/status", + "Failed to load decommission status: missing credentials", + ) + .await; + assert_missing_credentials( + &StartDecommission {}, + Method::POST, + "/rustfs/admin/v3/pools/decommission", + "Failed to start decommission: missing credentials", + ) + .await; + assert_missing_credentials( + &CancelDecommission {}, + Method::POST, + "/rustfs/admin/v3/pools/cancel", + "Failed to cancel decommission: missing credentials", + ) + .await; + assert_missing_credentials( + &ClearDecommission {}, + Method::POST, + "/rustfs/admin/v3/pools/clear", + "Failed to clear decommission: missing credentials", + ) + .await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nasync fn ", "\npub(crate) async fn ", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + + /// Pins the gate wiring itself: every pool handler authorizes through + /// `authorize_admin_request` with the same action vector it used before the + /// deduplication, and the mutating handlers keep deriving their audit actor + /// from the caller-supplied access key (rustfs/backlog#1829). + #[test] + fn pool_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("pools.rs") + .split("\n#[cfg(test)]\nmod ") + .next() + .expect("production source must precede the test module"); + + let read_actions = ["ServerInfoAdminAction", "DecommissionAdminAction"]; + let mutate_actions = ["DecommissionAdminAction"]; + for (handler, actions) in [ + ("ListPools", read_actions.as_slice()), + ("StatusPool", read_actions.as_slice()), + ("StatusDecommission", read_actions.as_slice()), + ("StartDecommission", mutate_actions.as_slice()), + ("CancelDecommission", mutate_actions.as_slice()), + ("ClearDecommission", mutate_actions.as_slice()), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, actions, false); + } + + for handler in ["StartDecommission", "CancelDecommission", "ClearDecommission"] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert!( + block.contains("let actor = MaskedAccessKey(&input_cred.access_key).to_string();"), + "{handler} must keep masking the caller access key for its audit trail" + ); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + assert!(!production.contains("validate_admin_request(")); + } } diff --git a/rustfs/src/admin/handlers/rebalance.rs b/rustfs/src/admin/handlers/rebalance.rs index 2e134d037..81eb853de 100644 --- a/rustfs/src/admin/handlers/rebalance.rs +++ b/rustfs/src/admin/handlers/rebalance.rs @@ -24,10 +24,9 @@ use crate::admin::storage_api::runtime::{ECStore, NotificationSys}; use crate::{ admin::runtime_sources::current_notification_system, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, router::{AdminOperation, Operation, S3Router}, }, - auth::{check_key_valid, get_session_token}, server::{ADMIN_PREFIX, RemoteAddr}, }; use http::{HeaderMap, HeaderValue, StatusCode, Uri}; @@ -497,23 +496,12 @@ impl Operation for RebalanceStart { "admin rebalance state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::RebalanceAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::RebalanceAdminAction)]).await?; if rebalance_query_present(&req.uri) { log_rebalance_request_rejected("start", "invalid_query_parameters", &request_id, &actor, &remote_addr); @@ -792,23 +780,12 @@ impl Operation for RebalanceStatus { "admin rebalance state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::RebalanceAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::RebalanceAdminAction)]).await?; let Some(store) = object_store_from_extensions(&req.extensions) else { return Err(s3_error!(InternalError, "object layer is not initialized")); @@ -924,23 +901,12 @@ impl Operation for RebalanceStop { "admin rebalance state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::RebalanceAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::RebalanceAdminAction)]).await?; if rebalance_query_present(&req.uri) { log_rebalance_request_rejected("stop", "invalid_query_parameters", &request_id, &actor, &remote_addr); @@ -1092,7 +1058,8 @@ mod rebalance_handler_tests { use super::build_rebalance_pool_progress; use super::calculate_rebalance_progress; use super::{ - RebalPoolProgress, RebalanceAdminStatus, RebalancePoolStatus, RebalanceStartStep, RebalanceStopPropagationStatus, + Body, HeaderMap, Method, Operation, Params, RebalPoolProgress, RebalanceAdminStatus, RebalancePoolStatus, RebalanceStart, + RebalanceStartStep, RebalanceStatus, RebalanceStop, RebalanceStopPropagationStatus, S3ErrorCode, S3Request, Uri, build_rebalance_admin_status, build_rebalance_pool_statuses, build_rebalance_stop_propagation_status, rebalance_pool_used, rebalance_query_present, rebalance_remaining_buckets, rebalance_rollback_failure_message, rebalance_rollback_stop_failure_message, rebalance_start_rollback_error, rebalance_start_steps, rebalance_stop_target_id, @@ -1104,6 +1071,29 @@ mod rebalance_handler_tests { }; use time::OffsetDateTime; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a rebalance admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + fn started_rebalance_meta(id: &str) -> RebalanceMeta { RebalanceMeta { id: id.to_string(), @@ -1928,4 +1918,74 @@ mod rebalance_handler_tests { vec!["peer node-b load_rebalance_meta(start=false) failed: timeout"] ); } + + /// The rebalance handlers pre-check credentials before delegating to the + /// shared admin gate, so a credential-less request keeps returning + /// `InvalidRequest: authentication required` rather than the gate's own + /// "get cred failed" wording (rustfs/backlog#1829). + #[tokio::test] + async fn rebalance_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&RebalanceStart {}, Method::POST, "/rustfs/admin/v3/rebalance/start").await; + assert_missing_credentials(&RebalanceStatus {}, Method::GET, "/rustfs/admin/v3/rebalance/status").await; + assert_missing_credentials(&RebalanceStop {}, Method::POST, "/rustfs/admin/v3/rebalance/stop").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = [ + "\npub struct ", + "\nasync fn ", + "\npub(crate) async fn ", + "\nmod ", + "\n#[cfg(test)]", + ] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + /// All three rebalance handlers authorize through the single shared gate with + /// the same `RebalanceAdminAction` vector they used before the deduplication, + /// and none of them binds the returned credentials (rustfs/backlog#1829). + #[test] + fn rebalance_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("rebalance.rs") + .split("\n#[cfg(test)]\nmod ") + .next() + .expect("production source must precede the test module"); + + for handler in ["RebalanceStart", "RebalanceStatus", "RebalanceStop"] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{handler} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + 1, + "{handler} must preserve its exact action-vector length" + ); + assert!( + block.contains("AdminAction::RebalanceAdminAction"), + "{handler} must authorize with RebalanceAdminAction" + ); + assert!( + !block.contains("let cred = authorize_admin_request("), + "{handler} does not consume the authenticated credentials" + ); + assert!( + block.contains("let actor = MaskedAccessKey(&input_cred.access_key).to_string();"), + "{handler} must keep masking the caller access key for its audit trail" + ); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + assert!(!production.contains("validate_admin_request(")); + } } diff --git a/rustfs/src/admin/handlers/system.rs b/rustfs/src/admin/handlers/system.rs index 3715b79ac..250304c85 100644 --- a/rustfs/src/admin/handlers/system.rs +++ b/rustfs/src/admin/handlers/system.rs @@ -13,7 +13,7 @@ // limitations under the License. use super::{cluster_snapshot, metrics}; -use crate::admin::auth::validate_admin_request; +use crate::admin::auth::authorize_admin_request; use crate::admin::handlers::account::{ACCOUNT_INFO_ROUTE, ACCOUNT_PASSWORD_ROUTE}; use crate::admin::handlers::mfa::{ACCOUNT_MFA_ROUTE, MFA_CHALLENGE_ROUTE, USER_MFA_ROUTE}; use crate::admin::route_policy::{ @@ -31,9 +31,8 @@ use crate::admin::storage_api::cluster::{ CapabilityState, CapabilityStatus, ObservabilitySnapshotProvider, TopologySnapshot, TopologySnapshotProvider, }; use crate::admin::storage_api::storageclass as storage_class_contract; -use crate::auth::{check_key_valid, get_session_token}; use crate::runtime_capabilities::{EndpointTopologySnapshotProvider, RustFsObservabilitySnapshotProvider}; -use crate::server::{ADMIN_PREFIX, RemoteAddr}; +use crate::server::ADMIN_PREFIX; use crate::workload_admission::workload_admission_registry_snapshot; use http::{HeaderMap, HeaderValue}; use hyper::{Method, StatusCode}; @@ -248,10 +247,10 @@ fn request_graceful_shutdown() {} #[async_trait::async_trait] impl Operation for ServiceHandle { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials.as_ref() else { + if req.credentials.is_none() { log_system_request_rejected!("service_handle", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } let Some(action) = service_action_from_uri(&req.uri) else { log_system_request_rejected!("service_handle", "invalid_action"); @@ -265,10 +264,7 @@ impl Operation for ServiceHandle { ServiceAction::Freeze | ServiceAction::Unfreeze => AdminAction::ServiceFreezeAdminAction, }; - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(admin_action)], remote_addr).await?; + authorize_admin_request(&req, vec![Action::AdminAction(admin_action)]).await?; let response = match action { ServiceAction::Restart => { @@ -362,22 +358,11 @@ struct ServerUpdateStatus { #[async_trait::async_trait] impl Operation for UpdateHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials.as_ref() else { + if req.credentials.is_none() { log_system_request_rejected!("server_update", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ServerUpdateAdminAction)], - remote_addr, - ) - .await?; + } + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ServerUpdateAdminAction)]).await?; // MinIO's server-update downloads and swaps the binary in place. RustFS // intentionally does not implement in-process self-update: binaries are @@ -459,24 +444,12 @@ fn bitrot_selftest_status_str() -> &'static str { #[async_trait::async_trait] impl Operation for ServerInfoHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { log_system_request_rejected!("query_server_info", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)], - remote_addr, - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)]).await?; let usecase = default_admin_usecase(); let info = usecase @@ -536,22 +509,11 @@ impl Operation for InspectDataHandler { use crate::admin::storage_api::object::StorageObjectOptions; use tokio::io::AsyncReadExt; - let Some(input_cred) = req.credentials.as_ref() else { + if req.credentials.is_none() { log_system_request_rejected!("inspect_data", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::InspectDataAction)], - remote_addr, - ) - .await?; + } + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::InspectDataAction)]).await?; // MinIO's inspect-data exports a signed archive of raw drive files for a // `volume`/`file` glob. RustFS erasure-codes and (optionally) encrypts @@ -608,24 +570,12 @@ pub struct StorageInfoHandler {} #[async_trait::async_trait] impl Operation for StorageInfoHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { log_system_request_rejected!("query_storage_info", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::StorageInfoAdminAction)], - remote_addr, - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::StorageInfoAdminAction)]).await?; let usecase = default_admin_usecase(); let info = usecase.execute_query_storage_info().await.map_err(S3Error::from)?; @@ -1178,16 +1128,12 @@ fn summarize_named_capability_statuses( #[async_trait::async_trait] impl Operation for RuntimeCapabilitiesHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { log_system_request_rejected!("runtime_capabilities", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request(&req.headers, &cred, owner, false, runtime_capabilities_gate_actions(), remote_addr).await?; + authorize_admin_request(&req, runtime_capabilities_gate_actions()).await?; let response = build_runtime_capabilities_response().await.map_err(|err| { log_system_request_failed!("runtime_capabilities", "build_runtime_capabilities_failed", err); @@ -1220,16 +1166,12 @@ pub(crate) fn data_usage_info_gate_actions() -> Vec { #[async_trait::async_trait] impl Operation for DataUsageInfoHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { log_system_request_rejected!("query_data_usage_info", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request(&req.headers, &cred, owner, false, data_usage_info_gate_actions(), remote_addr).await?; + authorize_admin_request(&req, data_usage_info_gate_actions()).await?; let usecase = default_admin_usecase(); let info = usecase.execute_query_data_usage_info().await.map_err(S3Error::from)?; @@ -1250,10 +1192,11 @@ impl Operation for DataUsageInfoHandler { #[cfg(test)] mod tests { use super::{ - MANUAL_TRANSITION_JOB_ROUTE, MANUAL_TRANSITION_RUN_ROUTE, OBSERVABILITY_SUMMARY_RESOLVED, RuntimeCapabilitiesHandler, - SITE_REPLICATION_EDIT_ROUTE, SITE_REPLICATION_INFO_ROUTE, SITE_REPLICATION_REPAIR_ROUTE, - SITE_REPLICATION_REPAIR_STATUS_ROUTE, SITE_REPLICATION_RESYNC_ROUTE, ServerInfoResponse, TOPOLOGY_SNAPSHOT_NOT_AVAILABLE, - TOPOLOGY_SUMMARY_RESOLVED, admin_route_capability_from_inventory, build_runtime_capabilities_response, + DataUsageInfoHandler, InspectDataHandler, MANUAL_TRANSITION_JOB_ROUTE, MANUAL_TRANSITION_RUN_ROUTE, + OBSERVABILITY_SUMMARY_RESOLVED, RuntimeCapabilitiesHandler, SITE_REPLICATION_EDIT_ROUTE, SITE_REPLICATION_INFO_ROUTE, + SITE_REPLICATION_REPAIR_ROUTE, SITE_REPLICATION_REPAIR_STATUS_ROUTE, SITE_REPLICATION_RESYNC_ROUTE, ServerInfoHandler, + ServerInfoResponse, ServiceHandle, StorageInfoHandler, TOPOLOGY_SNAPSHOT_NOT_AVAILABLE, TOPOLOGY_SUMMARY_RESOLVED, + UpdateHandler, admin_route_capability_from_inventory, build_runtime_capabilities_response, build_runtime_capabilities_summary, data_usage_info_gate_actions, runtime_capabilities_gate_actions, system_admin_discovery, }; @@ -1908,4 +1851,123 @@ mod tests { .contains(TOPOLOGY_SUMMARY_RESOLVED) ); } + + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a system admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("get cred failed")); + } + + /// Every system handler pre-checks credentials before delegating to the + /// shared admin gate, so the credential-less response stays byte-identical to + /// what it was before the deduplication. `ServiceHandle` in particular must + /// keep rejecting on missing credentials *before* it parses the requested + /// service action (rustfs/backlog#1829). + #[tokio::test] + async fn system_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&ServiceHandle {}, Method::POST, "/rustfs/admin/v3/service?action=restart").await; + assert_missing_credentials(&ServiceHandle {}, Method::POST, "/rustfs/admin/v3/service").await; + assert_missing_credentials(&UpdateHandler {}, Method::POST, "/rustfs/admin/v3/update").await; + assert_missing_credentials(&ServerInfoHandler {}, Method::GET, "/rustfs/admin/v3/info").await; + assert_missing_credentials(&InspectDataHandler {}, Method::GET, "/rustfs/admin/v3/inspect-data").await; + assert_missing_credentials(&StorageInfoHandler {}, Method::GET, "/rustfs/admin/v3/storageinfo").await; + assert_missing_credentials(&RuntimeCapabilitiesHandler {}, Method::GET, "/rustfs/admin/v4/runtime/capabilities").await; + assert_missing_credentials(&DataUsageInfoHandler {}, Method::GET, "/rustfs/admin/v3/datausageinfo").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = [ + "\npub struct ", + "\nasync fn ", + "\npub(crate) async fn ", + "\npub fn ", + "\npub(crate) fn ", + "\nfn ", + "\nmod ", + "\n#[cfg(test)]", + ] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + /// Pins the gate wiring: each system handler authorizes through exactly one + /// `authorize_admin_request` call carrying the same action vector it used + /// before the deduplication. The two gate-action helpers must be passed + /// through by name so the vectors pinned by the tests above keep governing + /// the live gate (rustfs/backlog#1829). + #[test] + fn system_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("system.rs") + .split("\n#[cfg(test)]\nmod ") + .next() + .expect("production source must precede the test module"); + + let service_tokens = [ + "AdminAction::ServiceRestartAdminAction", + "AdminAction::ServiceStopAdminAction", + "AdminAction::ServiceFreezeAdminAction", + ]; + let update_tokens = ["AdminAction::ServerUpdateAdminAction"]; + let server_info_tokens = ["AdminAction::ServerInfoAdminAction"]; + let inspect_data_tokens = ["AdminAction::InspectDataAction"]; + let storage_info_tokens = ["AdminAction::StorageInfoAdminAction"]; + let runtime_capabilities_tokens = ["runtime_capabilities_gate_actions()"]; + let data_usage_info_tokens = ["data_usage_info_gate_actions()"]; + + for (handler, inline_admin_actions, tokens) in [ + ("ServiceHandle", 1usize, service_tokens.as_slice()), + ("UpdateHandler", 1, update_tokens.as_slice()), + ("ServerInfoHandler", 1, server_info_tokens.as_slice()), + ("InspectDataHandler", 1, inspect_data_tokens.as_slice()), + ("StorageInfoHandler", 1, storage_info_tokens.as_slice()), + ("RuntimeCapabilitiesHandler", 0, runtime_capabilities_tokens.as_slice()), + ("DataUsageInfoHandler", 0, data_usage_info_tokens.as_slice()), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{handler} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + inline_admin_actions, + "{handler} must preserve its exact inline action-vector length" + ); + for token in tokens { + assert!(block.contains(token), "{handler} must authorize with {token}"); + } + assert!( + !block.contains("let cred = authorize_admin_request("), + "{handler} does not consume the authenticated credentials" + ); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + assert!(!production.contains("validate_admin_request(")); + } } From 61914ac4ad31155ab0aeab2df5349a46df031416 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Thu, 27 Aug 2026 03:16:44 +0800 Subject: [PATCH 22/54] refactor(admin): route tier, bucket metadata, archive, transition, and oidc auth through the shared gate (#6688) --- rustfs/src/admin/handlers/bucket_meta.rs | 129 ++++++++--- rustfs/src/admin/handlers/ilm_transition.rs | 59 ++++- rustfs/src/admin/handlers/inspect_archive.rs | 54 ++++- rustfs/src/admin/handlers/oidc.rs | 86 ++++++-- rustfs/src/admin/handlers/tier.rs | 217 ++++++++++--------- 5 files changed, 374 insertions(+), 171 deletions(-) diff --git a/rustfs/src/admin/handlers/bucket_meta.rs b/rustfs/src/admin/handlers/bucket_meta.rs index 48ef7fe2f..5de70dd0b 100644 --- a/rustfs/src/admin/handlers/bucket_meta.rs +++ b/rustfs/src/admin/handlers/bucket_meta.rs @@ -30,11 +30,10 @@ use crate::admin::storage_api::error::StorageError; use crate::storage::storage_api::lock_bucket_targets_metadata; use crate::{ admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, router::{AdminOperation, Operation, S3Router}, }, - auth::{check_key_valid, get_session_token}, - server::{ADMIN_PREFIX, RemoteAddr}, + server::ADMIN_PREFIX, }; use http::{HeaderMap, StatusCode}; use hyper::Method; @@ -117,22 +116,11 @@ impl Operation for ExportBucketMetadata { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ExportBucketMetadataAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ExportBucketMetadataAction)]).await?; let Some(store) = object_store_from_extensions(&req.extensions) else { return Err(s3_error!(InternalError, "object store is not initialized")); @@ -412,22 +400,11 @@ impl Operation for ImportBucketMetadata { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ImportBucketMetadataAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ImportBucketMetadataAction)]).await?; let mut input = req.input; let body = match input.store_all_limited(MAX_BUCKET_METADATA_IMPORT_SIZE).await { @@ -1128,3 +1105,93 @@ mod import_persist_tests { assert!(imported_quota_requires_fleet_proof(&durable).expect("durable quota should pass preflight")); } } + +#[cfg(test)] +mod shared_gate_tests { + use super::*; + use http::Uri; + use s3s::S3ErrorCode; + + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a bucket metadata admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + + /// The shared gate reports "get cred failed"; the per-handler pre-check keeps + /// the message each endpoint has always returned (rustfs/backlog#1829). + #[tokio::test] + async fn bucket_metadata_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&ExportBucketMetadata {}, Method::GET, "/rustfs/admin/v3/export-bucket-metadata").await; + assert_missing_credentials(&ImportBucketMetadata {}, Method::PUT, "/rustfs/admin/v3/import-bucket-metadata").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nfn ", "\n#[derive(", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + + #[test] + fn bucket_metadata_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("bucket_meta.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action) in [ + ("ExportBucketMetadata", "ExportBucketMetadataAction"), + ("ImportBucketMetadata", "ImportBucketMetadataAction"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, &[action], false); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + } +} diff --git a/rustfs/src/admin/handlers/ilm_transition.rs b/rustfs/src/admin/handlers/ilm_transition.rs index 655dc4fbf..798242439 100644 --- a/rustfs/src/admin/handlers/ilm_transition.rs +++ b/rustfs/src/admin/handlers/ilm_transition.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::admin::auth::validate_admin_request; +use crate::admin::auth::authorize_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::object_store_from_extensions; use crate::admin::storage_api::bucket::is_reserved_or_invalid_bucket; @@ -30,7 +30,6 @@ use crate::admin::storage_api::lifecycle::{ request_manual_transition_job_cancel, save_manual_transition_job_record, update_manual_transition_job_record, }; use crate::admin::storage_api::runtime::ECStore; -use crate::auth::{check_key_valid, get_session_token}; use crate::server::{ADMIN_PREFIX, RemoteAddr}; use http::{HeaderMap, HeaderValue}; use hyper::{Method, StatusCode}; @@ -406,20 +405,16 @@ async fn authorize_manual_transition_request(req: &S3Request) -> S3Result< authorize_transition_admin_request(req, AdminAction::SetTierAction).await } +/// The credential pre-check keeps this endpoint family's historical +/// missing-credentials message (the shared gate reports "get cred failed") and +/// still yields the masked actor every transition audit log records. async fn authorize_transition_admin_request(req: &S3Request, action: AdminAction) -> S3Result { let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req - .extensions - .get::>() - .and_then(|opt| opt.map(|addr| addr.0)); - - validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await?; + authorize_admin_request(req, vec![Action::AdminAction(action)]).await?; Ok(actor) } @@ -1491,6 +1486,50 @@ mod tests { assert!(!auth_block.contains("AdminAction::ServerInfoAdminAction")); } + /// The transition wrapper now delegates to the shared admin gate, which reports + /// "get cred failed"; its own pre-check keeps the message these endpoints have + /// always returned (rustfs/backlog#1829). + #[tokio::test] + async fn transition_admin_gate_keeps_its_missing_credentials_response() { + let err = authorize_transition_admin_request( + &manual_transition_job_request(Method::GET, "/rustfs/admin/v3/ilm/transition/jobs/job-123"), + AdminAction::ListTierAction, + ) + .await + .expect_err("a transition admin request without credentials must fail"); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + + #[test] + fn transition_admin_gate_routes_through_the_shared_gate() { + let production = include_str!("ilm_transition.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + let wrapper = extract_block_between_markers( + production, + "async fn authorize_transition_admin_request", + "fn transition_transaction_id_from_params", + ); + + assert_eq!( + wrapper.matches("authorize_admin_request(").count(), + 1, + "the transition wrapper must use exactly one shared gate" + ); + assert!( + wrapper.contains("authorize_admin_request(req, vec![Action::AdminAction(action)])"), + "the transition wrapper must forward its parameterized action unchanged" + ); + assert!( + wrapper.contains("MaskedAccessKey(&input_cred.access_key)"), + "the transition wrapper must keep returning the masked actor" + ); + assert!(!production.contains("check_key_valid(get_session_token")); + } + #[test] fn manual_transition_job_id_path_param_is_required() { with_manual_transition_job_params("/rustfs/admin/v3/ilm/transition/jobs/job-123", |params| { diff --git a/rustfs/src/admin/handlers/inspect_archive.rs b/rustfs/src/admin/handlers/inspect_archive.rs index e66ba942e..425246ece 100644 --- a/rustfs/src/admin/handlers/inspect_archive.rs +++ b/rustfs/src/admin/handlers/inspect_archive.rs @@ -27,11 +27,10 @@ //! digest does not match. Raw `xl.meta`, object contents, drive paths, //! endpoints, user metadata, and encryption keys are never archive entries. -use crate::admin::auth::validate_admin_request; +use crate::admin::auth::authorize_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::storage_api::access::spawn_traced; -use crate::auth::{check_key_valid, get_session_token}; -use crate::server::{ADMIN_PREFIX, RemoteAddr}; +use crate::server::ADMIN_PREFIX; use crate::storage::storage_api::DiskError; use crate::storage::{StorageDiskRpcExt, all_local_disk}; use aes_gcm::aead::{Aead, KeyInit, Payload}; @@ -576,16 +575,10 @@ fn inspect_archive_gate_actions() -> Vec { #[async_trait::async_trait] impl Operation for InspectArchiveHandler { async fn call(&self, mut req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials.as_ref() else { + if req.credentials.is_none() { return Err(s3_error!(AccessDenied, "Signature is required")); - }; - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req - .extensions - .get::>() - .and_then(|opt| opt.map(|addr| addr.0)); - validate_admin_request(&req.headers, &cred, owner, false, inspect_archive_gate_actions(), remote_addr).await?; + } + authorize_admin_request(&req, inspect_archive_gate_actions()).await?; let body = req .input @@ -1003,5 +996,42 @@ mod tests { .await .expect_err("unsigned request should fail"); assert_eq!(error.code(), &S3ErrorCode::AccessDenied); + // The shared gate reports InvalidRequest / "get cred failed"; the pre-check + // keeps this endpoint's own signature-required response (rustfs/backlog#1829). + assert_eq!(error.message(), Some("Signature is required")); + } + + /// The handler authorizes through the shared admin gate and still derives its + /// action vector from `inspect_archive_gate_actions()` (rustfs/backlog#1829). + #[test] + fn inspect_archive_handler_uses_the_shared_admin_gate_with_its_gate_actions() { + let production = include_str!("inspect_archive.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + let block = production + .split_once("impl Operation for InspectArchiveHandler") + .expect("the inspect archive handler must exist") + .1; + + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "InspectArchiveHandler must use exactly one shared gate" + ); + assert!( + block.contains("authorize_admin_request(&req, inspect_archive_gate_actions())"), + "InspectArchiveHandler must keep deriving its actions from inspect_archive_gate_actions()" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + 0, + "InspectArchiveHandler must not inline an action vector" + ); + assert!( + !block.contains("let cred = authorize_admin_request("), + "InspectArchiveHandler does not need the authenticated credentials" + ); + assert!(!production.contains("check_key_valid(get_session_token")); } } diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index 42ace4905..37d12dc88 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::admin::auth::validate_admin_request; +use crate::admin::auth::authorize_admin_request; use crate::admin::handlers::supervise_admin_mutation; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{ @@ -23,8 +23,7 @@ use crate::admin::service::federated_identity::DefaultFederatedSessionBinding; use crate::admin::storage_api::config::{ read_admin_config_without_migrate, read_admin_server_config_snapshot, save_admin_server_config_snapshot, }; -use crate::auth::{check_key_valid, get_session_token}; -use crate::server::{ADMIN_PREFIX, CONSOLE_PREFIX, MINIO_ADMIN_PREFIX, RemoteAddr}; +use crate::server::{ADMIN_PREFIX, CONSOLE_PREFIX, MINIO_ADMIN_PREFIX}; use http::StatusCode; use hyper::Method; use matchit::Params; @@ -857,23 +856,15 @@ fn redirect_response(location: &str) -> S3Result> Ok(resp) } +/// The pre-check keeps this endpoint family's historical missing-credentials +/// message; the shared gate reports "get cred failed". async fn authorize_oidc_config_request(req: &S3Request, action: AdminAction) -> S3Result<()> { - let Some(input_cred) = &req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(action)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await + authorize_admin_request(req, vec![Action::AdminAction(action)]).await?; + Ok(()) } async fn parse_json_body(req: &mut S3Request) -> S3Result { @@ -1847,4 +1838,65 @@ mod tests { .expect("provider KVS should exist"); assert_eq!(kvs.get(OIDC_ISSUER), "https://app.local/realms/app"); } + + /// The OIDC config gate now authorizes through the shared admin gate, which + /// reports "get cred failed"; its pre-check keeps the message these endpoints + /// have always returned (rustfs/backlog#1829). + #[tokio::test] + async fn oidc_config_gate_keeps_its_missing_credentials_message() { + for action in [AdminAction::ServerInfoAdminAction, AdminAction::ConfigUpdateAdminAction] { + let err = authorize_oidc_config_request(&build_oidc_request("/rustfs/admin/v3/idp/openid", None, None), action) + .await + .expect_err("a request without credentials must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + } + + #[test] + fn oidc_config_gate_routes_through_the_shared_gate() { + let production = include_str!("oidc.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + let wrapper = production + .split_once("async fn authorize_oidc_config_request") + .expect("the OIDC config gate must exist") + .1 + .split_once("\nasync fn parse_json_body") + .expect("the OIDC config gate must be followed by parse_json_body") + .0; + + assert_eq!( + wrapper.matches("authorize_admin_request(").count(), + 1, + "the OIDC config gate must use exactly one shared gate" + ); + assert!( + wrapper.contains("authorize_admin_request(req, vec![Action::AdminAction(action)])"), + "the OIDC config gate must forward its parameterized action unchanged" + ); + assert!( + !wrapper.contains("let cred = authorize_admin_request("), + "the OIDC config gate does not need the authenticated credentials" + ); + + for (handler, action) in [ + ("GetOidcConfigHandler", "AdminAction::ServerInfoAdminAction"), + ("PutOidcConfigHandler", "AdminAction::ConfigUpdateAdminAction"), + ("DeleteOidcConfigHandler", "AdminAction::ConfigUpdateAdminAction"), + ("ValidateOidcConfigHandler", "AdminAction::ServerInfoAdminAction"), + ] { + let marker = format!("impl Operation for {handler}"); + let block = production + .split_once(marker.as_str()) + .unwrap_or_else(|| panic!("{handler} should exist")) + .1; + let block = &block[..block.find("\npub struct ").unwrap_or(block.len())]; + assert!( + block.contains(&format!("authorize_oidc_config_request(&req, {action})")), + "{handler} must keep authorizing with {action}" + ); + } + } } diff --git a/rustfs/src/admin/handlers/tier.rs b/rustfs/src/admin/handlers/tier.rs index 81b8dd7d9..f41b742c6 100644 --- a/rustfs/src/admin/handlers/tier.rs +++ b/rustfs/src/admin/handlers/tier.rs @@ -24,11 +24,10 @@ use crate::admin::storage_api::tier::{ use crate::{ admin::runtime_sources::{current_daily_tier_stats, current_notification_system, current_tier_config_handle}, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, router::{AdminOperation, Operation, S3Router}, }, - auth::{check_key_valid, get_session_token}, - server::{ADMIN_PREFIX, RemoteAddr}, + server::ADMIN_PREFIX, }; use http::{HeaderMap, StatusCode, Uri}; use hyper::Method; @@ -220,22 +219,11 @@ impl Operation for AddTier { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::SetTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::SetTierAction)]).await?; let mut input = req.input; let body = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await { @@ -436,22 +424,11 @@ impl Operation for EditTier { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::SetTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::SetTierAction)]).await?; let mut input = req.input; let body = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await { @@ -544,22 +521,11 @@ impl Operation for ListTiers { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListTierAction)]).await?; let tier_config_mgr_handle = current_tier_config_handle(); let tier_config_mgr = tier_config_mgr_handle.read().await; @@ -589,22 +555,11 @@ impl Operation for RemoveTier { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::SetTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::SetTierAction)]).await?; let mut force: bool = false; let force_str = query.force.clone().unwrap_or_default(); @@ -671,22 +626,11 @@ pub struct VerifyTier {} #[async_trait::async_trait] impl Operation for VerifyTier { async fn call(&self, req: S3Request, params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListTierAction)]).await?; let tier = resolve_tier_name(&req.uri, ¶ms)?; let tier_config_mgr_handle = current_tier_config_handle(); @@ -705,22 +649,11 @@ pub struct GetTierInfo {} #[async_trait::async_trait] impl Operation for GetTierInfo { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListTierAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -836,22 +769,11 @@ impl Operation for ClearTier { async fn call(&self, req: S3Request, params: Params<'_, '_>) -> S3Result> { let query = parse_clear_tier_query(&req.uri)?; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::SetTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::SetTierAction)]).await?; let mut force: bool = false; let force_str = query.force; @@ -1186,4 +1108,97 @@ mod tests { stats.insert("ARCHIVE".to_string(), archive); stats } + + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str, message: &str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a tier admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(message)); + } + + /// The shared gate reports "get cred failed"; the per-handler pre-check keeps + /// the message each endpoint has always returned (rustfs/backlog#1829). + #[tokio::test] + async fn tier_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&AddTier {}, Method::PUT, "/rustfs/admin/v3/tier", "authentication required").await; + assert_missing_credentials(&EditTier {}, Method::POST, "/rustfs/admin/v3/tier/WARM", "authentication required").await; + assert_missing_credentials(&ListTiers {}, Method::GET, "/rustfs/admin/v3/tiers", "get cred failed").await; + assert_missing_credentials(&RemoveTier {}, Method::DELETE, "/rustfs/admin/v3/tier/WARM", "authentication required").await; + assert_missing_credentials(&VerifyTier {}, Method::GET, "/rustfs/admin/v3/tier/WARM", "authentication required").await; + assert_missing_credentials(&GetTierInfo {}, Method::GET, "/rustfs/admin/v3/tier-stats", "get cred failed").await; + assert_missing_credentials(&ClearTier {}, Method::DELETE, "/rustfs/admin/v3/tiers", "authentication required").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nfn ", "\n#[derive(", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + + #[test] + fn tier_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("tier.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action) in [ + ("AddTier", "SetTierAction"), + ("EditTier", "SetTierAction"), + ("ListTiers", "ListTierAction"), + ("RemoveTier", "SetTierAction"), + ("VerifyTier", "ListTierAction"), + ("GetTierInfo", "ListTierAction"), + ("ClearTier", "SetTierAction"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, &[action], false); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + } } From 31031f2a465bf9b1920c452bed8c2826e6300a28 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Thu, 27 Aug 2026 03:17:01 +0800 Subject: [PATCH 23/54] fix(ci): bound cold ILM compilation (#6689) --- .github/workflows/ci.yml | 5 ++++ scripts/check_test_wiring.py | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7cf207e0..ea1522ffa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -429,6 +429,11 @@ jobs: # - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition: # noncurrent transition/expiry after an immediate compensation transition. - name: Run ignored ILM integration tests serially + env: + # Match the measured Test and Lint link budget. The default exposed + # all 14 pod CPUs and a cold cache spent the full 80m compiling + # without starting one ILM test (main run 32982910990). + CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }} run: | mkdir -p artifacts/ilm-integration set +e diff --git a/scripts/check_test_wiring.py b/scripts/check_test_wiring.py index 9ebe537f7..1df46c234 100755 --- a/scripts/check_test_wiring.py +++ b/scripts/check_test_wiring.py @@ -174,6 +174,36 @@ def check_vault_test_groups(root: Path) -> list[str]: return errors +def check_ilm_build_budget(root: Path) -> list[str]: + workflow_path = root / ".github/workflows/ci.yml" + if not workflow_path.is_file(): + return [".github/workflows/ci.yml: missing workflow"] + workflow = workflow_path.read_text() + job = re.search( + r"^ test-ilm-integration-serial:\n(?P.*?)(?=^ [a-z0-9-]+:\n)", + workflow, + re.MULTILINE | re.DOTALL, + ) + if job is None: + return [".github/workflows/ci.yml: missing test-ilm-integration-serial job"] + + step = re.search( + r"^ - name: Run ignored ILM integration tests serially\n(?P.*?)(?=^ - name:|\Z)", + job.group("body"), + re.MULTILINE | re.DOTALL, + ) + if step is None: + return [".github/workflows/ci.yml: missing ignored ILM integration step"] + + budget = ( + "CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || " + "github.event_name == 'workflow_dispatch') && '3' || '2' }}" + ) + if budget not in step.group("body"): + return [".github/workflows/ci.yml: ILM integration must keep the measured 3/2 Cargo build budget"] + return [] + + def check_fuzz_targets(root: Path) -> list[str]: manifest = tomllib.loads((root / "fuzz/Cargo.toml").read_text()) expected = {item["name"] for item in manifest.get("bin", []) if "name" in item} @@ -738,6 +768,7 @@ def validate(root: Path) -> list[str]: errors: list[str] = [] errors.extend(check_e2e_modules(root)) errors.extend(check_vault_test_groups(root)) + errors.extend(check_ilm_build_budget(root)) errors.extend(check_fuzz_targets(root)) errors.extend(check_runner_selection(root)) errors.extend(check_s3_tests_runner(root)) @@ -748,6 +779,28 @@ def validate(root: Path) -> list[str]: class SelfTests(unittest.TestCase): + def test_ilm_lane_keeps_the_measured_cargo_build_budget(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + valid = ( + "jobs:\n" + " test-ilm-integration-serial:\n" + " steps:\n" + " - name: Run ignored ILM integration tests serially\n" + " env:\n" + " CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}\n" + " run: cargo nextest run\n" + " next-job:\n" + " steps: []\n" + ) + workflow.write_text(valid) + self.assertEqual(check_ilm_build_budget(root), []) + + workflow.write_text(valid.replace(" CARGO_BUILD_JOBS:", " BUILD_JOBS:")) + self.assertEqual(len(check_ilm_build_budget(root)), 1) + def test_vault_tests_require_cross_process_serialization(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -931,6 +984,7 @@ class SelfTests(unittest.TestCase): mock.patch(__name__ + ".check_runner_selection", return_value=[]), mock.patch(__name__ + ".check_workflow_readiness", return_value=[]), mock.patch(__name__ + ".check_profile_definitions", return_value=[]), + mock.patch(__name__ + ".check_ilm_build_budget", return_value=[]), mock.patch(__name__ + ".check_scheduled_alerts", return_value=[]), ): self.assertEqual(len(validate(root)), 1) From ba7785d61dbf4ed1cb678cfb379e8c49a98795bc Mon Sep 17 00:00:00 2001 From: houseme Date: Thu, 27 Aug 2026 03:17:24 +0800 Subject: [PATCH 24/54] chore(deps): migrate direct encoding deps to simd (#6690) --- Cargo.lock | 20 +- Cargo.toml | 2 - crates/e2e_test/Cargo.toml | 4 +- crates/e2e_test/src/checksum_upload_test.rs | 5 +- crates/e2e_test/src/compression_test.rs | 3 +- .../e2e_test/src/copy_object_checksum_test.rs | 9 +- crates/e2e_test/src/fake_s3_target/mod.rs | 6 +- .../src/inline_fast_path_cluster_test.rs | 7 +- crates/e2e_test/src/kms/common.rs | 14 +- .../src/kms/kms_comprehensive_test.rs | 2 +- .../e2e_test/src/kms/kms_edge_cases_test.rs | 15 +- crates/e2e_test/src/kms/kms_local_test.rs | 6 +- crates/e2e_test/src/kms/kms_vault_test.rs | 4 +- .../src/kms/multipart_encryption_test.rs | 2 +- crates/e2e_test/src/multipart_auth_test.rs | 23 +- crates/e2e_test/src/protocols/webdav_core.rs | 3 +- .../src/replication_extension_test.rs | 20 +- crates/e2e_test/src/ssec_copy_test.rs | 5 +- crates/ecstore/Cargo.toml | 1 - .../replication_target_boundary.rs | 4 +- crates/ecstore/src/cluster/rpc/http_auth.rs | 31 +- crates/ecstore/src/object_api/readers.rs | 38 +-- crates/ecstore/src/store/list_objects.rs | 6 +- crates/heal/Cargo.toml | 2 +- crates/heal/src/heal/resume/checkpoint.rs | 3 +- crates/heal/src/heal/storage.rs | 16 +- crates/kms/Cargo.toml | 4 +- crates/kms/examples/kms_dr_drill.rs | 8 +- crates/kms/examples/local_kms_key_decrypt.rs | 4 +- crates/kms/src/audit.rs | 2 +- crates/kms/src/backends/aws.rs | 13 +- crates/kms/src/backends/contract_tests.rs | 5 +- crates/kms/src/backends/local.rs | 18 +- crates/kms/src/backends/mod.rs | 5 +- crates/kms/src/backends/static_kms.rs | 5 +- crates/kms/src/backends/vault.rs | 26 +- crates/kms/src/backends/vault_transit.rs | 12 +- crates/kms/src/backup/drill.rs | 4 +- crates/kms/src/backup/local_export.rs | 5 +- crates/kms/src/backup/local_restore.rs | 4 +- crates/kms/src/backup/manifest.rs | 2 +- crates/kms/src/config.rs | 15 +- crates/kms/src/config_secret.rs | 9 +- crates/kms/src/manager.rs | 10 +- crates/kms/src/probe.rs | 4 +- crates/kms/src/service.rs | 24 +- crates/kms/src/service_manager.rs | 9 +- crates/kms/tests/behavior_lifecycle.rs | 3 +- crates/kms/tests/behavior_objects.rs | 2 +- crates/kms/tests/common/mod.rs | 5 +- crates/protocols/Cargo.toml | 10 +- crates/protocols/src/swift/account.rs | 2 +- crates/protocols/src/swift/formpost.rs | 8 +- crates/protocols/src/swift/slo.rs | 6 +- crates/protocols/src/swift/sync.rs | 2 +- crates/protocols/src/swift/tempurl.rs | 2 +- crates/protocols/src/webdav/server.rs | 3 +- .../tests/swift_metadata_persistence.rs | 2 +- crates/rio-v2/Cargo.toml | 2 +- crates/rio-v2/src/encrypt_reader.rs | 5 +- crates/rio/Cargo.toml | 2 +- crates/rio/src/checksum.rs | 25 +- crates/rio/src/hash_reader.rs | 9 +- rustfs/Cargo.toml | 1 - rustfs/src/admin/handlers/kms_audit.rs | 5 +- rustfs/src/admin/handlers/kms_backup.rs | 16 +- rustfs/src/admin/handlers/kms_dynamic.rs | 8 +- rustfs/src/admin/handlers/kms_key_metadata.rs | 4 +- rustfs/src/admin/handlers/kms_keys.rs | 5 +- rustfs/src/admin/handlers/site_replication.rs | 43 ++- rustfs/src/connect/client.rs | 5 +- rustfs/src/connect/identity.rs | 15 +- rustfs/src/connect/offline/bundle_writer.rs | 6 +- rustfs/src/connect/offline/enrollment.rs | 19 +- rustfs/src/connect/registration.rs | 19 +- rustfs/src/storage/helper.rs | 8 +- rustfs/src/storage/sse.rs | 268 ++++++++++-------- rustfs/tests/connect_identity.rs | 23 +- rustfs/tests/connect_offline_bundle.rs | 6 +- rustfs/tests/connect_offline_enrollment.rs | 41 +-- rustfs/tests/connect_registration.rs | 9 +- 81 files changed, 523 insertions(+), 505 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5bb08ede7..07023e21e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3868,7 +3868,7 @@ dependencies = [ "aws-sdk-s3", "aws-sdk-sts", "aws-smithy-http-client", - "base64 0.23.1", + "base64-simd", "bytes", "chrono", "clap", @@ -3876,7 +3876,7 @@ dependencies = [ "flatbuffers", "flate2", "futures", - "hex", + "hex-simd", "hmac 0.13.0", "hotpath", "http 1.5.0", @@ -9313,7 +9313,6 @@ dependencies = [ "aws-config", "aws-sdk-s3", "axum", - "base64 0.23.1", "base64-simd", "bytes", "chacha20poly1305", @@ -9573,7 +9572,6 @@ dependencies = [ "aws-smithy-http-client", "aws-smithy-runtime-api", "aws-smithy-types", - "base64 0.23.1", "base64-simd", "byteorder", "bytes", @@ -9739,7 +9737,7 @@ name = "rustfs-heal" version = "1.0.0-rc.3" dependencies = [ "async-trait", - "base64 0.23.1", + "base64-simd", "bytes", "crc-fast", "futures", @@ -9944,9 +9942,9 @@ dependencies = [ "aws-smithy-http-client", "aws-smithy-runtime-api", "aws-smithy-types", - "base64 0.23.1", + "base64-simd", "chacha20poly1305", - "hex", + "hex-simd", "hotpath", "http 1.5.0", "insta", @@ -10251,12 +10249,12 @@ dependencies = [ "async-compression", "async-trait", "axum", - "base64 0.23.1", + "base64-simd", "bytes", "dav-server", "futures", "futures-util", - "hex", + "hex-simd", "hmac 0.13.0", "hotpath", "http 1.5.0", @@ -10355,7 +10353,7 @@ dependencies = [ "aes-gcm", "arc-swap", "axum", - "base64 0.23.1", + "base64-simd", "bytes", "crc-fast", "faster-hex", @@ -10395,7 +10393,7 @@ dependencies = [ "aes-gcm", "bytes", "chacha20poly1305", - "hex", + "hex-simd", "hmac 0.13.0", "hotpath", "minlz", diff --git a/Cargo.toml b/Cargo.toml index 7f0b31277..0f7a7f001 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -243,7 +243,6 @@ aws-sdk-sts = { default-features = false, version = "1.113.0" } aws-smithy-http-client = { default-features = false, version = "1.4.0" } aws-smithy-runtime-api = { version = "1.15.0" } aws-smithy-types = { version = "1.6.2" } -base64 = "0.23.1" base64-simd = "0.8.0" brotli = "8.0.4" clap = { version = "4.6.6" } @@ -266,7 +265,6 @@ hashbrown = { version = "0.17.1" } # Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet # every authenticator app expects). Already in the graph transitively. data-encoding = "2.11.1" -hex = "0.4.3" hex-simd = "0.8.0" highway = { version = "1.3.0" } hostname = "0.4.2" diff --git a/crates/e2e_test/Cargo.toml b/crates/e2e_test/Cargo.toml index 81184ad52..75a539561 100644 --- a/crates/e2e_test/Cargo.toml +++ b/crates/e2e_test/Cargo.toml @@ -121,10 +121,10 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "time"] } uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] } urlencoding.workspace = true walkdir.workspace = true -base64 = { workspace = true } +base64-simd = { workspace = true } rand = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] } -hex = { workspace = true } +hex-simd = { workspace = true } md-5 = { workspace = true } opentelemetry-proto = { workspace = true } prost.workspace = true diff --git a/crates/e2e_test/src/checksum_upload_test.rs b/crates/e2e_test/src/checksum_upload_test.rs index 78b5d9fdd..3a242c3b2 100644 --- a/crates/e2e_test/src/checksum_upload_test.rs +++ b/crates/e2e_test/src/checksum_upload_test.rs @@ -24,7 +24,6 @@ mod tests { use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart}; use aws_smithy_http_client::Builder as SmithyHttpClientBuilder; - use base64::Engine; use md5::{Digest as Md5Digest, Md5}; use rustfs_rio::{Checksum, ChecksumType as RioChecksumType}; use sha2::Sha256; @@ -74,12 +73,12 @@ mod tests { let mut hasher = Md5::new(); hasher.update(body); let digest = hasher.finalize(); - base64::engine::general_purpose::STANDARD.encode(digest.as_slice()) + base64_simd::STANDARD.encode_to_string(digest.as_slice()) } fn checksum_sha256_base64(body: &[u8]) -> String { let digest = Sha256::digest(body); - base64::engine::general_purpose::STANDARD.encode(digest.as_slice()) + base64_simd::STANDARD.encode_to_string(digest.as_slice()) } fn checksum_crc64nvme_base64(body: &[u8]) -> String { diff --git a/crates/e2e_test/src/compression_test.rs b/crates/e2e_test/src/compression_test.rs index 1ed0269ec..f9df6167d 100644 --- a/crates/e2e_test/src/compression_test.rs +++ b/crates/e2e_test/src/compression_test.rs @@ -652,11 +652,10 @@ const MPU_SSE_COMPRESSION_BUCKET: &str = "compression-mpu-sse-bucket"; async fn start_rustfs_with_compression_and_sse( env: &mut RustFSTestEnvironment, ) -> Result<(), Box> { - use base64::Engine; env.cleanup_existing_processes().await?; let binary_path = rustfs_binary_path(); - let master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); // Server output goes to a file inside the per-test temp dir so a failing // run can be diagnosed from the child's logs. let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?; diff --git a/crates/e2e_test/src/copy_object_checksum_test.rs b/crates/e2e_test/src/copy_object_checksum_test.rs index b4ac11bbb..a39b04141 100644 --- a/crates/e2e_test/src/copy_object_checksum_test.rs +++ b/crates/e2e_test/src/copy_object_checksum_test.rs @@ -27,8 +27,7 @@ mod tests { VersioningConfiguration, }; use aws_smithy_http_client::Builder as SmithyHttpClientBuilder; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; + use base64_simd::STANDARD as BASE64; use rustfs_rio::{Checksum, ChecksumType as RioChecksumType}; use sha2::{Digest, Sha256}; use tracing::info; @@ -465,7 +464,7 @@ mod tests { create_versioned_bucket(&client, dst_bucket).await; let content = b"deterministic synthetic payload for copy-object checksum #4996"; - let expected_sha256 = BASE64.encode(Sha256::digest(content)); + let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content)); client .put_object() @@ -534,7 +533,7 @@ mod tests { create_versioned_bucket(&client, dst_bucket).await; let content = b"another deterministic payload whose source checksum must survive the copy"; - let expected_sha256 = BASE64.encode(Sha256::digest(content)); + let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content)); // Store the source WITH a SHA-256 checksum so it has one to preserve. let put_src = client @@ -614,7 +613,7 @@ mod tests { create_versioned_bucket(&client, dst_bucket).await; let content = b"payload whose copy must be re-checksummed with a different algorithm"; - let expected_sha256 = BASE64.encode(Sha256::digest(content)); + let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content)); // Source is stored WITH a SHA-256 checksum. client diff --git a/crates/e2e_test/src/fake_s3_target/mod.rs b/crates/e2e_test/src/fake_s3_target/mod.rs index aad643043..c5d7edffb 100644 --- a/crates/e2e_test/src/fake_s3_target/mod.rs +++ b/crates/e2e_test/src/fake_s3_target/mod.rs @@ -1113,7 +1113,7 @@ fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] { fn md5_hex(input: impl AsRef<[u8]>) -> String { let mut hasher = Md5::new(); hasher.update(input.as_ref()); - hex::encode(hasher.finalize()) + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower) } fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result { @@ -1375,7 +1375,7 @@ impl S3 for FakeBackend { Some(value) => value, None => { let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?; - hex::encode(digest) + hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower) } }; let version = ObjectVersion { @@ -1660,7 +1660,7 @@ impl S3 for FakeBackend { } let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?; let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?; - let e_tag = hex::encode(digest); + let e_tag = hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower); let mut state = lock(&self.store); let existing_bytes = state .uploads diff --git a/crates/e2e_test/src/inline_fast_path_cluster_test.rs b/crates/e2e_test/src/inline_fast_path_cluster_test.rs index 8de63303f..5583a1b84 100644 --- a/crates/e2e_test/src/inline_fast_path_cluster_test.rs +++ b/crates/e2e_test/src/inline_fast_path_cluster_test.rs @@ -28,7 +28,6 @@ use aws_sdk_s3::types::{ BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, ServerSideEncryption, Transition, TransitionStorageClass, VersioningConfiguration, }; -use base64::Engine; use bytes::Bytes; use flate2::read::GzDecoder; use http::header::{CONTENT_ENCODING, HOST}; @@ -1808,7 +1807,7 @@ async fn four_node_inline_fallback_controls() -> TestResult { let collector = OtlpMetricCollector::start().await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?; configure_reader_metric_cluster(&mut cluster, &collector); - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", &sse_master_key); cluster.start().await?; @@ -2017,7 +2016,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te let collector = OtlpMetricCollector::start().await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?; - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key); cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true"); @@ -2489,7 +2488,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_ hot.set_env("RUSTFS_SCANNER_CYCLE", "1"); hot.set_env("RUSTFS_ILM_PROCESS_TIME", "1"); - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); hot.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key); hot.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); hot.start().await?; diff --git a/crates/e2e_test/src/kms/common.rs b/crates/e2e_test/src/kms/common.rs index b551a9849..af9b410db 100644 --- a/crates/e2e_test/src/kms/common.rs +++ b/crates/e2e_test/src/kms/common.rs @@ -27,7 +27,7 @@ use aws_sdk_s3::Client; use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError}; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::ServerSideEncryption; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use http::header::{CONTENT_TYPE, HOST}; use md5::{Digest as Md5Digest, Md5}; use rustfs_signer::constants::UNSIGNED_PAYLOAD; @@ -64,7 +64,7 @@ pub fn init_logging() { pub fn sse_customer_key_md5_base64(key: &str) -> String { let mut hasher = Md5::new(); hasher.update(key.as_bytes()); - BASE64.encode(hasher.finalize()) + BASE64.encode_to_string(hasher.finalize()) } pub fn assert_s3_error(result: Result>, status: u16, code: &str, message: &str, context: &str) @@ -365,7 +365,7 @@ pub async fn create_key_with_specific_id(key_dir: &str, key_id: &str) -> Result< "created_at": format!("{}[UTC]", chrono::Utc::now().to_rfc3339()), "rotated_at": serde_json::Value::Null, "created_by": "e2e-test", - "encrypted_key_material": BASE64.encode(key_data), + "encrypted_key_material": BASE64.encode_to_string(key_data), "nonce": Vec::::new() }); @@ -383,7 +383,7 @@ pub async fn test_sse_c_encryption(s3_client: &Client, bucket: &str) -> Result<( info!("Testing SSE-C encryption"); let test_key = "01234567890123456789012345678901"; // 32-byte key - let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key); + let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key); let test_key_md5 = sse_customer_key_md5_base64(test_key); let test_data = b"Hello, KMS SSE-C World!"; let object_key = "test-sse-c-object"; @@ -551,8 +551,8 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<() // Test SSE-C with wrong key for download let test_key = "01234567890123456789012345678901"; let wrong_key = "98765432109876543210987654321098"; - let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key); - let wrong_key_b64 = base64::engine::general_purpose::STANDARD.encode(wrong_key); + let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key); + let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key); let test_key_md5 = sse_customer_key_md5_base64(test_key); let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key); let test_data = b"Test data for error scenarios"; @@ -807,7 +807,7 @@ pub async fn test_multipart_upload_with_config( // Prepare encryption parameters let (sse_c_key_b64, sse_c_key_md5) = match &config.encryption_type { EncryptionType::SSEC { key, key_md5 } => { - let key_b64 = base64::engine::general_purpose::STANDARD.encode(key); + let key_b64 = base64_simd::STANDARD.encode_to_string(key); (Some(key_b64), Some(key_md5.clone())) } _ => (None, None), diff --git a/crates/e2e_test/src/kms/kms_comprehensive_test.rs b/crates/e2e_test/src/kms/kms_comprehensive_test.rs index d4bbf604a..8569118bf 100644 --- a/crates/e2e_test/src/kms/kms_comprehensive_test.rs +++ b/crates/e2e_test/src/kms/kms_comprehensive_test.rs @@ -177,7 +177,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box { // SSE-C let key = format!("testkey{i:026}"); // 32-byte key - let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key); + let key_b64 = base64_simd::STANDARD.encode_to_string(&key); let key_md5 = sse_customer_key_md5_base64(&key); client @@ -535,8 +534,8 @@ async fn test_kms_key_validation_security() -> Result<(), Box Result<(), Box) -> String { "conditions": conditions, }); - base64::engine::general_purpose::STANDARD.encode(policy.to_string()) + base64_simd::STANDARD.encode_to_string(policy.to_string()) } fn sse_customer_key_md5_base64(key: &str) -> String { let mut hasher = Md5::new(); hasher.update(key.as_bytes()); - base64::engine::general_purpose::STANDARD.encode(hasher.finalize()) + base64_simd::STANDARD.encode_to_string(hasher.finalize()) } fn md5_hex(input: impl AsRef<[u8]>) -> String { let mut hasher = Md5::new(); hasher.update(input.as_ref()); - hex::encode(hasher.finalize()) + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower) } async fn create_restricted_user( @@ -97,7 +96,7 @@ fn restricted_user_client(env: &RustFSTestEnvironment, username: &str, secret_ke const LOCAL_SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY"; fn local_sse_master_key_value() -> String { - base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]) + base64_simd::STANDARD.encode_to_string([0x42u8; 32]) } async fn make_tar(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec { @@ -1887,7 +1886,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition let object_key = "sse-c-object.txt"; let expected_body = b"anonymous-post-sse-c".to_vec(); let customer_key = "01234567890123456789012345678901"; - let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key); + let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key); let customer_key_md5 = sse_customer_key_md5_base64(customer_key); let admin_client = env.create_s3_client(); @@ -1941,7 +1940,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition .bucket(bucket) .key(object_key) .sse_customer_algorithm("AES256") - .sse_customer_key(base64::engine::general_purpose::STANDARD.encode(customer_key)) + .sse_customer_key(base64_simd::STANDARD.encode_to_string(customer_key)) .sse_customer_key_md5(customer_key_md5) .send() .await?; @@ -1963,8 +1962,8 @@ async fn test_anonymous_post_object_rejects_sse_c_exact_policy_mismatch() -> Res let object_key = "sse-c-mismatch-object.txt"; let policy_key = "01234567890123456789012345678901"; let request_key = "abcdefghijklmnopqrstuvwxyzABCDEF"; - let policy_key_b64 = base64::engine::general_purpose::STANDARD.encode(policy_key); - let request_key_b64 = base64::engine::general_purpose::STANDARD.encode(request_key); + let policy_key_b64 = base64_simd::STANDARD.encode_to_string(policy_key); + let request_key_b64 = base64_simd::STANDARD.encode_to_string(request_key); let admin_client = env.create_s3_client(); admin_client.create_bucket().bucket(bucket).send().await?; @@ -3526,7 +3525,7 @@ async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Resul init_logging(); let mut env = RustFSTestEnvironment::new().await?; - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())]) .await?; @@ -3799,7 +3798,7 @@ async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<( init_logging(); let mut env = RustFSTestEnvironment::new().await?; - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())]) .await?; @@ -3925,7 +3924,7 @@ async fn test_signed_put_object_extract_preserves_sse_c() -> Result<(), Box String { fn basic_auth_header_for(access_key: &str, secret_key: &str) -> String { let credentials = format!("{}:{}", access_key, secret_key); - let encoded = base64::engine::general_purpose::STANDARD.encode(credentials); + let encoded = base64_simd::STANDARD.encode_to_string(credentials); format!("Basic {}", encoded) } diff --git a/crates/e2e_test/src/replication_extension_test.rs b/crates/e2e_test/src/replication_extension_test.rs index 479fbeeed..98b9a5d8c 100644 --- a/crates/e2e_test/src/replication_extension_test.rs +++ b/crates/e2e_test/src/replication_extension_test.rs @@ -34,7 +34,7 @@ use aws_sdk_s3::types::{ VersioningConfiguration, }; use aws_sdk_s3::{Client, Config}; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use base64_simd::STANDARD as BASE64_STANDARD; use bytes::Bytes; use flate2::read::GzDecoder; use futures::{Stream, StreamExt}; @@ -1244,7 +1244,7 @@ async fn wait_for_source_replication_pending_or_failed( } async fn wait_for_source_replication_status(client: &Client, bucket: &str, key: &str, expected: &str, ssec: bool) -> TestResult { - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); let wait = async { loop { @@ -1339,7 +1339,7 @@ async fn assert_failed_replication_stays_absent_for( ssec: bool, duration: Duration, ) -> TestResult { - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); let wait = async { let deadline = tokio::time::Instant::now() + duration; @@ -4332,7 +4332,7 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult { let target_client = target_env.create_s3_client(); let key = "ssec-contract.txt"; let body = b"repl-17 SSE-C payload"; - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); source_client @@ -4387,7 +4387,7 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult { ); // A wrong customer key must fail too. - let wrong_key = BASE64_STANDARD.encode("99999999999999999999999999999999"); + let wrong_key = BASE64_STANDARD.encode_to_string("99999999999999999999999999999999"); let wrong_key_md5 = sse_customer_key_md5_base64("99999999999999999999999999999999"); let wrong_read = target_client .get_object() @@ -4423,7 +4423,7 @@ async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult { let source_client = source_env.create_s3_client(); let target_client = target_env.create_s3_client(); let key = "ssec-mp-contract.bin"; - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); let created = source_client @@ -4568,7 +4568,7 @@ async fn test_ssec_replication_fails_closed_when_target_drops_passthrough_header .await?; put_bucket_replication(&source_env, source_bucket, &target_arn).await?; - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); let put_ssec = |key: &'static str| { source_client @@ -4741,7 +4741,7 @@ async fn test_bucket_replication_sse_c_heals_after_target_outage() -> TestResult let source_client = source_env.create_s3_client(); let key = "ssec-heal-contract.txt"; let body = b"repl-22 ssec heal payload".to_vec(); - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); // Target outage: the SSE-C write cannot replicate. @@ -4856,7 +4856,7 @@ async fn test_bucket_replication_sse_c_existing_object_resync() -> TestResult { // The SSE-C object exists before any replication wiring. let key = "ssec-existing-contract.txt"; let body = b"repl-22 ssec existing-object payload".to_vec(); - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); source_client .put_object() @@ -9152,7 +9152,7 @@ async fn test_get_and_head_proxy_unreplicated_object_to_replication_target() -> // the real SSE-C decryption; the plaintext fake simply ignores them). target.take_requests(); let ssec_key = "01234567890123456789012345678901"; - let ssec_key_b64 = BASE64_STANDARD.encode(ssec_key); + let ssec_key_b64 = BASE64_STANDARD.encode_to_string(ssec_key); let ssec_key_md5 = sse_customer_key_md5_base64(ssec_key); let _ = source_client .get_object() diff --git a/crates/e2e_test/src/ssec_copy_test.rs b/crates/e2e_test/src/ssec_copy_test.rs index e84ed9b11..0ca783ff3 100644 --- a/crates/e2e_test/src/ssec_copy_test.rs +++ b/crates/e2e_test/src/ssec_copy_test.rs @@ -21,7 +21,6 @@ use aws_sdk_s3::error::BoxError; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration}; use aws_smithy_http_client::Builder as SmithyHttpClientBuilder; -use base64::Engine; use md5::{Digest as Md5Digest, Md5}; use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -107,8 +106,8 @@ fn customer_key(byte: u8) -> CustomerKey { hasher.update(raw); CustomerKey { raw: String::from_utf8_lossy(&raw).into_owned(), - encoded: base64::engine::general_purpose::STANDARD.encode(raw), - md5: base64::engine::general_purpose::STANDARD.encode(hasher.finalize()), + encoded: base64_simd::STANDARD.encode_to_string(raw), + md5: base64_simd::STANDARD.encode_to_string(hasher.finalize()), } } diff --git a/crates/ecstore/Cargo.toml b/crates/ecstore/Cargo.toml index cbfafd4a9..c2d605510 100644 --- a/crates/ecstore/Cargo.toml +++ b/crates/ecstore/Cargo.toml @@ -176,7 +176,6 @@ rmp.workspace = true rmp-serde.workspace = true tokio-util = { workspace = true, features = ["io", "compat"] } tokio-stream = { workspace = true, features = ["sync"] } -base64 = { workspace = true } hmac = { workspace = true } sha2 = { workspace = true } hex-simd = { workspace = true } diff --git a/crates/ecstore/src/bucket/replication/replication_target_boundary.rs b/crates/ecstore/src/bucket/replication/replication_target_boundary.rs index 50aa14cd7..dbe3a29d5 100644 --- a/crates/ecstore/src/bucket/replication/replication_target_boundary.rs +++ b/crates/ecstore/src/bucket/replication/replication_target_boundary.rs @@ -200,7 +200,7 @@ impl ReplicationTargetStore { } pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo) -> Result<(PutObjectOptions, bool)> { - use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; + use base64_simd::STANDARD as BASE64_STANDARD; use rustfs_utils::http::{AMZ_CHECKSUM_TYPE, AMZ_CHECKSUM_TYPE_FULL_OBJECT}; let mut meta = HashMap::new(); @@ -252,7 +252,7 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo) && !checksum_data.is_empty() { if is_ssec { - let encoded = BASE64_STANDARD.encode(checksum_data); + let encoded = BASE64_STANDARD.encode_to_string(checksum_data); insert_header_map(&mut meta, SUFFIX_REPLICATION_SSEC_CRC, encoded); } else if object_info.is_encrypted() { // Encrypted checksums cannot be exposed as plaintext headers, and diff --git a/crates/ecstore/src/cluster/rpc/http_auth.rs b/crates/ecstore/src/cluster/rpc/http_auth.rs index 00b0dd2bf..157da2c57 100644 --- a/crates/ecstore/src/cluster/rpc/http_auth.rs +++ b/crates/ecstore/src/cluster/rpc/http_auth.rs @@ -31,8 +31,7 @@ use crate::storage_api_contracts::internode::{ NS_SCANNER_PROTOCOL_VERSION, PUT_FILE_AUTH_TRAILER_DIGEST_LEN, PUT_FILE_AUTH_TRAILER_LEN, PUT_FILE_AUTH_TRAILER_MAC_LEN, PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_CAPABILITY_VERSION, }; -use base64::Engine as _; -use base64::engine::general_purpose; + use hmac::{Hmac, KeyInit, Mac}; use http::uri::Authority; use http::{HeaderMap, HeaderValue, Method, Uri}; @@ -523,11 +522,11 @@ fn generate_signature(secret: &str, url: &str, method: &Method, timestamp: i64) let mut mac = ::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); mac.update(data.as_bytes()); let result = mac.finalize(); - general_purpose::STANDARD.encode(result.into_bytes()) + base64_simd::STANDARD.encode_to_string(result.into_bytes()) } fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, signature: &str) -> bool { - let Ok(signature) = general_purpose::STANDARD.decode(signature) else { + let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else { return false; }; @@ -745,11 +744,11 @@ fn generate_signature_v2(secret: &str, scope: SignatureV2Scope<'_>) -> std::io:: let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; update_signature_v2(&mut mac, scope); - Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes())) + Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes())) } fn verify_signature_v2(secret: &str, scope: SignatureV2Scope<'_>, signature: &str) -> bool { - let Ok(signature) = general_purpose::STANDARD.decode(signature) else { + let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else { return false; }; let Ok(mut mac) = ::new_from_slice(secret.as_bytes()) else { @@ -792,11 +791,11 @@ fn generate_replay_scope_signature(secret: &str, scope: ReplayScope<'_>) -> std: let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; update_replay_scope(&mut mac, scope); - Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes())) + Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes())) } fn verify_replay_scope_signature(secret: &str, scope: ReplayScope<'_>, signature: &str) -> bool { - let Ok(signature) = general_purpose::STANDARD.decode(signature) else { + let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else { return false; }; let Ok(mut mac) = ::new_from_slice(secret.as_bytes()) else { @@ -821,15 +820,15 @@ fn generate_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; update_boot_epoch_proof(&mut mac, audience, challenge, boot_epoch); - Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes())) + Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes())) } fn verify_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot_epoch: Uuid, proof: &str) -> std::io::Result<()> { if audience.is_empty() || challenge.is_nil() || boot_epoch.is_nil() { return Err(std::io::Error::other("Invalid RPC boot epoch proof scope")); } - let proof = general_purpose::STANDARD - .decode(proof) + let proof = base64_simd::STANDARD + .decode_to_vec(proof) .map_err(|_| std::io::Error::other("Invalid RPC boot epoch proof"))?; let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; @@ -862,7 +861,7 @@ fn generate_replay_cache_capability_proof( let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; update_replay_cache_capability_proof(&mut mac, audience, challenge, boot_epoch); - Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes())) + Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes())) } fn verify_replay_cache_capability_proof( @@ -872,8 +871,8 @@ fn verify_replay_cache_capability_proof( boot_epoch: Uuid, proof: &str, ) -> std::io::Result<()> { - let proof = general_purpose::STANDARD - .decode(proof) + let proof = base64_simd::STANDARD + .decode_to_vec(proof) .map_err(|_| std::io::Error::other("Invalid RPC replay cache capability proof"))?; let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; @@ -1988,9 +1987,9 @@ mod tests { let method = Method::GET; let timestamp = 1640995200; let signature = generate_signature(secret, url, &method, timestamp); - let mut tampered = general_purpose::STANDARD.decode(&signature).unwrap(); + let mut tampered = base64_simd::STANDARD.decode_to_vec(&signature).unwrap(); tampered[0] ^= 1; - let tampered_signature = general_purpose::STANDARD.encode(tampered); + let tampered_signature = base64_simd::STANDARD.encode_to_string(tampered); assert!(verify_signature(secret, url, &method, timestamp, &signature)); assert!(!verify_signature(secret, url, &method, timestamp, &tampered_signature)); diff --git a/crates/ecstore/src/object_api/readers.rs b/crates/ecstore/src/object_api/readers.rs index 9fc69118d..8bcc23974 100644 --- a/crates/ecstore/src/object_api/readers.rs +++ b/crates/ecstore/src/object_api/readers.rs @@ -1407,8 +1407,7 @@ fn multipart_part_numbers(parts: &[ObjectPartInfo]) -> Vec { #[cfg(test)] mod tests { use super::*; - use base64::Engine; - use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use base64_simd::STANDARD as BASE64_STANDARD; use md5::{Digest, Md5}; use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_MD5_HEADER}; use std::collections::HashMap; @@ -1468,7 +1467,7 @@ mod tests { request: ReadEncryptionRequest<'_>, ) -> std::result::Result, EncryptionResolutionError> { if let Some(encoded) = request.metadata.get(TEST_OBJECT_KEY_HEADER) { - let decoded = BASE64_STANDARD.decode(encoded).map_err(|_| { + let decoded = BASE64_STANDARD.decode_to_vec(encoded).map_err(|_| { EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidMetadata, "invalid test object key") })?; let key_bytes = decoded.try_into().map_err(|_| { @@ -1493,7 +1492,7 @@ mod tests { .map_err(|_| { EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidRequest, "invalid test encryption key") })?; - let decoded = BASE64_STANDARD.decode(encoded).map_err(|_| { + let decoded = BASE64_STANDARD.decode_to_vec(encoded).map_err(|_| { EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidRequest, "invalid test encryption key") })?; let key_bytes = decoded.try_into().map_err(|_| { @@ -1505,7 +1504,7 @@ mod tests { let base_nonce = request .metadata .get(TEST_NONCE_HEADER) - .and_then(|encoded| BASE64_STANDARD.decode(encoded).ok()) + .and_then(|encoded| BASE64_STANDARD.decode_to_vec(encoded).ok()) .and_then(|bytes| bytes.try_into().ok()) .unwrap_or_else(|| fixture_nonce(request.bucket, request.object)); Ok(Some(ReadEncryptionMaterial { @@ -1533,7 +1532,7 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert( TEST_DIRECT_KEY_HEADER, - HeaderValue::from_str(&BASE64_STANDARD.encode(key_bytes)).expect("test key header is valid"), + HeaderValue::from_str(&BASE64_STANDARD.encode_to_string(key_bytes)).expect("test key header is valid"), ); headers } @@ -2439,7 +2438,10 @@ mod tests { user_defined: Arc::new(HashMap::from([ ("X-Amz-Server-Side-Encryption".to_string(), "aws:kms".to_string()), ("X-Amz-Server-Side-Encryption-Iv".to_string(), "AAAAAAAAAAAAAAAA".to_string()), - ("X-Amz-Server-Side-Encryption-Key".to_string(), BASE64_STANDARD.encode([7_u8; 32])), + ( + "X-Amz-Server-Side-Encryption-Key".to_string(), + BASE64_STANDARD.encode_to_string([7_u8; 32]), + ), ("x-rustfs-encryption-original-size".to_string(), "64".to_string()), ])), ..Default::default() @@ -2748,7 +2750,7 @@ mod tests { ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(key_bytes)), + BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -2796,11 +2798,11 @@ mod tests { name: object.to_string(), size: encrypted.len() as i64, user_defined: Arc::new(HashMap::from([ - (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)), + (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)), ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(customer_key)), + BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -2856,7 +2858,7 @@ mod tests { ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(key_bytes)), + BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -3008,13 +3010,13 @@ mod tests { ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(key_bytes)), + BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), total_plaintext.to_string(), ), - (TEST_NONCE_HEADER.to_string(), BASE64_STANDARD.encode(LEGACY_FIXTURE_BASE_NONCE)), + (TEST_NONCE_HEADER.to_string(), BASE64_STANDARD.encode_to_string(LEGACY_FIXTURE_BASE_NONCE)), ]) } @@ -3751,11 +3753,11 @@ mod tests { name: object.to_string(), size: encrypted.len() as i64, user_defined: Arc::new(HashMap::from([ - (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)), + (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)), ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(customer_key)), + BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -3821,7 +3823,7 @@ mod tests { ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(key_bytes)), + BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -3964,11 +3966,11 @@ mod tests { ..Default::default() }]), user_defined: Arc::new(HashMap::from([ - (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)), + (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)), ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(customer_key)), + BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), diff --git a/crates/ecstore/src/store/list_objects.rs b/crates/ecstore/src/store/list_objects.rs index 683e9eeb3..e3ed34d24 100644 --- a/crates/ecstore/src/store/list_objects.rs +++ b/crates/ecstore/src/store/list_objects.rs @@ -42,7 +42,7 @@ use crate::storage_api_contracts::{ }; use crate::store::ECStore; use crate::store::utils::is_reserved_or_invalid_bucket; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use base64_simd::STANDARD as BASE64_STANDARD; use bytes::Bytes; use futures::future::join_all; use rand::seq::SliceRandom; @@ -1398,11 +1398,11 @@ async fn persist_observed_list_objects_mutation(store: Option<&ECStore>, bucket: } fn encode_persistent_list_metadata_string(value: &str) -> String { - BASE64_STANDARD.encode(value.as_bytes()) + BASE64_STANDARD.encode_to_string(value.as_bytes()) } fn decode_persistent_list_metadata_string(value: &str) -> Option { - let bytes = BASE64_STANDARD.decode(value).ok()?; + let bytes = BASE64_STANDARD.decode_to_vec(value).ok()?; String::from_utf8(bytes).ok() } diff --git a/crates/heal/Cargo.toml b/crates/heal/Cargo.toml index ddedc83d4..511595429 100644 --- a/crates/heal/Cargo.toml +++ b/crates/heal/Cargo.toml @@ -90,7 +90,7 @@ uuid = { workspace = true, features = ["v4", "serde", "fast-rng", "macro-diagnos async-trait = { workspace = true } futures = { workspace = true } metrics = { workspace = true } -base64 = { workspace = true } +base64-simd = { workspace = true } bytes = { workspace = true } crc-fast = { workspace = true } sha2 = { workspace = true } diff --git a/crates/heal/src/heal/resume/checkpoint.rs b/crates/heal/src/heal/resume/checkpoint.rs index b3f097c9f..505f10a4d 100644 --- a/crates/heal/src/heal/resume/checkpoint.rs +++ b/crates/heal/src/heal/resume/checkpoint.rs @@ -13,7 +13,6 @@ // limitations under the License. use crate::{Error, Result}; -use base64::Engine as _; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashSet; @@ -827,7 +826,7 @@ impl CheckpointManager { } fn checkpoint_digest(checkpoint_data: &[u8]) -> String { - base64::engine::general_purpose::STANDARD.encode(Sha256::digest(checkpoint_data)) + base64_simd::STANDARD.encode_to_string(Sha256::digest(checkpoint_data)) } fn digest_path(task_id: &str) -> std::path::PathBuf { diff --git a/crates/heal/src/heal/storage.rs b/crates/heal/src/heal/storage.rs index f84a75a60..1f857aca1 100644 --- a/crates/heal/src/heal/storage.rs +++ b/crates/heal/src/heal/storage.rs @@ -14,8 +14,7 @@ use crate::{Error, Result}; use async_trait::async_trait; -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64_simd::URL_SAFE_NO_PAD; use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use rustfs_madmin::heal_commands::HealResultItem; use serde::{Deserialize, Serialize}; @@ -147,7 +146,7 @@ pub(crate) fn encode_heal_token(marker: Option<&str>, version_marker: Option<&st // serde_json of a simple two-Option struct cannot fail; fall back to an // empty object rather than panicking if it somehow does. let json = serde_json::to_vec(&payload).unwrap_or_else(|_| b"{}".to_vec()); - format!("{HEAL_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(json)) + format!("{HEAL_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode_to_string(json)) } /// Decode an opaque heal continuation token back into `(marker, version_marker)`. @@ -174,7 +173,7 @@ pub(crate) fn decode_heal_token(token: &str) -> (Option, Option) return (None, None); }; - let bytes = match URL_SAFE_NO_PAD.decode(encoded) { + let bytes = match URL_SAFE_NO_PAD.decode_to_vec(encoded) { Ok(bytes) => bytes, Err(e) => { warn!( @@ -234,7 +233,7 @@ const DISK_WALK_TOKEN_PREFIX: &str = "dw1:"; /// enumerators can never misread each other's cursor: a `dw1:` token decodes to /// `(None, None)` under the B5 decoder, and a `v1:` token decodes to `None` here. pub(crate) fn encode_disk_walk_token(next_forward: &str) -> String { - format!("{DISK_WALK_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(next_forward.as_bytes())) + format!("{DISK_WALK_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode_to_string(next_forward.as_bytes())) } /// Decode a disk-walk continuation token back into the `next_forward` object key. @@ -261,7 +260,7 @@ pub(crate) fn decode_disk_walk_token(token: &str) -> Option { return None; }; - let bytes = match URL_SAFE_NO_PAD.decode(encoded) { + let bytes = match URL_SAFE_NO_PAD.decode_to_vec(encoded) { Ok(bytes) => bytes, Err(e) => { warn!( @@ -1513,7 +1512,6 @@ mod tests { decode_disk_walk_token, decode_heal_token, encode_disk_walk_token, encode_heal_token, is_transient_object_exists_error, is_transient_object_exists_message, next_heal_listing_token, }; - use base64::Engine as _; #[test] fn next_heal_listing_token_returns_none_for_complete_page() { @@ -1564,7 +1562,7 @@ mod tests { assert_eq!(decode_heal_token("no-prefix-here"), (None, None)); assert_eq!(decode_heal_token("v1:!!!not-base64!!!"), (None, None)); // valid base64 of non-JSON bytes. - let bad_json = format!("v1:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json")); + let bad_json = format!("v1:{}", base64_simd::URL_SAFE_NO_PAD.encode_to_string(b"not json")); assert_eq!(decode_heal_token(&bad_json), (None, None)); // a raw v2-style list_objects_v2 token (no "v1:" prefix) resets cleanly. assert_eq!(decode_heal_token("some-opaque-legacy-token"), (None, None)); @@ -1576,7 +1574,7 @@ mod tests { // list_object_versions returns NotImplemented for that pairing. // Craft a token whose JSON encodes (None, Some) directly and confirm coercion. let json = br#"{"m":null,"v":"orphan-version"}"#; - let token = format!("v1:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)); + let token = format!("v1:{}", base64_simd::URL_SAFE_NO_PAD.encode_to_string(json)); assert_eq!(decode_heal_token(&token), (None, None), "version-only marker must coerce to (None, None)"); } diff --git a/crates/kms/Cargo.toml b/crates/kms/Cargo.toml index f8491d5a2..77f853097 100644 --- a/crates/kms/Cargo.toml +++ b/crates/kms/Cargo.toml @@ -50,8 +50,8 @@ aes-gcm = { workspace = true, features = ["rand_core"] } argon2 = { workspace = true } chacha20poly1305 = { workspace = true } rand = { workspace = true, features = ["serde"] } -base64 = { workspace = true } -hex = { workspace = true } +base64-simd = { workspace = true } +hex-simd = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } zeroize = { workspace = true, features = ["derive"] } diff --git a/crates/kms/examples/kms_dr_drill.rs b/crates/kms/examples/kms_dr_drill.rs index 9074bb202..7c673b4e3 100644 --- a/crates/kms/examples/kms_dr_drill.rs +++ b/crates/kms/examples/kms_dr_drill.rs @@ -23,7 +23,7 @@ //! Exit status is the verdict: 0 when every check held, 1 otherwise, so a //! scheduled drill fails its job instead of quietly filing a bad report. -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use rustfs_kms::backup::{BackupKek, DrillDataset, DrillDisaster, DrillRequest, DrillVerdict, run_local_drill}; use std::path::PathBuf; use std::process::ExitCode; @@ -100,7 +100,11 @@ fn disaster_from_env() -> Result { fn kek_from_env() -> Result { let raw = Zeroizing::new(required(ENV_KEK)?); - let decoded = Zeroizing::new(BASE64.decode(raw.trim()).map_err(|_| format!("{ENV_KEK} must be base64"))?); + let decoded = Zeroizing::new( + BASE64 + .decode_to_vec(raw.trim()) + .map_err(|_| format!("{ENV_KEK} must be base64"))?, + ); if decoded.len() != 32 { return Err(format!("{ENV_KEK} must decode to exactly 32 bytes")); } diff --git a/crates/kms/examples/local_kms_key_decrypt.rs b/crates/kms/examples/local_kms_key_decrypt.rs index 37f174ccc..75365d90a 100644 --- a/crates/kms/examples/local_kms_key_decrypt.rs +++ b/crates/kms/examples/local_kms_key_decrypt.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use base64_simd::STANDARD as BASE64_STANDARD; use rustfs_kms::{LocalConfig, backends::local::LocalKmsClient}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -69,7 +69,7 @@ async fn run() -> Result<(), String> { .decrypt_key_material_for_export(&key_id) .await .map_err(|error| error.to_string())?; - let encoded = Zeroizing::new(BASE64_STANDARD.encode(key_material.as_ref())); + let encoded = Zeroizing::new(BASE64_STANDARD.encode_to_string(key_material.as_ref())); let mut stdout = io::stdout().lock(); writeln!(stdout, "{}", encoded.as_str()).map_err(|error| format!("failed to write decrypted key: {error}")) diff --git a/crates/kms/src/audit.rs b/crates/kms/src/audit.rs index df809926e..87a09e080 100644 --- a/crates/kms/src/audit.rs +++ b/crates/kms/src/audit.rs @@ -305,7 +305,7 @@ pub fn redact_encryption_context(encryption_context: &HashMap) - } fn digest_value(value: &str) -> String { - let digest = hex::encode(Sha256::digest(value.as_bytes())); + let digest = hex_simd::encode_to_string(Sha256::digest(value.as_bytes()), hex_simd::AsciiCase::Lower); format!("{DIGEST_PREFIX}{}", &digest[..DIGEST_LEN]) } diff --git a/crates/kms/src/backends/aws.rs b/crates/kms/src/backends/aws.rs index 93b85c956..87ee53ae7 100644 --- a/crates/kms/src/backends/aws.rs +++ b/crates/kms/src/backends/aws.rs @@ -848,8 +848,7 @@ mod tests { use aws_sdk_kms::config::{BehaviorVersion, Credentials, Region}; use aws_smithy_http_client::test_util::{NeverClient, ReplayEvent, StaticReplayClient}; use aws_smithy_types::body::SdkBody; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; + use base64_simd::STANDARD as BASE64; use std::sync::atomic::{AtomicU64, Ordering}; /// AWS KMS speaks awsJson1_1; every request goes to `/` on the regional @@ -977,8 +976,8 @@ mod tests { let ciphertext = b"encrypted-data-key".to_vec(); let (http_client, backend) = scripted_backend(vec![ok_event(serde_json::json!({ "KeyId": "arn:aws:kms:us-east-1:111122223333:key/test-key", - "Plaintext": BASE64.encode(&plaintext), - "CiphertextBlob": BASE64.encode(&ciphertext), + "Plaintext": BASE64.encode_to_string(&plaintext), + "CiphertextBlob": BASE64.encode_to_string(&ciphertext), }))]); let response = backend @@ -997,7 +996,7 @@ mod tests { let plaintext = b"recovered-data-key".to_vec(); let (_http, backend) = scripted_backend(vec![ok_event(serde_json::json!({ "KeyId": "arn:aws:kms:us-east-1:111122223333:key/test-key", - "Plaintext": BASE64.encode(&plaintext), + "Plaintext": BASE64.encode_to_string(&plaintext), "EncryptionAlgorithm": "SYMMETRIC_DEFAULT", }))]); @@ -1061,8 +1060,8 @@ mod tests { error_event(400, "ThrottlingException", "rate exceeded"), ok_event(serde_json::json!({ "KeyId": "test-key", - "Plaintext": BASE64.encode([1u8; 32]), - "CiphertextBlob": BASE64.encode(b"blob"), + "Plaintext": BASE64.encode_to_string([1u8; 32]), + "CiphertextBlob": BASE64.encode_to_string(b"blob"), })), ]); diff --git a/crates/kms/src/backends/contract_tests.rs b/crates/kms/src/backends/contract_tests.rs index ed1342f1f..8b25643b0 100644 --- a/crates/kms/src/backends/contract_tests.rs +++ b/crates/kms/src/backends/contract_tests.rs @@ -41,8 +41,7 @@ use crate::types::{ DescribeKeyRequest, EncryptRequest, GenerateDataKeyRequest, KeySpec, KeyState, KeyUsage, ObjectEncryptionContext, RewrapDataKeyRequest, }; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; +use base64_simd::STANDARD as BASE64; use rand::RngExt as _; use std::collections::HashMap; use std::sync::Arc; @@ -285,7 +284,7 @@ async fn static_backend_stateless_contract() { let key_id = "static-contract-key"; let mut raw_key = [0u8; 32]; rand::rng().fill(&mut raw_key[..]); - let config = KmsConfig::static_kms(key_id.to_string(), BASE64.encode(raw_key)); + let config = KmsConfig::static_kms(key_id.to_string(), BASE64.encode_to_string(raw_key)); let static_backend = StaticKmsBackend::new(config).await.expect("static backend should build"); let backend: &dyn KmsBackend = &static_backend; diff --git a/crates/kms/src/backends/local.rs b/crates/kms/src/backends/local.rs index b694f366a..ac10274fc 100644 --- a/crates/kms/src/backends/local.rs +++ b/crates/kms/src/backends/local.rs @@ -33,7 +33,7 @@ use aes_gcm::{ }; use argon2::{Algorithm, Argon2, Params, Version}; use async_trait::async_trait; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use jiff::Zoned; use rand::RngExt; use serde::de::{self, IgnoredAny, MapAccess, Visitor}; @@ -1268,7 +1268,7 @@ impl LocalKmsClient { } let encrypted_bytes = BASE64 - .decode(&stored_key.encrypted_key_material) + .decode_to_vec(&stored_key.encrypted_key_material) .map_err(|e| KmsError::material_corrupt(key_id, format!("stored key material is not valid base64: {e}")))?; let effective_protection = if stored_key.at_rest_protection == StoredKeyProtection::LegacyUnspecified { @@ -1406,13 +1406,17 @@ impl LocalKmsClient { .encrypt(&nonce, key_material) .map_err(|e| KmsError::cryptographic_error("encrypt", e.to_string()))?; // Encode encrypted bytes to base64 string - (BASE64.encode(&encrypted), nonce.to_vec(), StoredKeyProtection::EncryptedMasterKey) + ( + BASE64.encode_to_string(&encrypted), + nonce.to_vec(), + StoredKeyProtection::EncryptedMasterKey, + ) } else { warn!( key_id = %master_key.key_id, "Local KMS is storing key material as plaintext-dev-only because no master key is configured" ); - (BASE64.encode(key_material), Vec::new(), StoredKeyProtection::PlaintextDevOnly) + (BASE64.encode_to_string(key_material), Vec::new(), StoredKeyProtection::PlaintextDevOnly) }; let stored_key = StoredMasterKey { @@ -2648,10 +2652,10 @@ mod tests { let tampered_material = { let mut material = BASE64 - .decode(pristine["encrypted_key_material"].as_str().expect("material is a string")) + .decode_to_vec(pristine["encrypted_key_material"].as_str().expect("material is a string")) .expect("decode pristine material"); *material.last_mut().expect("material is not empty") ^= 0x01; - BASE64.encode(&material) + BASE64.encode_to_string(&material) }; type PoisonCase = (&'static str, Vec, fn(&KmsError) -> bool); @@ -2976,7 +2980,7 @@ mod tests { "created_at": "2024-01-01T00:00:00+00:00", "rotated_at": serde_json::Value::Null, "created_by": "legacy-test", - "encrypted_key_material": BASE64.encode([7u8; 32]), + "encrypted_key_material": BASE64.encode_to_string([7u8; 32]), "nonce": Vec::::new() }); diff --git a/crates/kms/src/backends/mod.rs b/crates/kms/src/backends/mod.rs index a6213724e..6fcb26a9b 100644 --- a/crates/kms/src/backends/mod.rs +++ b/crates/kms/src/backends/mod.rs @@ -722,8 +722,7 @@ impl Default for BackendCapabilities { mod tests { use super::*; use crate::config::KmsConfig; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; + use base64_simd::STANDARD as BASE64; /// Backend that implements only the trait-mandated operations and relies /// on the default `capabilities` implementation. @@ -958,7 +957,7 @@ mod tests { #[tokio::test] async fn static_backend_capabilities_golden() { - let config = KmsConfig::static_kms("static-key".to_string(), BASE64.encode([0u8; 32])); + let config = KmsConfig::static_kms("static-key".to_string(), BASE64.encode_to_string([0u8; 32])); let backend = static_kms::StaticKmsBackend::new(config) .await .expect("static backend should build"); diff --git a/crates/kms/src/backends/static_kms.rs b/crates/kms/src/backends/static_kms.rs index 2f3560497..2ad2d3e1a 100644 --- a/crates/kms/src/backends/static_kms.rs +++ b/crates/kms/src/backends/static_kms.rs @@ -434,8 +434,7 @@ mod tests { use crate::backends::KmsBackend as KmsBackendTrait; use crate::config::{BackendConfig, KmsBackend, StaticConfig}; use crate::encryption::is_data_key_envelope; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; + use base64_simd::STANDARD as BASE64; /// Generate a random 32-byte key and return (key_id, raw_key). fn random_static_key(key_id: &str) -> (String, [u8; 32]) { @@ -447,7 +446,7 @@ mod tests { fn static_config(key_id: &str, raw_key: &[u8; 32]) -> StaticConfig { StaticConfig { key_id: key_id.to_string(), - secret_key: BASE64.encode(raw_key), + secret_key: BASE64.encode_to_string(raw_key), } } diff --git a/crates/kms/src/backends/vault.rs b/crates/kms/src/backends/vault.rs index 0a787631a..2bfb97c6c 100644 --- a/crates/kms/src/backends/vault.rs +++ b/crates/kms/src/backends/vault.rs @@ -33,7 +33,7 @@ use crate::persisted_observability::{BoundedUnknownFieldName, UnknownFieldSummar use crate::policy::{self, AttemptError, OpClass, RetryPolicy}; use crate::types::*; use async_trait::async_trait; -use base64::{Engine as _, engine::general_purpose}; +use base64_simd::STANDARD as BASE64; use jiff::Zoned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -527,8 +527,8 @@ fn decode_stored_key_material(key_id: &str, encrypted_material: &str) -> Result< // Mirrors `decrypt_key_material`: stored material is currently base64 without an // additional encryption layer. - let key_material = general_purpose::STANDARD - .decode(encrypted_material) + let key_material = BASE64 + .decode_to_vec(encrypted_material) .map_err(|e| KmsError::material_corrupt(key_id, format!("stored key material is not valid base64: {e}")))?; // Key material must be exactly 32 bytes for AES-256. @@ -693,7 +693,7 @@ impl VaultKmsClient { /// confidentiality. Any identity with KV read access to the key path can recover the /// plaintext master key. async fn encrypt_key_material(&self, key_material: &[u8]) -> Result { - Ok(general_purpose::STANDARD.encode(key_material)) + Ok(base64_simd::STANDARD.encode_to_string(key_material)) } /// Read the immutable material record of one key version. @@ -2405,7 +2405,7 @@ mod tests { tags: HashMap::new(), deletion_date: None, rotated_at: None, - encrypted_key_material: general_purpose::STANDARD.encode([0x42u8; 32]), + encrypted_key_material: base64_simd::STANDARD.encode_to_string([0x42u8; 32]), baseline_version: None, wrap_budget_reserved: 0, } @@ -2869,21 +2869,21 @@ mod tests { )); // Truncated material: valid base64 of fewer than 32 bytes. - let truncated = general_purpose::STANDARD.encode([0x42u8; 16]); + let truncated = base64_simd::STANDARD.encode_to_string([0x42u8; 16]); assert!(matches!( decode_stored_key_material("poisoned", &truncated), Err(KmsError::MaterialCorrupt { key_id, .. }) if key_id == "poisoned" )); // Oversized material: valid base64 of more than 32 bytes. - let oversized = general_purpose::STANDARD.encode([0x42u8; 33]); + let oversized = base64_simd::STANDARD.encode_to_string([0x42u8; 33]); assert!(matches!( decode_stored_key_material("poisoned", &oversized), Err(KmsError::MaterialCorrupt { key_id, .. }) if key_id == "poisoned" )); // Well-formed material still decodes. - let valid = general_purpose::STANDARD.encode([0x42u8; 32]); + let valid = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); assert_eq!( decode_stored_key_material("healthy", &valid).expect("valid material must decode"), vec![0x42u8; 32] @@ -3046,7 +3046,7 @@ mod tests { description: None, metadata: HashMap::new(), tags: HashMap::new(), - encrypted_key_material: general_purpose::STANDARD.encode([0x42u8; 32]), + encrypted_key_material: base64_simd::STANDARD.encode_to_string([0x42u8; 32]), baseline_version: Some(1), deletion_date: None, rotated_at: None, @@ -3760,7 +3760,7 @@ mod tests { /// Base64 material distinct from `healthy_key_data`'s, standing in for the /// material a concurrent rotation committed. fn rotated_material() -> String { - general_purpose::STANDARD.encode([0x43u8; 32]) + base64_simd::STANDARD.encode_to_string([0x43u8; 32]) } /// The issue's lost-update scenario: node A disables a key while node B's @@ -4358,7 +4358,7 @@ mod tests { let material_v2 = [0x43u8; 32]; let record_v2 = VaultKeyVersionRecord { version: 2, - encrypted_key_material: general_purpose::STANDARD.encode(material_v2), + encrypted_key_material: base64_simd::STANDARD.encode_to_string(material_v2), created_at: Zoned::now(), }; // A well-formed envelope wrapped under version 2 — under a reverted @@ -4900,8 +4900,8 @@ mod tests { #[tokio::test] async fn wired_decrypt_of_pre_versioning_envelope_adds_no_request() { let key_data = healthy_key_data(); - let key_material = general_purpose::STANDARD - .decode(&key_data.encrypted_key_material) + let key_material = BASE64 + .decode_to_vec(&key_data.encrypted_key_material) .expect("decode fixture material"); let (encrypted_key, nonce) = AesDekCrypto::new() .encrypt(&key_material, b"dek-plaintext", &[]) diff --git a/crates/kms/src/backends/vault_transit.rs b/crates/kms/src/backends/vault_transit.rs index 5b49ed87b..b4177e950 100644 --- a/crates/kms/src/backends/vault_transit.rs +++ b/crates/kms/src/backends/vault_transit.rs @@ -31,7 +31,7 @@ use crate::persisted_observability::{BoundedUnknownFieldName, UnknownFieldSummar use crate::policy::{self, AttemptError, OpClass, RetryPolicy}; use crate::types::*; use async_trait::async_trait; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use jiff::Zoned; use moka::future::Cache; use serde::{Deserialize, Serialize}; @@ -479,7 +479,7 @@ impl VaultTransitKmsClient { .map(|(key, value)| (key.clone(), value.clone())) .collect(); let serialized = serde_json::to_vec(&ordered)?; - Ok(Some(BASE64.encode(serialized))) + Ok(Some(BASE64.encode_to_string(serialized))) } fn map_vault_error(key_id: &str, error: vaultrs::error::ClientError, operation: &str) -> KmsError { @@ -524,7 +524,7 @@ impl VaultTransitKmsClient { plaintext: &[u8], encryption_context: &HashMap, ) -> Result { - let plaintext_b64 = BASE64.encode(plaintext); + let plaintext_b64 = BASE64.encode_to_string(plaintext); let plaintext_b64 = plaintext_b64.as_str(); let aad = Self::canonicalize_context(encryption_context)?; let aad = aad.as_deref(); @@ -568,7 +568,7 @@ impl VaultTransitKmsClient { .await?; BASE64 - .decode(response.plaintext) + .decode_to_vec(response.plaintext) .map_err(|e| KmsError::cryptographic_error("base64_decode", e.to_string())) } @@ -3031,7 +3031,7 @@ mod tests { ScriptedResponse::ok(kv2_write_ack()), // decrypt of the pre-rotation envelope; Vault owns the transit // crypto, so the recovered material is the responder's to hand back. - ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode(RECOVERED_DEK) })), + ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode_to_string(RECOVERED_DEK) })), ]) .await; @@ -3243,7 +3243,7 @@ mod tests { // rewrap, context-bound route: latest-version read, then decrypt, // then re-encrypt under the newest version. ScriptedResponse::ok(transit_key_read_data_up_to("wired-key", 2)), - ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode(RECOVERED_DEK) })), + ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode_to_string(RECOVERED_DEK) })), ScriptedResponse::ok(serde_json::json!({ "ciphertext": "vault:v2:rewrapped" })), ]) .await; diff --git a/crates/kms/src/backup/drill.rs b/crates/kms/src/backup/drill.rs index 1ffc77d5b..2ba81fedf 100644 --- a/crates/kms/src/backup/drill.rs +++ b/crates/kms/src/backup/drill.rs @@ -945,7 +945,7 @@ async fn tree_digest(root: &Path) -> Result { lines.push(format!( "{relative}\u{1f}{}\u{1f}{modified}\u{1f}{}", metadata.len(), - hex::encode(Sha256::digest(&content)) + hex_simd::encode_to_string(Sha256::digest(&content), hex_simd::AsciiCase::Lower) )); } lines.sort(); @@ -1199,7 +1199,7 @@ mod tests { let text = String::from_utf8(encoded.clone()).expect("evidence is utf-8"); assert!(!text.contains(DRILL_MASTER_KEY), "the evidence must not carry the master key"); assert!( - !text.contains(&hex::encode([0x37u8; 32])), + !text.contains(&hex_simd::encode_to_string([0x37u8; 32], hex_simd::AsciiCase::Lower)), "the evidence must not carry backup KEK material" ); diff --git a/crates/kms/src/backup/local_export.rs b/crates/kms/src/backup/local_export.rs index e26f7cd35..ca0ab58da 100644 --- a/crates/kms/src/backup/local_export.rs +++ b/crates/kms/src/backup/local_export.rs @@ -568,7 +568,10 @@ pub(crate) fn compute_master_key_verifier(master_key: &str, salt: Option<&[u8]>, let mut hasher = Sha256::new(); hasher.update(&framing); hasher.update(derived.as_slice()); - Ok(format!("{prefix}{}", hex::encode(hasher.finalize()))) + Ok(format!( + "{prefix}{}", + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower) + )) } /// The bundle-level protection label is the weakest state observed across diff --git a/crates/kms/src/backup/local_restore.rs b/crates/kms/src/backup/local_restore.rs index 3fe2612f2..9fadb89d1 100644 --- a/crates/kms/src/backup/local_restore.rs +++ b/crates/kms/src/backup/local_restore.rs @@ -72,7 +72,7 @@ use aes_gcm::{ Aes256Gcm, Nonce, aead::{Aead, KeyInit}, }; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tokio::fs; @@ -640,7 +640,7 @@ fn decode_key_record( return Err(BackupError::corrupted(format!("bundled key record '{stem}' carries no key material")).into()); } let material = - Zeroizing::new(BASE64.decode(&probe.encrypted_key_material).map_err(|error| { + Zeroizing::new(BASE64.decode_to_vec(&probe.encrypted_key_material).map_err(|error| { BackupError::corrupted(format!("bundled key record '{stem}' material is not valid base64: {error}")) })?); if !allowed_modes.contains(&protection_mode(probe.at_rest_protection)) { diff --git a/crates/kms/src/backup/manifest.rs b/crates/kms/src/backup/manifest.rs index 28ea05f76..0b75fc924 100644 --- a/crates/kms/src/backup/manifest.rs +++ b/crates/kms/src/backup/manifest.rs @@ -69,7 +69,7 @@ impl ContentDigest { pub fn sha256_of(bytes: &[u8]) -> Self { Self { algorithm: DigestAlgorithm::Sha256, - hex: hex::encode(Sha256::digest(bytes)), + hex: hex_simd::encode_to_string(Sha256::digest(bytes), hex_simd::AsciiCase::Lower), } } diff --git a/crates/kms/src/config.rs b/crates/kms/src/config.rs index bb670609b..506e09a18 100644 --- a/crates/kms/src/config.rs +++ b/crates/kms/src/config.rs @@ -367,9 +367,8 @@ impl StaticConfig { /// Decode the base64-encoded secret key into raw bytes. /// Returns an error if the key is not valid base64 or is not exactly 32 bytes. pub fn decode_key(&self) -> Result<[u8; 32]> { - use base64::Engine as _; - let bytes = base64::engine::general_purpose::STANDARD - .decode(&self.secret_key) + let bytes = base64_simd::STANDARD + .decode_to_vec(&self.secret_key) .map_err(|e| KmsError::configuration_error(format!("Static KMS secret key is not valid base64: {e}")))?; if bytes.len() != 32 { return Err(KmsError::configuration_error(format!( @@ -1963,9 +1962,7 @@ mod tests { #[test] fn static_kms_config_serialization_does_not_expose_key_material() { - use base64::Engine as _; - - let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]); + let encoded_key = base64_simd::STANDARD.encode_to_string([0x5au8; 32]); let config = KmsConfig::static_kms("static-key".to_string(), encoded_key.clone()); let serialized = serde_json::to_string(&config).expect("static KMS config should serialize"); @@ -2569,14 +2566,12 @@ mod tests { #[test] fn test_from_env_reads_static_secret_file_and_sets_default_key() { - use base64::Engine as _; - let temp_dir = TempDir::new().expect("create temp dir for static KMS secret"); let secret_path = temp_dir.path().join("static-kms-secret"); // Named `*_key_b64` (not `*_secret`) so the logging-guardrails check does not // flag these fixture interpolations as secrets leaking into log strings. - let file_key_b64 = base64::engine::general_purpose::STANDARD.encode([7u8; 32]); - let env_key_b64 = base64::engine::general_purpose::STANDARD.encode([9u8; 32]); + let file_key_b64 = base64_simd::STANDARD.encode_to_string([7u8; 32]); + let env_key_b64 = base64_simd::STANDARD.encode_to_string([9u8; 32]); std::fs::write(&secret_path, format!("file-key:{file_key_b64}\n")).expect("write static KMS secret file"); with_vars( diff --git a/crates/kms/src/config_secret.rs b/crates/kms/src/config_secret.rs index 4903b384d..f08a7a988 100644 --- a/crates/kms/src/config_secret.rs +++ b/crates/kms/src/config_secret.rs @@ -46,8 +46,7 @@ use crate::error::{KmsError, Result}; use aes_gcm::aead::{Aead, Payload}; use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; use argon2::{Algorithm, Argon2, Params, Version}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64_simd::STANDARD as BASE64_STANDARD; use rand::RngExt; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -284,14 +283,16 @@ fn seal_value(label: &str, plaintext: &str, secret: &str) -> Result { payload.extend_from_slice(&salt); payload.extend_from_slice(&nonce); payload.extend_from_slice(&ciphertext); - Ok(format!("{SEALED_VALUE_PREFIX}{}", BASE64_STANDARD.encode(payload))) + Ok(format!("{SEALED_VALUE_PREFIX}{}", BASE64_STANDARD.encode_to_string(payload))) } fn open_value(label: &str, sealed: &str, secret: &str) -> Result { let encoded = sealed .strip_prefix(SEALED_VALUE_PREFIX) .expect("caller checks the sealed prefix"); - let payload = BASE64_STANDARD.decode(encoded).map_err(|_| sealed_value_unreadable(label))?; + let payload = BASE64_STANDARD + .decode_to_vec(encoded) + .map_err(|_| sealed_value_unreadable(label))?; if payload.len() <= LOCAL_KMS_MASTER_KEY_SALT_LEN + NONCE_LEN { return Err(sealed_value_unreadable(label)); } diff --git a/crates/kms/src/manager.rs b/crates/kms/src/manager.rs index f2cc936c5..07e4daaaa 100644 --- a/crates/kms/src/manager.rs +++ b/crates/kms/src/manager.rs @@ -675,7 +675,6 @@ mod tests { use crate::error::KmsError; use crate::types::{KeyMetadata, KeySpec, KeyState, KeyStatus, KeyUsage}; use async_trait::async_trait; - use base64::Engine as _; use jiff::Zoned; use std::collections::HashMap; use std::sync::Mutex; @@ -1110,8 +1109,13 @@ mod tests { .await .expect("enable should succeed"); - let base64 = base64::engine::general_purpose::STANDARD; - let encodings = |bytes: &[u8]| vec![hex::encode(bytes), base64.encode(bytes)]; + let base64 = base64_simd::STANDARD; + let encodings = |bytes: &[u8]| { + vec![ + hex_simd::encode_to_string(bytes, hex_simd::AsciiCase::Lower), + base64.encode_to_string(bytes), + ] + }; let mut forbidden = vec![grant_token.to_string()]; forbidden.extend(encodings(&data_key.plaintext_key)); forbidden.extend(encodings(&decrypted.plaintext)); diff --git a/crates/kms/src/probe.rs b/crates/kms/src/probe.rs index 3ec8f8b03..f65a71a44 100644 --- a/crates/kms/src/probe.rs +++ b/crates/kms/src/probe.rs @@ -551,7 +551,6 @@ mod tests { ListKeysRequest, ListKeysResponse, }; use async_trait::async_trait; - use base64::Engine as _; use metrics_util::MetricKind; use metrics_util::debugging::{DebugValue, DebuggingRecorder}; use std::future::Future; @@ -562,8 +561,7 @@ mod tests { } async fn static_backend() -> Arc { - let config = - KmsConfig::static_kms("static-key".to_string(), base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])); + let config = KmsConfig::static_kms("static-key".to_string(), base64_simd::STANDARD.encode_to_string([0x42u8; 32])); Arc::new(StaticKmsBackend::new(config).await.expect("static backend should build")) } diff --git a/crates/kms/src/service.rs b/crates/kms/src/service.rs index 7efe65b94..c4f0a6054 100644 --- a/crates/kms/src/service.rs +++ b/crates/kms/src/service.rs @@ -23,7 +23,6 @@ use crate::encryption::context_aad; use crate::error::{KmsError, Result}; use crate::manager::KmsManager; use crate::types::*; -use base64::Engine; use jiff::Zoned; use md5::{Digest as Md5Digest, Md5}; use rand::random; @@ -40,7 +39,7 @@ use zeroize::Zeroize; fn md5_hex(input: impl AsRef<[u8]>) -> String { let mut hasher = Md5::new(); hasher.update(input.as_ref()); - hex::encode(hasher.finalize()) + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower) } /// Data key for object encryption @@ -836,19 +835,16 @@ impl ObjectEncryptionService { // Internal headers for decryption headers.insert( INTERNAL_ENCRYPTION_IV_HEADER.to_string(), - base64::engine::general_purpose::STANDARD.encode(&metadata.iv), + base64_simd::STANDARD.encode_to_string(&metadata.iv), ); if let Some(ref tag) = metadata.tag { - headers.insert( - INTERNAL_ENCRYPTION_TAG_HEADER.to_string(), - base64::engine::general_purpose::STANDARD.encode(tag), - ); + headers.insert(INTERNAL_ENCRYPTION_TAG_HEADER.to_string(), base64_simd::STANDARD.encode_to_string(tag)); } headers.insert( INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), - base64::engine::general_purpose::STANDARD.encode(&metadata.encrypted_data_key), + base64_simd::STANDARD.encode_to_string(&metadata.encrypted_data_key), ); // Whatever the object was sealed under is what gets stored: for a @@ -906,14 +902,14 @@ impl ObjectEncryptionService { let iv = headers .get(INTERNAL_ENCRYPTION_IV_HEADER) .ok_or_else(|| KmsError::validation_error("Missing IV header"))?; - let iv = base64::engine::general_purpose::STANDARD - .decode(iv) + let iv = base64_simd::STANDARD + .decode_to_vec(iv) .map_err(|e| KmsError::validation_error(format!("Invalid IV: {e}")))?; let tag = if let Some(tag_str) = headers.get(INTERNAL_ENCRYPTION_TAG_HEADER) { Some( - base64::engine::general_purpose::STANDARD - .decode(tag_str) + base64_simd::STANDARD + .decode_to_vec(tag_str) .map_err(|e| KmsError::validation_error(format!("Invalid tag: {e}")))?, ) } else { @@ -921,8 +917,8 @@ impl ObjectEncryptionService { }; let encrypted_data_key = if let Some(key_str) = headers.get(INTERNAL_ENCRYPTION_KEY_HEADER) { - base64::engine::general_purpose::STANDARD - .decode(key_str) + base64_simd::STANDARD + .decode_to_vec(key_str) .map_err(|e| KmsError::validation_error(format!("Invalid encrypted key: {e}")))? } else { Vec::new() // Empty for SSE-C diff --git a/crates/kms/src/service_manager.rs b/crates/kms/src/service_manager.rs index ec67af821..e52186a4b 100644 --- a/crates/kms/src/service_manager.rs +++ b/crates/kms/src/service_manager.rs @@ -763,10 +763,10 @@ pub async fn get_global_encryption_service() -> Option KmsConfig { - KmsConfig::static_kms(key_id.to_string(), BASE64_STANDARD.encode([fill; 32])) + KmsConfig::static_kms(key_id.to_string(), BASE64_STANDARD.encode_to_string([fill; 32])) } /// End-to-end wiring check for the AWS backend: an admin configure request @@ -822,7 +822,7 @@ mod tests { #[tokio::test] async fn redacted_config_omits_static_key_material() { let manager = KmsServiceManager::new(); - let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]); + let encoded_key = base64_simd::STANDARD.encode_to_string([0x5au8; 32]); manager .configure(KmsConfig::static_kms("static-key".to_string(), encoded_key)) .await @@ -1020,7 +1020,6 @@ mod tests { #[tokio::test] async fn configure_cannot_replace_existing_local_backend() { - use base64::Engine as _; use tempfile::TempDir; let key_dir = TempDir::new().expect("create local KMS directory"); @@ -1029,7 +1028,7 @@ mod tests { let manager = KmsServiceManager::new(); manager.configure(local.clone()).await.expect("configure local KMS"); - let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]); + let encoded_key = base64_simd::STANDARD.encode_to_string([0x5au8; 32]); let error = manager .configure(KmsConfig::static_kms("static-key".to_string(), encoded_key)) .await diff --git a/crates/kms/tests/behavior_lifecycle.rs b/crates/kms/tests/behavior_lifecycle.rs index c0867b209..9a85ec940 100644 --- a/crates/kms/tests/behavior_lifecycle.rs +++ b/crates/kms/tests/behavior_lifecycle.rs @@ -451,6 +451,5 @@ async fn harness_restart_brings_the_service_back_over_the_same_state() { } fn base64_of(bytes: &[u8]) -> String { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.encode(bytes) + base64_simd::STANDARD.encode_to_string(bytes) } diff --git a/crates/kms/tests/behavior_objects.rs b/crates/kms/tests/behavior_objects.rs index 979cf0bb8..dbab6b311 100644 --- a/crates/kms/tests/behavior_objects.rs +++ b/crates/kms/tests/behavior_objects.rs @@ -667,7 +667,7 @@ async fn sse_c_round_trips_and_rejects_the_wrong_key() { async fn sse_c_validates_the_supplied_key_md5() { let (_kms, service) = service_with_key("sse-c-md5-unused").await; let customer_key = [0x33u8; 32]; - let correct_md5 = hex::encode(md5_of(&customer_key)); + let correct_md5 = hex_simd::encode_to_string(md5_of(&customer_key), hex_simd::AsciiCase::Lower); service .encrypt_object_with_customer_key(BUCKET, "md5-ok.bin", payload(64).as_slice(), &customer_key, Some(&correct_md5)) diff --git a/crates/kms/tests/common/mod.rs b/crates/kms/tests/common/mod.rs index e795e5f28..2c0cb9a92 100644 --- a/crates/kms/tests/common/mod.rs +++ b/crates/kms/tests/common/mod.rs @@ -34,8 +34,7 @@ use std::future::Future; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; +use base64_simd::STANDARD as BASE64; use rustfs_kms::backends::BackendCapabilities; use rustfs_kms::{ CreateKeyRequest, DeleteKeyRequest, KeyUsage, KmsConfig, KmsError, KmsManager, KmsServiceManager, KmsServiceStatus, @@ -51,7 +50,7 @@ pub const STATIC_KEY_ID: &str = "behavior-static-key"; /// Fixed rather than random so a failure is reproducible; it is test-only /// material and never leaves this crate's test binaries. pub fn static_secret_key() -> String { - BASE64.encode([0x5au8; 32]) + BASE64.encode_to_string([0x5au8; 32]) } /// Which backend a harness instance is running. diff --git a/crates/protocols/Cargo.toml b/crates/protocols/Cargo.toml index a121ebef9..7f8170d9c 100644 --- a/crates/protocols/Cargo.toml +++ b/crates/protocols/Cargo.toml @@ -99,14 +99,14 @@ swift = [ "dep:md-5", "dep:hmac", "dep:sha1", - "dep:hex", + "dep:hex-simd", "dep:ipnetwork", "dep:rustfs-trusted-proxies", "dep:astral-tokio-tar", - "dep:base64", + "dep:base64-simd", "dep:async-compression", ] -webdav = ["dep:dav-server", "dep:hyper", "dep:hyper-util", "dep:http", "dep:http-body-util", "dep:tokio-rustls", "dep:base64", "dep:rustls", "dep:percent-encoding", "dep:rustfs-tls-runtime", "dep:subtle"] +webdav = ["dep:dav-server", "dep:hyper", "dep:hyper-util", "dep:http", "dep:http-body-util", "dep:tokio-rustls", "dep:base64-simd", "dep:rustls", "dep:percent-encoding", "dep:rustfs-tls-runtime", "dep:subtle"] sftp = ["dep:russh", "dep:russh-sftp", "dep:uuid", "dep:subtle", "dep:tokio-util", "dep:socket2"] [dependencies] @@ -163,11 +163,11 @@ urlencoding = { workspace = true, optional = true } md-5 = { workspace = true, optional = true } hmac = { workspace = true, optional = true } sha1 = { workspace = true, optional = true } -hex = { workspace = true, optional = true } +hex-simd = { workspace = true, optional = true } ipnetwork = { workspace = true, optional = true } rustfs-trusted-proxies = { workspace = true, optional = true } astral-tokio-tar = { workspace = true, optional = true } -base64 = { workspace = true, optional = true } +base64-simd = { workspace = true, optional = true } async-compression = { workspace = true, optional = true, features = ["tokio", "gzip", "bzip2"] } # WebDAV specific dependencies (optional) diff --git a/crates/protocols/src/swift/account.rs b/crates/protocols/src/swift/account.rs index 3cdab2114..aeae24b6a 100644 --- a/crates/protocols/src/swift/account.rs +++ b/crates/protocols/src/swift/account.rs @@ -97,7 +97,7 @@ fn get_account_metadata_bucket_name(account: &str) -> String { let mut hasher = Sha256::new(); hasher.update(account.as_bytes()); let hash_bytes = hasher.finalize(); - let hash = hex::encode(hash_bytes); + let hash = hex_simd::encode_to_string(hash_bytes, hex_simd::AsciiCase::Lower); format!("swift-account-{}", &hash[0..16]) } diff --git a/crates/protocols/src/swift/formpost.rs b/crates/protocols/src/swift/formpost.rs index 052dad787..a63b5f67f 100644 --- a/crates/protocols/src/swift/formpost.rs +++ b/crates/protocols/src/swift/formpost.rs @@ -182,7 +182,7 @@ pub fn generate_signature( mac.update(message.as_bytes()); let result = mac.finalize(); - let signature = hex::encode(result.into_bytes()); + let signature = hex_simd::encode_to_string(result.into_bytes(), hex_simd::AsciiCase::Lower); Ok(signature) } @@ -213,10 +213,10 @@ pub fn validate_formpost(path: &str, request: &FormPostRequest, key: &str) -> Sw // the sibling TempURL/SFTP checks. Decode the hex first so the comparison runs // over the raw HMAC bytes and does not leak via string length; a non-hex // provided signature can never match and is rejected the same way. - let expected_bytes = - hex::decode(&expected_sig).map_err(|e| SwiftError::InternalServerError(format!("Signature encoding error: {}", e)))?; + let expected_bytes = hex_simd::decode_to_vec(&expected_sig) + .map_err(|e| SwiftError::InternalServerError(format!("Signature encoding error: {}", e)))?; - let signatures_match = match hex::decode(request.signature.trim()) { + let signatures_match = match hex_simd::decode_to_vec(request.signature.trim()) { Ok(provided_bytes) => super::tempurl::constant_time_compare(&provided_bytes, &expected_bytes), Err(_) => false, }; diff --git a/crates/protocols/src/swift/slo.rs b/crates/protocols/src/swift/slo.rs index c53cd1f4d..8e29e6ecd 100644 --- a/crates/protocols/src/swift/slo.rs +++ b/crates/protocols/src/swift/slo.rs @@ -84,7 +84,11 @@ impl SLOManifest { let mut hasher = Md5::new(); hasher.update(etag_concat.as_bytes()); - format!("\"{}-{}\"", hex::encode(hasher.finalize()), self.segments.len()) + format!( + "\"{}-{}\"", + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower), + self.segments.len() + ) } /// Validate manifest against actual segments diff --git a/crates/protocols/src/swift/sync.rs b/crates/protocols/src/swift/sync.rs index 60a8749ec..2d9b9e1d6 100644 --- a/crates/protocols/src/swift/sync.rs +++ b/crates/protocols/src/swift/sync.rs @@ -300,7 +300,7 @@ pub fn generate_sync_signature(path: &str, key: &str) -> SwiftResult { mac.update(path.as_bytes()); let result = mac.finalize(); - Ok(hex::encode(result.into_bytes())) + Ok(hex_simd::encode_to_string(result.into_bytes(), hex_simd::AsciiCase::Lower)) } /// Verify sync signature diff --git a/crates/protocols/src/swift/tempurl.rs b/crates/protocols/src/swift/tempurl.rs index 2c0e7fbad..87b6fcd39 100644 --- a/crates/protocols/src/swift/tempurl.rs +++ b/crates/protocols/src/swift/tempurl.rs @@ -125,7 +125,7 @@ impl TempURL { // Hex-encode result let result = mac.finalize(); - let signature = hex::encode(result.into_bytes()); + let signature = hex_simd::encode_to_string(result.into_bytes(), hex_simd::AsciiCase::Lower); Ok(signature) } diff --git a/crates/protocols/src/webdav/server.rs b/crates/protocols/src/webdav/server.rs index fd1f56c09..8162e8601 100644 --- a/crates/protocols/src/webdav/server.rs +++ b/crates/protocols/src/webdav/server.rs @@ -675,8 +675,7 @@ fn fixed_body(message: impl Into) -> WebDavBody { /// Decode base64 string fn base64_decode(encoded: &str) -> Result, ()> { - use base64::Engine; - base64::engine::general_purpose::STANDARD.decode(encoded).map_err(|_| ()) + base64_simd::STANDARD.decode_to_vec(encoded).map_err(|_| ()) } #[cfg(test)] diff --git a/crates/protocols/tests/swift_metadata_persistence.rs b/crates/protocols/tests/swift_metadata_persistence.rs index 6b0b2df8d..ad636cb09 100644 --- a/crates/protocols/tests/swift_metadata_persistence.rs +++ b/crates/protocols/tests/swift_metadata_persistence.rs @@ -56,7 +56,7 @@ fn keystone_credentials(project_id: &str) -> Credentials { fn account_metadata_bucket_name(account: &str) -> String { let mut hasher = Sha256::new(); hasher.update(account.as_bytes()); - let hash = hex::encode(hasher.finalize()); + let hash = hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower); format!("swift-account-{}", &hash[0..16]) } diff --git a/crates/rio-v2/Cargo.toml b/crates/rio-v2/Cargo.toml index b941ed7fc..10e36d0ff 100644 --- a/crates/rio-v2/Cargo.toml +++ b/crates/rio-v2/Cargo.toml @@ -57,7 +57,7 @@ hotpath.workspace = true aes-gcm = { workspace = true, features = ["rand_core"] } bytes = { workspace = true, features = ["serde"] } chacha20poly1305.workspace = true -hex.workspace = true +hex-simd.workspace = true hmac.workspace = true minlz.workspace = true pin-project-lite.workspace = true diff --git a/crates/rio-v2/src/encrypt_reader.rs b/crates/rio-v2/src/encrypt_reader.rs index 56f119724..9dab95c2a 100644 --- a/crates/rio-v2/src/encrypt_reader.rs +++ b/crates/rio-v2/src/encrypt_reader.rs @@ -731,12 +731,15 @@ fn random_stream_nonce() -> [u8; 12] { #[cfg(test)] mod tests { use super::*; - use hex::encode as hex_encode; use std::io::Cursor; use tokio::io::AsyncReadExt; const DARE_PACKAGE_SIZE: usize = DARE_HEADER_SIZE + DARE_PAYLOAD_SIZE + DARE_TAG_SIZE; + fn hex_encode(data: impl AsRef<[u8]>) -> String { + hex_simd::encode_to_string(data, hex_simd::AsciiCase::Lower) + } + #[tokio::test] async fn decrypt_reader_can_start_from_non_zero_sequence_number() { let plaintext = vec![0xAB; DARE_PAYLOAD_SIZE * 2 + 19]; diff --git a/crates/rio/Cargo.toml b/crates/rio/Cargo.toml index 87ecc79cb..46723ff2b 100644 --- a/crates/rio/Cargo.toml +++ b/crates/rio/Cargo.toml @@ -81,7 +81,7 @@ serde_json = { workspace = true, features = ["raw_value"] } md-5 = { workspace = true } tracing.workspace = true thiserror.workspace = true -base64.workspace = true +base64-simd.workspace = true sha1.workspace = true sha2.workspace = true xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] } diff --git a/crates/rio/src/checksum.rs b/crates/rio/src/checksum.rs index cd50db57b..1f211476d 100644 --- a/crates/rio/src/checksum.rs +++ b/crates/rio/src/checksum.rs @@ -13,7 +13,7 @@ // limitations under the License. use crate::errors::ChecksumMismatch; -use base64::{Engine as _, engine::general_purpose}; +use base64_simd::STANDARD as BASE64_STANDARD; use bytes::Bytes; use http::HeaderMap; use sha1::Sha1; @@ -330,7 +330,7 @@ impl Checksum { let mut hasher = checksum_type.hasher()?; hasher.write_all(data).ok()?; let raw = hasher.finalize(); - let encoded = general_purpose::STANDARD.encode(&raw); + let encoded = BASE64_STANDARD.encode_to_string(&raw); let checksum = Checksum { checksum_type, @@ -369,7 +369,7 @@ impl Checksum { value_string = value.to_string(); } // let raw = base64_simd::URL_SAFE_NO_PAD.decode_to_vec(&value_string).ok()?; - let raw = general_purpose::STANDARD.decode(&value_string).ok()?; + let raw = BASE64_STANDARD.decode_to_vec(&value_string).ok()?; let checksum = Checksum { checksum_type, @@ -413,14 +413,14 @@ impl Checksum { if self.want_parts > 0 && self.want_parts != parts { return Err(ChecksumMismatch { want: format!("{}-{}", self.encoded, self.want_parts), - got: format!("{}-{}", general_purpose::STANDARD.encode(&sum), parts), + got: format!("{}-{}", base64_simd::STANDARD.encode_to_string(&sum), parts), }); } if sum != self.raw { return Err(ChecksumMismatch { want: self.encoded.clone(), - got: general_purpose::STANDARD.encode(&sum), + got: base64_simd::STANDARD.encode_to_string(&sum), }); } @@ -569,7 +569,7 @@ impl Checksum { } } - self.encoded = general_purpose::STANDARD.encode(&self.raw); + self.encoded = base64_simd::STANDARD.encode_to_string(&self.raw); Ok(()) } } @@ -1160,7 +1160,7 @@ pub fn read_checksums(mut buf: &[u8], part: i32) -> (HashMap, bo let checksum_bytes = &buf[..length]; buf = &buf[length..]; - let mut checksum_str = general_purpose::STANDARD.encode(checksum_bytes); + let mut checksum_str = base64_simd::STANDARD.encode_to_string(checksum_bytes); if checksum_type.is(ChecksumType::MULTIPART) { is_multipart = true; @@ -1190,7 +1190,7 @@ pub fn read_checksums(mut buf: &[u8], part: i32) -> (HashMap, bo if part > 0 && (part as u64) <= parts_count { let offset = ((part - 1) as usize) * length; let part_checksum = &buf[offset..offset + length]; - checksum_str = general_purpose::STANDARD.encode(part_checksum); + checksum_str = base64_simd::STANDARD.encode_to_string(part_checksum); } buf = &buf[want_len..]; } @@ -1249,7 +1249,7 @@ pub fn read_part_checksums(mut buf: &[u8]) -> Vec> { let checksum_bytes = &buf[..length]; buf = &buf[length..]; - let checksum_str = general_purpose::STANDARD.encode(checksum_bytes); + let checksum_str = base64_simd::STANDARD.encode_to_string(checksum_bytes); part_checksum.insert(checksum_type.to_string(), checksum_str); } @@ -1553,7 +1553,6 @@ mod tests { // asserted alongside the raw hex so both the digest and its encoding are pinned. #[test] fn xxhash_sha512_regression_lock_non_empty() { - use base64::{Engine as _, engine::general_purpose::STANDARD}; let data = b"The quick brown fox jumps over the lazy dog"; // XXH3-64(fox) = 0xce7d19a5418fb365 is the official upstream vector. @@ -1565,7 +1564,11 @@ mod tests { let got_hex: String = c.raw.iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(got_hex, want_hex, "{t:?} raw hex drifted"); // encoded field must be the standard-base64 of raw (S3 wire form) - assert_eq!(c.encoded, STANDARD.encode(&c.raw), "{t:?} encoded field != base64(raw)"); + assert_eq!( + c.encoded, + base64_simd::STANDARD.encode_to_string(&c.raw), + "{t:?} encoded field != base64(raw)" + ); } } diff --git a/crates/rio/src/hash_reader.rs b/crates/rio/src/hash_reader.rs index f33cb7661..49efc1d6b 100644 --- a/crates/rio/src/hash_reader.rs +++ b/crates/rio/src/hash_reader.rs @@ -91,8 +91,7 @@ use crate::Sha256Hasher; use crate::compress_index::{Index, TryGetIndex}; use crate::get_content_checksum; use crate::{DynReader, EtagReader, EtagResolvable, HardLimitReader, HashReaderDetector, WarpReader, boxed_reader, wrap_reader}; -use base64::Engine; -use base64::engine::general_purpose; + use http::HeaderMap; use pin_project_lite::pin_project; use s3s::TrailingHeaders; @@ -582,8 +581,8 @@ impl AsyncRead for HashReader { }) { expected_content_hash.encoded = checksum_str; - expected_content_hash.raw = general_purpose::STANDARD - .decode(&expected_content_hash.encoded) + expected_content_hash.raw = base64_simd::STANDARD + .decode_to_vec(&expected_content_hash.encoded) .map_err(|_| std::io::Error::other("Invalid base64 checksum"))?; if expected_content_hash.raw.is_empty() { @@ -598,7 +597,7 @@ impl AsyncRead for HashReader { && !expected_content_hash.checksum_type.trailing() { expected_content_hash.raw = content_hash; - expected_content_hash.encoded = general_purpose::STANDARD.encode(&expected_content_hash.raw); + expected_content_hash.encoded = base64_simd::STANDARD.encode_to_string(&expected_content_hash.raw); } else if content_hash != expected_content_hash.raw { let expected_hex = hex_simd::encode_to_string(&expected_content_hash.raw, hex_simd::AsciiCase::Lower); let actual_hex = hex_simd::encode_to_string(content_hash, hex_simd::AsciiCase::Lower); diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index 6fbedae51..394831cd9 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -310,7 +310,6 @@ astral-tokio-tar = { workspace = true } atoi = { workspace = true } atomic_enum = { workspace = true } async_zip = { workspace = true, default-features = false, features = ["tokio", "deflate"] } -base64 = { workspace = true } zeroize = { workspace = true } hmac = { workspace = true } sha2 = { workspace = true } diff --git a/rustfs/src/admin/handlers/kms_audit.rs b/rustfs/src/admin/handlers/kms_audit.rs index f819a4a90..883131fdf 100644 --- a/rustfs/src/admin/handlers/kms_audit.rs +++ b/rustfs/src/admin/handlers/kms_audit.rs @@ -439,7 +439,6 @@ fn dispatch(entry: AuditEntry) { #[cfg(test)] mod tests { use super::*; - use base64::Engine; use rustfs_kms::backends::local::LocalKmsBackend; use rustfs_kms::config::KmsConfig; use rustfs_kms::types::{CreateKeyRequest, DeleteKeyRequest, DescribeKeyRequest, GenerateDataKeyRequest, KeySpec}; @@ -689,8 +688,8 @@ mod tests { .expect("data key should be generated"); // What the endpoint hands back, and therefore what must not reappear. - let plaintext_b64 = base64::prelude::BASE64_STANDARD.encode(&response.plaintext_key); - let ciphertext_b64 = base64::prelude::BASE64_STANDARD.encode(&response.ciphertext_blob); + let plaintext_b64 = base64_simd::STANDARD.encode_to_string(&response.plaintext_key); + let ciphertext_b64 = base64_simd::STANDARD.encode_to_string(&response.ciphertext_blob); assert!(!response.plaintext_key.is_empty(), "the test must drive real key material"); let redacted = rustfs_kms::redact_encryption_context(&std::collections::HashMap::from([ diff --git a/rustfs/src/admin/handlers/kms_backup.rs b/rustfs/src/admin/handlers/kms_backup.rs index 02682e7da..f94651874 100644 --- a/rustfs/src/admin/handlers/kms_backup.rs +++ b/rustfs/src/admin/handlers/kms_backup.rs @@ -46,7 +46,7 @@ use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{current_deployment_id, current_kms_runtime_service_manager}; use crate::auth::{check_key_valid, get_session_token}; use crate::server::{ADMIN_PREFIX, RemoteAddr}; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use hyper::{HeaderMap, Method, StatusCode}; use matchit::Params; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; @@ -429,7 +429,7 @@ impl BackupEnvironment { let decoded = Zeroizing::new( BASE64 - .decode(raw_kek.trim()) + .decode_to_vec(raw_kek.trim()) .map_err(|_| (StatusCode::PRECONDITION_FAILED, format!("{ENV_KMS_BACKUP_KEK} must be base64-encoded")))?, ); if decoded.len() != 32 { @@ -516,7 +516,9 @@ fn reuses_business_secret(kek_material: &[u8], raw_kek: &str, config: &KmsConfig if raw_kek == secret.as_str() || secret.as_bytes() == kek_material { return true; } - BASE64.decode(secret.as_str()).is_ok_and(|decoded| decoded == kek_material) + BASE64 + .decode_to_vec(secret.as_str()) + .is_ok_and(|decoded| decoded == kek_material) }) } @@ -1185,7 +1187,7 @@ mod tests { const DEPLOYMENT: &str = "deployment-under-test"; fn test_kek_bytes() -> Vec { - BASE64.decode(TEST_KEK_B64).expect("test KEK must decode") + BASE64.decode_to_vec(TEST_KEK_B64).expect("test KEK must decode") } fn local_config(key_dir: PathBuf) -> KmsConfig { @@ -1310,7 +1312,7 @@ mod tests { .expect_err("an empty KEK must be refused"); assert_eq!(error.0, StatusCode::PRECONDITION_FAILED); - let short = BASE64.encode([0x11; 16]); + let short = BASE64.encode_to_string([0x11; 16]); let error = BackupEnvironment::build(PathBuf::from("/tmp/root"), &short, "kek".to_string(), 1, &config) .expect_err("a KEK that is not 32 bytes must be refused"); assert_eq!(error.0, StatusCode::PRECONDITION_FAILED); @@ -1353,7 +1355,7 @@ mod tests { ); // An unrelated KEK is accepted. - let independent = BASE64.encode([0x5a; 32]); + let independent = BASE64.encode_to_string([0x5a; 32]); assert!(BackupEnvironment::build(PathBuf::from("/tmp/root"), &independent, "kek".to_string(), 1, &config).is_ok()); } @@ -1372,7 +1374,7 @@ mod tests { let master_key = "local-master-key-super-secret"; let vault_token = "hvs.vault-token-super-secret"; let approle_secret = "approle-secret-id-super-secret"; - let static_key = BASE64.encode([0x7c; 32]); + let static_key = BASE64.encode_to_string([0x7c; 32]); let local = local_config_with_master_key(PathBuf::from("/var/lib/rustfs/kms"), master_key); let kv2 = vault_kv2_config(VaultAuthMethod::Token { diff --git a/rustfs/src/admin/handlers/kms_dynamic.rs b/rustfs/src/admin/handlers/kms_dynamic.rs index 65431c977..5bee555f4 100644 --- a/rustfs/src/admin/handlers/kms_dynamic.rs +++ b/rustfs/src/admin/handlers/kms_dynamic.rs @@ -1422,12 +1422,8 @@ mod tests { #[test] fn static_kms_config_is_not_persisted_with_cluster_configuration() { - use base64::Engine as _; - - let config = rustfs_kms::KmsConfig::static_kms( - "static-key".to_string(), - base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]), - ); + let config = + rustfs_kms::KmsConfig::static_kms("static-key".to_string(), base64_simd::STANDARD.encode_to_string([0x5au8; 32])); assert!(ensure_kms_config_persistable(&config).is_err()); } diff --git a/rustfs/src/admin/handlers/kms_key_metadata.rs b/rustfs/src/admin/handlers/kms_key_metadata.rs index 679e501fd..8e4bd4a3a 100644 --- a/rustfs/src/admin/handlers/kms_key_metadata.rs +++ b/rustfs/src/admin/handlers/kms_key_metadata.rs @@ -408,7 +408,6 @@ impl Operation for UntagKmsKeyHandler { mod tests { use super::*; use crate::admin::handlers::kms_keys::stable_json_value; - use base64::Engine as _; use rustfs_kms::KmsManager; use rustfs_kms::backends::local::LocalKmsBackend; use rustfs_kms::backends::static_kms::StaticKmsBackend; @@ -431,8 +430,7 @@ mod tests { /// is the backend that must answer every metadata update with a capability /// gap rather than a failure of the request. async fn static_service() -> ObjectEncryptionService { - let config = - KmsConfig::static_kms("static-key".to_string(), base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])); + let config = KmsConfig::static_kms("static-key".to_string(), base64_simd::STANDARD.encode_to_string([0x42u8; 32])); let backend = Arc::new( StaticKmsBackend::new(config.clone()) .await diff --git a/rustfs/src/admin/handlers/kms_keys.rs b/rustfs/src/admin/handlers/kms_keys.rs index de99ae07c..6e0bbdf08 100644 --- a/rustfs/src/admin/handlers/kms_keys.rs +++ b/rustfs/src/admin/handlers/kms_keys.rs @@ -21,7 +21,6 @@ use crate::admin::runtime_sources::{current_kms_runtime_service_manager, current use crate::auth::{check_key_valid, get_session_token}; use crate::kms_deletion_gate::current_key_impact; use crate::server::{ADMIN_PREFIX, RemoteAddr}; -use base64::Engine; use hyper::{HeaderMap, Method, StatusCode}; use matchit::Params; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; @@ -1535,8 +1534,8 @@ impl Operation for GenerateDataKeyHandler { Ok(response) => { let api_response = GenerateDataKeyApiResponse { key_id: response.key_id, - plaintext_key: base64::prelude::BASE64_STANDARD.encode(&response.plaintext_key), - ciphertext_blob: base64::prelude::BASE64_STANDARD.encode(&response.ciphertext_blob), + plaintext_key: base64_simd::STANDARD.encode_to_string(&response.plaintext_key), + ciphertext_blob: base64_simd::STANDARD.encode_to_string(&response.ciphertext_blob), }; let data = serde_json::to_vec(&api_response) diff --git a/rustfs/src/admin/handlers/site_replication.rs b/rustfs/src/admin/handlers/site_replication.rs index 3b7637dd5..2dfa83a18 100644 --- a/rustfs/src/admin/handlers/site_replication.rs +++ b/rustfs/src/admin/handlers/site_replication.rs @@ -56,9 +56,8 @@ use crate::storage::storage_api::{ delete_config_no_lock, lock_bucket_targets_metadata, read_config_no_lock, save_config_no_lock, with_config_object_read_lock, with_config_object_write_lock, }; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64_simd::STANDARD as BASE64_STANDARD; +use base64_simd::URL_SAFE_NO_PAD; use futures::StreamExt; use hmac::{Hmac, Mac}; use http::header::{CONTENT_TYPE, HOST}; @@ -1645,7 +1644,7 @@ fn hash_client_secret(secret: Option<&str>) -> String { let mut hasher = Sha256::new(); hasher.update(secret.as_bytes()); - URL_SAFE_NO_PAD.encode(hasher.finalize()) + URL_SAFE_NO_PAD.encode_to_string(hasher.finalize()) } fn config_enabled(value: Option) -> bool { @@ -3775,7 +3774,7 @@ impl SiteReplicationRepairTask<'_> { digest.update(self.path().as_bytes()); digest.update([0]); digest.update(payload); - Ok(URL_SAFE_NO_PAD.encode(digest.finalize())) + Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize())) } async fn send(&self, transport: &PeerTransport, access_key: &str, secret_key: &str) -> S3Result> { @@ -3862,7 +3861,7 @@ fn site_replication_repair_plan_token(state: &SiteReplicationState, plan: &SiteR for (_, task) in site_replication_repair_tasks(plan) { digest.update(task.id()?.as_bytes()); } - Ok(URL_SAFE_NO_PAD.encode(digest.finalize())) + Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize())) } fn site_replication_repair_preflight_token( @@ -3892,7 +3891,7 @@ fn site_replication_repair_preflight_token( digest.update(event.path.as_bytes()); digest.update(&[0]); } - Ok(URL_SAFE_NO_PAD.encode(digest.finalize().into_bytes())) + Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize().into_bytes())) } fn site_replication_repair_task_checkpoint_id( @@ -3906,7 +3905,7 @@ fn site_replication_repair_task_checkpoint_id( digest.update(peer_deployment_id.as_bytes()); digest.update(&[0]); digest.update(task.id()?.as_bytes()); - Ok(URL_SAFE_NO_PAD.encode(digest.finalize().into_bytes())) + Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize().into_bytes())) } fn site_replication_repair_sites( @@ -4488,11 +4487,11 @@ fn raw_config_to_string(raw: &[u8]) -> Option { } fn raw_config_to_base64(raw: &[u8]) -> Option { - (!raw.is_empty()).then(|| BASE64_STANDARD.encode(raw)) + (!raw.is_empty()).then(|| BASE64_STANDARD.encode_to_string(raw)) } fn encode_bucket_meta_wire_value(value: Option) -> Option { - value.map(|raw| BASE64_STANDARD.encode(raw.as_bytes())) + value.map(|raw| BASE64_STANDARD.encode_to_string(raw.as_bytes())) } fn encode_bucket_meta_wire_item(mut item: SRBucketMeta) -> SRBucketMeta { @@ -4508,7 +4507,7 @@ fn encode_bucket_meta_wire_item(mut item: SRBucketMeta) -> SRBucketMeta { fn decode_bucket_meta_wire_value(raw: &str) -> Vec { BASE64_STANDARD - .decode(raw.as_bytes()) + .decode_to_vec(raw.as_bytes()) .ok() .filter(|decoded| std::str::from_utf8(decoded).is_ok()) .unwrap_or_else(|| raw.as_bytes().to_vec()) @@ -7717,7 +7716,7 @@ fn site_resync_page(status: &SRResyncOpStatus, limit: usize, offset: usize) -> S }; let encoded = serde_json::to_vec(&token) .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("encode resync cursor failed: {err}")))?; - URL_SAFE_NO_PAD.encode(encoded) + URL_SAFE_NO_PAD.encode_to_string(encoded) } else { String::new() }; @@ -7736,7 +7735,7 @@ fn parse_site_resync_page(query: &HashMap, status: &SRResyncOpSt } let offset = if let Some(value) = query.get("continuationToken") { let decoded = URL_SAFE_NO_PAD - .decode(value) + .decode_to_vec(value) .map_err(|_| s3_error!(InvalidRequest, "invalid resync continuation token"))?; let token: SiteResyncContinuationToken = serde_json::from_slice(&decoded).map_err(|_| s3_error!(InvalidRequest, "invalid resync continuation token"))?; @@ -14098,7 +14097,7 @@ mod tests { let bucket = SRBucketInfo { bucket: "photos".to_string(), created_at: Some(OffsetDateTime::UNIX_EPOCH), - object_lock_config: Some(BASE64_STANDARD.encode("")), + object_lock_config: Some(BASE64_STANDARD.encode_to_string("")), ..Default::default() }; let bootstrap = bootstrap_bucket_make_op_path(&bucket); @@ -14625,10 +14624,10 @@ mod tests { SRBucketInfo { bucket: "photos".to_string(), policy: Some(serde_json::json!({"Statement": []})), - versioning: Some(BASE64_STANDARD.encode("")), - quota_config: Some(BASE64_STANDARD.encode(r#"{"quota":1024}"#)), - expiry_lc_config: Some(BASE64_STANDARD.encode("")), - object_lock_config: Some(BASE64_STANDARD.encode("")), + versioning: Some(BASE64_STANDARD.encode_to_string("")), + quota_config: Some(BASE64_STANDARD.encode_to_string(r#"{"quota":1024}"#)), + expiry_lc_config: Some(BASE64_STANDARD.encode_to_string("")), + object_lock_config: Some(BASE64_STANDARD.encode_to_string("")), created_at: Some(OffsetDateTime::UNIX_EPOCH), api_version: Some(SITE_REPL_API_VERSION.to_string()), ..Default::default() @@ -14667,7 +14666,7 @@ mod tests { "photos".to_string(), SRBucketInfo { bucket: "photos".to_string(), - expiry_lc_config: Some(BASE64_STANDARD.encode("")), + expiry_lc_config: Some(BASE64_STANDARD.encode_to_string("")), api_version: Some(SITE_REPL_API_VERSION.to_string()), ..Default::default() }, @@ -17637,7 +17636,7 @@ mod tests { fn test_metainfo_bucket_config_values_are_base64_encoded() { let raw = br#""#; - assert_eq!(raw_config_to_base64(raw), Some(BASE64_STANDARD.encode(raw))); + assert_eq!(raw_config_to_base64(raw), Some(BASE64_STANDARD.encode_to_string(raw))); assert_ne!(raw_config_to_base64(raw), raw_config_to_string(raw)); assert_eq!(raw_config_to_base64(&[]), None); } @@ -18182,8 +18181,8 @@ mod tests { }; let dep_a_xml = site_config_xml("dep-b"); let dep_b_xml = site_config_xml("dep-a"); - let dep_a_b64 = BASE64_STANDARD.encode(dep_a_xml.as_bytes()); - let dep_b_b64 = BASE64_STANDARD.encode(dep_b_xml.as_bytes()); + let dep_a_b64 = BASE64_STANDARD.encode_to_string(dep_a_xml.as_bytes()); + let dep_b_b64 = BASE64_STANDARD.encode_to_string(dep_b_xml.as_bytes()); // Both sites present the complete config in base64 wire form → NOT a mismatch. assert_eq!( diff --git a/rustfs/src/connect/client.rs b/rustfs/src/connect/client.rs index 4855adb46..9d283460f 100644 --- a/rustfs/src/connect/client.rs +++ b/rustfs/src/connect/client.rs @@ -14,7 +14,6 @@ use std::time::Duration; -use base64::Engine as _; use chrono::{DateTime, Utc}; use reqwest::{Client, StatusCode, Url, header}; use rustls::RootCertStore; @@ -228,8 +227,8 @@ impl ConnectClient { pending: &PendingRegistration, identity: &super::identity::DeviceIdentity, ) -> Result { - let csr_der = base64::engine::general_purpose::STANDARD - .decode(&pending.certificate_request) + let csr_der = base64_simd::STANDARD + .decode_to_vec(&pending.certificate_request) .map_err(|_| ClientError::PendingRegistration)?; let transcript = RegistrationTranscript::build( &token.registration_token_uid, diff --git a/rustfs/src/connect/identity.rs b/rustfs/src/connect/identity.rs index e3028c770..8ce7bb890 100644 --- a/rustfs/src/connect/identity.rs +++ b/rustfs/src/connect/identity.rs @@ -20,8 +20,7 @@ //! module produces, so any divergence is a protocol break rather than a //! local behaviour change. -use base64::Engine as _; -use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD as BASE64_URL_NO_PAD}; +use base64_simd::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD as BASE64_URL_NO_PAD}; use p256::ecdsa::signature::{Signer as _, Verifier as _}; use p256::ecdsa::{Signature, SigningKey}; use p256::elliptic_curve::Generate as _; @@ -112,7 +111,7 @@ impl RegistrationTranscript { } let expiry = expires_unix.to_string(); - let csr_digest = BASE64_URL_NO_PAD.encode(Sha256::digest(certificate_request)); + let csr_digest = BASE64_URL_NO_PAD.encode_to_string(Sha256::digest(certificate_request)); let fields: [(&'static str, &str); FIELD_COUNT] = [ ("registrationTokenUid", registration_token_uid), @@ -238,7 +237,7 @@ impl DeviceIdentity { /// Standard padded base64 of the certificate request, as the body carries it. pub fn certificate_request_base64(&self) -> Result { - Ok(BASE64_STANDARD.encode(self.certificate_request_der()?)) + Ok(BASE64_STANDARD.encode_to_string(self.certificate_request_der()?)) } /// Sign a transcript, producing the low-S fixed-width proof. @@ -252,20 +251,20 @@ impl DeviceIdentity { RegistrationProof { algorithm: PROOF_ALGORITHM.to_string(), - value: BASE64_URL_NO_PAD.encode(canonical.to_bytes()), + value: BASE64_URL_NO_PAD.encode_to_string(canonical.to_bytes()), } } pub(crate) fn sign_pending_registration_state(&self, state: &[u8]) -> String { let signature: Signature = self.signing_key.sign(state); - BASE64_URL_NO_PAD.encode(signature.normalize_s().to_bytes()) + BASE64_URL_NO_PAD.encode_to_string(signature.normalize_s().to_bytes()) } pub(crate) fn verifies_pending_registration_state(&self, state: &[u8], proof: &str) -> bool { - let Ok(octets) = BASE64_URL_NO_PAD.decode(proof) else { + let Ok(octets) = BASE64_URL_NO_PAD.decode_to_vec(proof) else { return false; }; - if BASE64_URL_NO_PAD.encode(&octets) != proof { + if BASE64_URL_NO_PAD.encode_to_string(&octets) != proof { return false; } let Ok(signature) = Signature::from_slice(&octets) else { diff --git a/rustfs/src/connect/offline/bundle_writer.rs b/rustfs/src/connect/offline/bundle_writer.rs index e46eb1a43..aea3194c8 100644 --- a/rustfs/src/connect/offline/bundle_writer.rs +++ b/rustfs/src/connect/offline/bundle_writer.rs @@ -25,7 +25,7 @@ use std::path::Path; use std::path::PathBuf; #[cfg(target_os = "linux")] -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use base64_simd::URL_SAFE_NO_PAD; #[cfg(target_os = "linux")] use p256::ecdsa::{Signature, SigningKey, signature::Signer as _}; #[cfg(target_os = "linux")] @@ -163,7 +163,7 @@ fn write_offline_bundle_unix( let produced_at = OffsetDateTime::from_unix_timestamp(context.produced_at_unix).map_err(|_| BundleError::InvalidMetadata)?; let produced_at = produced_at.format(&Rfc3339).map_err(|_| BundleError::InvalidMetadata)?; - let nonce = URL_SAFE_NO_PAD.encode(context.nonce); + let nonce = URL_SAFE_NO_PAD.encode_to_string(context.nonce); let device_key_id = hex_lower(&Sha256::digest(key.public_key_der())); let manifest_entries = entries .iter() @@ -397,7 +397,7 @@ fn sign(key: &DeviceIdentity, manifest: &[u8]) -> Result { input.push(0); input.extend_from_slice(manifest); let signature: Signature = signing_key.sign(&input); - Ok(URL_SAFE_NO_PAD.encode(signature.normalize_s().to_bytes())) + Ok(URL_SAFE_NO_PAD.encode_to_string(signature.normalize_s().to_bytes())) } #[cfg(target_os = "linux")] diff --git a/rustfs/src/connect/offline/enrollment.rs b/rustfs/src/connect/offline/enrollment.rs index a6c7c69d1..ef36ce4cd 100644 --- a/rustfs/src/connect/offline/enrollment.rs +++ b/rustfs/src/connect/offline/enrollment.rs @@ -32,8 +32,7 @@ //! are frozen beside it. Reordering the checks changes which reason a given //! artifact produces, which is itself part of the contract. -use base64::Engine as _; -use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD as BASE64_URL_NO_PAD}; +use base64_simd::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD as BASE64_URL_NO_PAD}; use p256::ecdsa::signature::{Signer as _, Verifier as _}; use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; use p256::pkcs8::DecodePrivateKey as _; @@ -367,7 +366,7 @@ impl OfflineEnrollment { // The octets that were transmitted. They are never re-serialised: every // later step signs and parses this same buffer. let bytes = BASE64_STANDARD - .decode(envelope.bytes.as_bytes()) + .decode_to_vec(envelope.bytes.as_bytes()) .map_err(|_| EnrollmentError::MalformedDocument)?; // Step 2: routing only. @@ -440,8 +439,8 @@ impl OfflineEnrollment { challenge_nonce: &challenge.nonce, challenge_proof: &challenge.challenge_proof, device_key_id: key_id(&point), - device_public_key: BASE64_URL_NO_PAD.encode(point), - device_nonce: BASE64_URL_NO_PAD.encode(device_nonce), + device_public_key: BASE64_URL_NO_PAD.encode_to_string(point), + device_nonce: BASE64_URL_NO_PAD.encode_to_string(device_nonce), produced_at, }; @@ -451,7 +450,7 @@ impl OfflineEnrollment { let signature = sign(key, TAG_RESPONSE, &bytes)?; let envelope = SignedDocument { - bytes: BASE64_STANDARD.encode(&bytes), + bytes: BASE64_STANDARD.encode_to_string(&bytes), signature: DocumentSignature { algorithm: SIGNATURE_ALGORITHM.to_owned(), key_id: document.device_key_id, @@ -537,7 +536,7 @@ fn verify_trust_chain( /// checked against these, never against a re-encoding of the parsed link. fn decode_trust_link(entry: &SignedDocument) -> Result<(TrustLink, Vec), EnrollmentError> { let bytes = BASE64_STANDARD - .decode(entry.bytes.as_bytes()) + .decode_to_vec(entry.bytes.as_bytes()) .map_err(|_| EnrollmentError::MalformedDocument)?; let link = serde_json::from_slice(&bytes).map_err(|_| EnrollmentError::TrustChainInvalid)?; Ok((link, bytes)) @@ -559,7 +558,7 @@ fn decode_signature(signature: &DocumentSignature) -> Result Result Option<(VerifyingKey, [u8; PUBLIC_KEY_OCTET return None; } - let point: [u8; PUBLIC_KEY_OCTETS] = BASE64_URL_NO_PAD.decode(value).ok()?.try_into().ok()?; + let point: [u8; PUBLIC_KEY_OCTETS] = BASE64_URL_NO_PAD.decode_to_vec(value).ok()?.try_into().ok()?; if point[0] != UNCOMPRESSED_POINT { return None; } diff --git a/rustfs/src/connect/registration.rs b/rustfs/src/connect/registration.rs index 82d1dcae4..829002c9b 100644 --- a/rustfs/src/connect/registration.rs +++ b/rustfs/src/connect/registration.rs @@ -15,8 +15,7 @@ use std::io::Read; use std::sync::Arc; -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_NO_PAD; +use base64_simd::URL_SAFE_NO_PAD as BASE64_URL_NO_PAD; use p256::ecdsa::signature::Signer as _; use p256::ecdsa::{Signature, SigningKey}; use p256::pkcs8::DecodePrivateKey as _; @@ -89,10 +88,10 @@ impl RegistrationToken { } let document: RegistrationTokenDocument = serde_json::from_slice(&bytes).map_err(TokenError::Invalid)?; let decoded = BASE64_URL_NO_PAD - .decode(&document.registration_token_secret) + .decode_to_vec(&document.registration_token_secret) .map(Zeroizing::new) .map_err(|_| TokenError::SecretShape)?; - if decoded.len() != 32 || BASE64_URL_NO_PAD.encode(&decoded) != document.registration_token_secret { + if decoded.len() != 32 || BASE64_URL_NO_PAD.encode_to_string(&decoded) != document.registration_token_secret { return Err(TokenError::SecretShape); } if !is_uuid_v7(&document.registration_token_uid) @@ -198,10 +197,10 @@ impl<'a> RotationRequest<'a> { request_id: &'a str, certificate_request: &'a str, ) -> Result { - let csr_der = base64::engine::general_purpose::STANDARD - .decode(certificate_request) + let csr_der = base64_simd::STANDARD + .decode_to_vec(certificate_request) .map_err(|_| CredentialValidationError::CertificateRequest)?; - let csr_digest = BASE64_URL_NO_PAD.encode(Sha256::digest(&csr_der)); + let csr_digest = BASE64_URL_NO_PAD.encode_to_string(Sha256::digest(&csr_der)); let transcript = rotation_transcript(credential_fingerprint, device_name, request_id, &csr_digest)?; let key = identity .to_pkcs8_der() @@ -216,7 +215,7 @@ impl<'a> RotationRequest<'a> { certificate_request, proof: ProofOwned { algorithm: "ES256".to_string(), - value: BASE64_URL_NO_PAD.encode(canonical.to_bytes()), + value: BASE64_URL_NO_PAD.encode_to_string(canonical.to_bytes()), }, }) } @@ -475,8 +474,8 @@ pub(crate) fn public_key_fingerprint(identity: &DeviceIdentity) -> String { } pub(crate) fn certificate_request_matches(encoded: &str, identity: &DeviceIdentity) -> Result { - let der = base64::engine::general_purpose::STANDARD - .decode(encoded) + let der = base64_simd::STANDARD + .decode_to_vec(encoded) .map_err(|_| CredentialValidationError::CertificateRequest)?; let (remaining, request) = X509CertificationRequest::from_der(&der).map_err(|_| CredentialValidationError::CertificateRequest)?; diff --git a/rustfs/src/storage/helper.rs b/rustfs/src/storage/helper.rs index 20104dac5..5b6a2f88e 100644 --- a/rustfs/src/storage/helper.rs +++ b/rustfs/src/storage/helper.rs @@ -446,7 +446,6 @@ mod tests { use crate::server::{refresh_audit_module_enabled, refresh_notify_module_enabled}; use crate::storage::access::ReqInfo; use crate::storage::request_context::RequestContext; - use base64::Engine as _; use http::{Extensions, HeaderMap, HeaderValue, Method, Uri}; use metrics::{Counter, CounterFn, Gauge, GaugeFn, Histogram, HistogramFn, Key, KeyName, Metadata, SharedString, Unit}; use rustfs_audit::ObjectVersion; @@ -765,10 +764,7 @@ mod tests { std::collections::HashMap::from([ ("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()), ("x-rustfs-encryption-key-id".to_string(), "finance-key".to_string()), - ( - "x-rustfs-encryption-key".to_string(), - base64::engine::general_purpose::STANDARD.encode([7u8; 48]), - ), + ("x-rustfs-encryption-key".to_string(), base64_simd::STANDARD.encode_to_string([7u8; 48])), ("x-rustfs-encryption-algorithm".to_string(), "aws:kms".to_string()), ]) } @@ -840,7 +836,7 @@ mod tests { let rendered = serde_json::to_string(&tags).expect("audit tags serialize"); assert!( - !rendered.contains(&base64::engine::general_purpose::STANDARD.encode([7u8; 48])), + !rendered.contains(&base64_simd::STANDARD.encode_to_string([7u8; 48])), "the audit entry must not carry the wrapped data key: {rendered}" ); }, diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index 6da76d0b2..97a116d93 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -83,7 +83,7 @@ use aes_gcm::{ aead::{Aead, KeyInit}, }; use async_trait::async_trait; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use base64_simd::STANDARD as BASE64_STANDARD; #[cfg(feature = "rio-v2")] use chacha20poly1305::ChaCha20Poly1305; #[cfg(feature = "rio-v2")] @@ -153,7 +153,7 @@ fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] { } fn md5_base64(input: impl AsRef<[u8]>) -> String { - BASE64_STANDARD.encode(md5_bytes(input)) + BASE64_STANDARD.encode_to_string(md5_bytes(input)) } use super::Error; @@ -562,7 +562,7 @@ pub(crate) fn extract_ssekms_context_from_headers(headers: &HeaderMap) -> Result let value = v .to_str() .map_err(|_| sse_invalid_argument("The x-amz-server-side-encryption-context header must be valid UTF-8."))?; - let decoded = BASE64_STANDARD.decode(value).map_err(|_| { + let decoded = BASE64_STANDARD.decode_to_vec(value).map_err(|_| { sse_invalid_argument("The x-amz-server-side-encryption-context header must be valid base64-encoded JSON.") })?; @@ -1320,7 +1320,7 @@ fn stored_envelope_master_key_version(metadata: &HashMap) -> Opt // this lookup never reads, so the normalized result is identical without it. let encoded = normalize_managed_metadata(metadata, None); let encoded = encoded.get(INTERNAL_ENCRYPTION_KEY_HEADER)?; - let envelope = BASE64_STANDARD.decode(encoded).ok()?; + let envelope = BASE64_STANDARD.decode_to_vec(encoded).ok()?; envelope_master_key_version(&envelope) } @@ -1516,7 +1516,7 @@ fn build_object_encryption_context( fn encode_minio_kms_context(context: &HashMap) -> Result { let encoded = serde_json::to_vec(context) .map_err(|e| ApiError::from(StorageError::other(format!("Failed to serialize KMS context: {e}"))))?; - Ok(BASE64_STANDARD.encode(encoded)) + Ok(BASE64_STANDARD.encode_to_string(encoded)) } fn decode_minio_kms_context(metadata: &HashMap) -> Result>, ApiError> { @@ -1524,7 +1524,7 @@ fn decode_minio_kms_context(metadata: &HashMap) -> Result